Skip to content

Commit 2154c34

Browse files
authored
Merge branch 'dev' into feat/255-workplace-endpoints
2 parents 58b4905 + 55017ae commit 2154c34

File tree

57 files changed

+12059
-1188
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

57 files changed

+12059
-1188
lines changed
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
name: Jobboard Frontend Tests
2+
3+
on:
4+
push:
5+
paths:
6+
- "apps/jobboard-frontend/**"
7+
- ".github/workflows/frontend-tests.yml"
8+
pull_request:
9+
types: [opened, synchronize, reopened]
10+
paths:
11+
- "apps/jobboard-frontend/**"
12+
- ".github/workflows/frontend-tests.yml"
13+
14+
jobs:
15+
test:
16+
name: Run Vitest Suite
17+
runs-on: ubuntu-latest
18+
defaults:
19+
run:
20+
working-directory: apps/jobboard-frontend
21+
22+
steps:
23+
- name: Checkout repository
24+
uses: actions/checkout@v5
25+
26+
- name: Install pnpm
27+
uses: pnpm/action-setup@v4
28+
with:
29+
version: 10
30+
31+
- name: Setup Node.js
32+
uses: actions/setup-node@v5
33+
with:
34+
node-version: 22
35+
cache: pnpm
36+
cache-dependency-path: apps/jobboard-frontend/pnpm-lock.yaml
37+
38+
- name: Install dependencies
39+
run: pnpm install --frozen-lockfile
40+
41+
- name: Run unit tests
42+
run: pnpm test

apps/jobboard-backend/src/main/java/org/bounswe/jobboardbackend/exception/ErrorCode.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ public enum ErrorCode {
3232
WORKPLACE_NOT_FOUND(HttpStatus.NOT_FOUND),
3333
WORKPLACE_ALREADY_EXISTS(HttpStatus.CONFLICT),
3434
REVIEW_ALREADY_EXISTS(HttpStatus.CONFLICT),
35+
WORKPLACE_UNAUTHORIZED(HttpStatus.FORBIDDEN),
3536

3637
VALIDATION_ERROR(HttpStatus.BAD_REQUEST),
3738

@@ -50,6 +51,7 @@ public enum ErrorCode {
5051
BAD_REQUEST(HttpStatus.BAD_REQUEST),
5152

5253
JOB_APPLICATION_NOT_FOUND(HttpStatus.NOT_FOUND),
54+
APPLICATION_ALREADY_EXISTS(HttpStatus.CONFLICT),
5355
MISSING_FILTER_PARAMETER(HttpStatus.BAD_REQUEST),
5456

5557
JOB_POST_NOT_FOUND(HttpStatus.NOT_FOUND),

apps/jobboard-backend/src/main/java/org/bounswe/jobboardbackend/jobapplication/controller/JobApplicationController.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ public JobApplicationController(JobApplicationService service) {
2626
this.service = service;
2727
}
2828

29+
// Legacy endpoint with query parameters (kept for backward compatibility)
30+
// This endpoint is split into two separate endpoints.
2931
@PreAuthorize("isAuthenticated()")
3032
@GetMapping
3133
public ResponseEntity<List<JobApplicationResponse>> getFiltered(
@@ -41,6 +43,25 @@ public ResponseEntity<List<JobApplicationResponse>> getFiltered(
4143
}
4244
}
4345

46+
47+
@PreAuthorize("isAuthenticated()")
48+
@GetMapping("/job-seeker/{jobSeekerId}")
49+
public ResponseEntity<List<JobApplicationResponse>> getByJobSeeker(@PathVariable Long jobSeekerId) {
50+
return ResponseEntity.ok(service.getByJobSeekerId(jobSeekerId));
51+
}
52+
53+
@PreAuthorize("isAuthenticated()")
54+
@GetMapping("/job-post/{jobPostId}")
55+
public ResponseEntity<List<JobApplicationResponse>> getByJobPost(@PathVariable Long jobPostId) {
56+
return ResponseEntity.ok(service.getByJobPostId(jobPostId));
57+
}
58+
59+
@PreAuthorize("isAuthenticated()")
60+
@GetMapping("/workplace/{workplaceId}")
61+
public ResponseEntity<List<JobApplicationResponse>> getByWorkplace(@PathVariable Long workplaceId) {
62+
return ResponseEntity.ok(service.getByWorkplaceId(workplaceId));
63+
}
64+
4465
@PreAuthorize("isAuthenticated()")
4566
@GetMapping("/{id}")
4667
public ResponseEntity<JobApplicationResponse> getById(@PathVariable Long id) {

apps/jobboard-backend/src/main/java/org/bounswe/jobboardbackend/jobapplication/dto/JobApplicationResponse.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import lombok.*;
44
import org.bounswe.jobboardbackend.jobapplication.model.JobApplicationStatus;
5+
import org.bounswe.jobboardbackend.workplace.dto.WorkplaceBriefResponse;
56

67
import java.time.LocalDateTime;
78

@@ -13,7 +14,7 @@ public class JobApplicationResponse {
1314

1415
private Long id;
1516
private String title;
16-
private String company;
17+
private WorkplaceBriefResponse workplace;
1718
private String applicantName;
1819
private Long jobSeekerId;
1920
private Long jobPostId;

apps/jobboard-backend/src/main/java/org/bounswe/jobboardbackend/jobapplication/repository/JobApplicationRepository.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,8 @@ public interface JobApplicationRepository extends JpaRepository<JobApplication,
1212
List<JobApplication> findByJobSeekerId(Long jobSeekerId);
1313

1414
List<JobApplication> findByJobPostId(Long jobPostId);
15+
16+
List<JobApplication> findByJobPost_Workplace_Id(Long workplaceId);
17+
18+
boolean existsByJobSeekerIdAndJobPostId(Long jobSeekerId, Long jobPostId);
1519
}

apps/jobboard-backend/src/main/java/org/bounswe/jobboardbackend/jobapplication/service/JobApplicationService.java

Lines changed: 54 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
import org.bounswe.jobboardbackend.jobapplication.repository.JobApplicationRepository;
1313
import org.bounswe.jobboardbackend.jobpost.model.JobPost;
1414
import org.bounswe.jobboardbackend.jobpost.repository.JobPostRepository;
15+
import org.bounswe.jobboardbackend.workplace.service.WorkplaceService;
16+
import org.bounswe.jobboardbackend.workplace.repository.EmployerWorkplaceRepository;
17+
import org.bounswe.jobboardbackend.workplace.repository.WorkplaceRepository;
1518
import org.springframework.beans.factory.annotation.Value;
19+
import org.springframework.security.access.AccessDeniedException;
1620
import org.springframework.security.access.prepost.PreAuthorize;
1721
import org.springframework.security.core.Authentication;
1822
import org.springframework.security.core.context.SecurityContextHolder;
@@ -36,6 +40,9 @@ public class JobApplicationService {
3640
private final JobApplicationRepository applicationRepository;
3741
private final UserRepository userRepository;
3842
private final JobPostRepository jobPostRepository;
43+
private final WorkplaceService workplaceService;
44+
private final EmployerWorkplaceRepository employerWorkplaceRepository;
45+
private final WorkplaceRepository workplaceRepository;
3946

4047
// === GCS config ===
4148
@Value("${app.gcs.bucket:bounswe-jobboard}")
@@ -55,26 +62,51 @@ public class JobApplicationService {
5562

5663
public JobApplicationService(JobApplicationRepository applicationRepository,
5764
UserRepository userRepository,
58-
JobPostRepository jobPostRepository) {
65+
JobPostRepository jobPostRepository,
66+
WorkplaceService workplaceService,
67+
EmployerWorkplaceRepository employerWorkplaceRepository,
68+
WorkplaceRepository workplaceRepository) {
5969
this.applicationRepository = applicationRepository;
6070
this.userRepository = userRepository;
6171
this.jobPostRepository = jobPostRepository;
72+
this.workplaceService = workplaceService;
73+
this.employerWorkplaceRepository = employerWorkplaceRepository;
74+
this.workplaceRepository = workplaceRepository;
6275
}
6376

6477
@Transactional(readOnly = true)
6578
public List<JobApplicationResponse> getByJobSeekerId(Long jobSeekerId) {
79+
// Verify job seeker exists
80+
userRepository.findById(jobSeekerId)
81+
.orElseThrow(() -> new HandleException(ErrorCode.USER_NOT_FOUND, "Job seeker with ID " + jobSeekerId + " not found"));
82+
6683
return applicationRepository.findByJobSeekerId(jobSeekerId).stream()
6784
.map(this::toResponseDto)
6885
.collect(Collectors.toList());
6986
}
7087

7188
@Transactional(readOnly = true)
7289
public List<JobApplicationResponse> getByJobPostId(Long jobPostId) {
90+
// Verify job post exists
91+
jobPostRepository.findById(jobPostId)
92+
.orElseThrow(() -> new HandleException(ErrorCode.JOB_POST_NOT_FOUND, "Job post with ID " + jobPostId + " not found"));
93+
7394
return applicationRepository.findByJobPostId(jobPostId).stream()
7495
.map(this::toResponseDto)
7596
.collect(Collectors.toList());
7697
}
7798

99+
@Transactional(readOnly = true)
100+
public List<JobApplicationResponse> getByWorkplaceId(Long workplaceId) {
101+
// Verify workplace exists
102+
workplaceRepository.findById(workplaceId)
103+
.orElseThrow(() -> new HandleException(ErrorCode.WORKPLACE_NOT_FOUND, "Workplace with ID " + workplaceId + " not found"));
104+
105+
return applicationRepository.findByJobPost_Workplace_Id(workplaceId).stream()
106+
.map(this::toResponseDto)
107+
.collect(Collectors.toList());
108+
}
109+
78110
@Transactional(readOnly = true)
79111
public JobApplicationResponse getById(Long id) {
80112
return applicationRepository.findById(id)
@@ -87,12 +119,15 @@ public JobApplicationResponse getById(Long id) {
87119
public JobApplicationResponse create(CreateJobApplicationRequest dto) {
88120
User jobSeeker = getCurrentUser();
89121

90-
91-
92122
// Get job post
93123
JobPost jobPost = jobPostRepository.findById(dto.getJobPostId())
94124
.orElseThrow(() -> new HandleException(ErrorCode.JOB_POST_NOT_FOUND, "Job post with ID " + dto.getJobPostId() + " not found"));
95125

126+
// Check if jobseeker already applied to this job post
127+
if (applicationRepository.existsByJobSeekerIdAndJobPostId(jobSeeker.getId(), dto.getJobPostId())) {
128+
throw new HandleException(ErrorCode.APPLICATION_ALREADY_EXISTS, "You have already applied to this job post");
129+
}
130+
96131
// Create application
97132
JobApplication application = JobApplication.builder()
98133
.jobSeeker(jobSeeker)
@@ -107,16 +142,15 @@ public JobApplicationResponse create(CreateJobApplicationRequest dto) {
107142
}
108143

109144
@Transactional
145+
@PreAuthorize("hasRole('ROLE_EMPLOYER')")
110146
public JobApplicationResponse approve(Long id, String feedback) {
111147
JobApplication application = applicationRepository.findById(id)
112148
.orElseThrow(() -> new HandleException(ErrorCode.JOB_APPLICATION_NOT_FOUND, "Application with ID " + id + " not found"));
113149

114-
// Check authorization: only the employer who posted the job can approve
150+
// Check authorization: any employer of the workplace can approve
115151
User employer = getCurrentUser();
116-
117-
if (!application.getJobPost().getEmployer().getId().equals(employer.getId())) {
118-
throw new HandleException(ErrorCode.USER_UNAUTHORIZED, "Only the employer who posted the job can approve applications");
119-
}
152+
Long workplaceId = application.getJobPost().getWorkplace().getId();
153+
assertEmployerOfWorkplace(workplaceId, employer.getId());
120154

121155
// Update status
122156
application.setStatus(JobApplicationStatus.APPROVED);
@@ -128,16 +162,15 @@ public JobApplicationResponse approve(Long id, String feedback) {
128162
}
129163

130164
@Transactional
165+
@PreAuthorize("hasRole('ROLE_EMPLOYER')")
131166
public JobApplicationResponse reject(Long id, String feedback) {
132167
JobApplication application = applicationRepository.findById(id)
133168
.orElseThrow(() -> new HandleException(ErrorCode.JOB_APPLICATION_NOT_FOUND, "Application with ID " + id + " not found"));
134169

135-
// Check authorization: only the employer who posted the job can reject
170+
// Check authorization: any employer of the workplace can reject
136171
User employer = getCurrentUser();
137-
138-
if (!application.getJobPost().getEmployer().getId().equals(employer.getId())) {
139-
throw new HandleException(ErrorCode.USER_UNAUTHORIZED, "Only the employer who posted the job can reject applications");
140-
}
172+
Long workplaceId = application.getJobPost().getWorkplace().getId();
173+
assertEmployerOfWorkplace(workplaceId, employer.getId());
141174

142175
// Update status
143176
application.setStatus(JobApplicationStatus.REJECTED);
@@ -173,7 +206,7 @@ private JobApplicationResponse toResponseDto(JobApplication application) {
173206
.applicantName(jobSeeker.getUsername())
174207
.jobPostId(jobPost.getId())
175208
.title(jobPost.getTitle())
176-
.company(jobPost.getCompany())
209+
.workplace(workplaceService.toBriefResponse(jobPost.getWorkplace()))
177210
.status(application.getStatus())
178211
.specialNeeds(application.getSpecialNeeds())
179212
.feedback(application.getFeedback())
@@ -354,4 +387,11 @@ public void deleteCv(Long applicationId) {
354387
applicationRepository.save(application);
355388
}
356389
}
390+
391+
private void assertEmployerOfWorkplace(Long workplaceId, Long userId) {
392+
boolean isEmployer = employerWorkplaceRepository.existsByWorkplace_IdAndUser_Id(workplaceId, userId);
393+
if (!isEmployer) {
394+
throw new HandleException(ErrorCode.WORKPLACE_UNAUTHORIZED, "You are not an employer of this workplace");
395+
}
396+
}
357397
}

apps/jobboard-backend/src/main/java/org/bounswe/jobboardbackend/jobpost/controller/JobPostController.java

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,16 @@ public JobPostController(JobPostService service) {
2727
public ResponseEntity<List<JobPostResponse>> getFiltered(
2828
@RequestParam(required = false) String title,
2929
@RequestParam(required = false) String companyName,
30+
@RequestParam(required = false) String location,
31+
@RequestParam(required = false) String sector,
3032
@RequestParam(required = false) List<String> ethicalTags,
3133
@RequestParam(required = false) Integer minSalary,
3234
@RequestParam(required = false) Integer maxSalary,
3335
@RequestParam(required = false) Boolean isRemote,
34-
@RequestParam(required = false) Boolean inclusiveOpportunity
36+
@RequestParam(required = false) Boolean inclusiveOpportunity,
37+
@RequestParam(required = false) Boolean nonProfit
3538
) {
36-
return ResponseEntity.ok(service.getFiltered(title, companyName, ethicalTags, minSalary, maxSalary, isRemote, inclusiveOpportunity));
39+
return ResponseEntity.ok(service.getFiltered(title, companyName, location, sector, ethicalTags, minSalary, maxSalary, isRemote, inclusiveOpportunity, nonProfit));
3740
}
3841

3942
@PreAuthorize("isAuthenticated()")
@@ -42,6 +45,12 @@ public ResponseEntity<List<JobPostResponse>> getByEmployerId(@PathVariable Long
4245
return ResponseEntity.ok(service.getByEmployerId(employerId));
4346
}
4447

48+
@PreAuthorize("isAuthenticated()")
49+
@GetMapping("/workplace/{workplaceId}")
50+
public ResponseEntity<List<JobPostResponse>> getByWorkplaceId(@PathVariable Long workplaceId) {
51+
return ResponseEntity.ok(service.getByWorkplaceId(workplaceId));
52+
}
53+
4554
@PreAuthorize("isAuthenticated()")
4655
@GetMapping("/{id}")
4756
public ResponseEntity<JobPostResponse> getById(@PathVariable Long id) {

apps/jobboard-backend/src/main/java/org/bounswe/jobboardbackend/jobpost/dto/CreateJobPostRequest.java

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package org.bounswe.jobboardbackend.jobpost.dto;
22

33
import jakarta.validation.constraints.NotBlank;
4+
import jakarta.validation.constraints.NotNull;
45
import jakarta.validation.constraints.Size;
56
import lombok.*;
67

@@ -15,21 +16,18 @@ public class CreateJobPostRequest {
1516
private String title;
1617

1718
@NotBlank(message = "Description cannot be empty")
18-
@Size(max = 1000)
19+
@Size(max = 5000)
1920
private String description;
2021

21-
@NotBlank(message = "Company name is required")
22-
private String company;
23-
24-
@NotBlank(message = "Location is required")
25-
private String location;
22+
@NotNull(message = "Workplace ID is required")
23+
private Long workplaceId;
2624

2725
private boolean remote;
2826

29-
private String ethicalTags; // comma-separated, optional
30-
3127
private boolean inclusiveOpportunity; // targeted toward candidates with disabilities
3228

29+
private boolean nonProfit; // indicates if this is a non-profit/volunteer position
30+
3331
private Integer minSalary;
3432
private Integer maxSalary;
3533

apps/jobboard-backend/src/main/java/org/bounswe/jobboardbackend/jobpost/dto/JobPostResponse.java

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.fasterxml.jackson.annotation.JsonProperty;
44
import lombok.*;
5+
import org.bounswe.jobboardbackend.workplace.dto.WorkplaceBriefResponse;
56
import java.time.LocalDateTime;
67

78
@Data
@@ -14,11 +15,13 @@ public class JobPostResponse {
1415
private Long employerId;
1516
private String title;
1617
private String description;
17-
private String company;
18-
private String location;
18+
19+
// Workplace information
20+
private WorkplaceBriefResponse workplace;
21+
1922
private boolean remote;
20-
private String ethicalTags;
2123
private boolean inclusiveOpportunity;
24+
private boolean nonProfit;
2225
private Integer minSalary;
2326
private Integer maxSalary;
2427
private String contact;

apps/jobboard-backend/src/main/java/org/bounswe/jobboardbackend/jobpost/dto/UpdateJobPostRequest.java

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,17 @@ public class UpdateJobPostRequest {
1212
@Size(max = 100)
1313
private String title;
1414

15-
@Size(max = 1000)
15+
@Size(max = 5000)
1616
private String description;
1717

18-
private String company;
19-
20-
private String location;
18+
private Long workplaceId;
2119

2220
private Boolean remote;
2321

24-
private String ethicalTags;
25-
2622
private Boolean inclusiveOpportunity;
2723

24+
private Boolean nonProfit;
25+
2826
private Integer minSalary;
2927
private Integer maxSalary;
3028

0 commit comments

Comments
 (0)