Job Postings & Applications
Part of the Anaya Care product wiki. See 00-overview.md.
Purpose
The job-postings module is the platform's hiring marketplace. Businesses (via admins/care managers) publish job postings — optionally tied to a specific client and/or an uncovered shift — and care providers browse and apply from the mobile app. Reviewers triage and accept/reject applications on the web dashboard. Accepting an application can onboard an unaffiliated caregiver into the business, auto-assign them to the posting's client, and fill the originating shift.
Backend module: apps/backend/src/job-postings/ (job-postings.module.ts), one controller (controllers/job-postings.controller.ts) and two services (services/job-postings.service.ts, services/job-applications.service.ts).
Entities & Data Model
JobPosting
- Collection:
job_postings(apps/backend/src/job-postings/entities/job-posting.entity.ts:46) - Soft-deleted via
isDeletedflag; timestamps enabled;toJSONtransform renamesclientId→clientwhen populated (lines 48–66).
| Field | Type | Notes |
|---|---|---|
business | ObjectId → Business | required, indexed; auto-injected/tenant-scoped by global business-scope plugin (apps/backend/src/common/plugins/business-scope.plugin.ts) |
clientId | ObjectId → Client | optional; links the posting to a specific client (line 79–85) |
title | string | required |
description | string | optional |
skills | ObjectId[] → Skill | required-skill list (line 93) |
location | embedded Address | required (line 96) |
compensation | embedded { minHourlyRate, maxHourlyRate, isNegotiable } | required (lines 31–43) |
status | JobPostingStatus | default Draft (line 102–108) |
urgency | JobPostingUrgency | Normal/Urgent, default Normal |
gender | JobPostingGender | optional, client gender (Male/Female/Other) |
isFeatured | boolean | default false |
createdBy | ObjectId → User | required |
isDeleted | boolean | default false (soft delete) |
sourceDocument | { sourceType: 'Shift', sourceId } | optional; set when converted from a shift (lines 138–151). JobPostingSourceType = 'Shift' is the only value (packages/shared/src/types/job-posting.ts:263) |
schedule | embedded { dtstart, durationMinutes, rrule?, tzid } | optional concrete time slot (lines 13–29, 153–154) |
publishedAt / closedAt | Date | set by publish/close |
Indexes: (business, createdAt), (clientId, status), (status, urgency), (location.city, location.region), (isFeatured, status), schedule.dtstart (lines 168–176).
JobApplication
- Collection:
job_applications(apps/backend/src/job-postings/entities/job-application.entity.ts:8)
| Field | Type | Notes |
|---|---|---|
business | ObjectId → Business | required, indexed; copied from the posting at apply time (job-applications.service.ts:105) |
jobPosting | ObjectId → JobPosting | required |
caregiver | ObjectId → User | required (the applicant) |
status | JobApplicationStatus | default Submitted |
coverMessage | string | optional, max 2000 chars (dto/create-job-application.dto.ts) |
adminNotes | string | optional reviewer notes, max 2000 chars; returned to the applicant on the posting-detail endpoint (controllers/job-postings.controller.ts:498) |
reviewedBy / reviewedAt | ObjectId → User / Date | set on any status change (job-applications.service.ts:297–301) |
Unique compound index (jobPosting, caregiver) prevents duplicate applications (entity line 81), plus (business, createdAt), (jobPosting, status), (caregiver, status).
Shared enums (packages/shared/src/enums/domain-status.ts:437–469):
JobPostingStatus:Draft|Published|ClosedJobPostingUrgency:Normal|UrgentJobPostingGender:Male|Female|OtherJobApplicationStatus:Submitted|Reviewed|Rejected|Accepted
The local backend enums file is deprecated and empty (apps/backend/src/job-postings/enums/job-posting.enums.ts).
Workflows & State Machines
Posting lifecycle
Status is a plain field set by dedicated endpoints — there is no transition guard/state machine (any of these can be called from any current status):
- Create —
POST /job-postings(JOB_POSTINGS_CREATE). Defaults toDraft, but the DTO accepts astatus, so a posting can be created directly asPublished, which immediately notifies care providers (job-postings.service.ts:62–64). - Convert from shift —
POST /shifts/:shiftId/convert-to-job-posting(JOB_POSTINGS_CONVERT,apps/backend/src/shifts/shifts.controller.ts:1462). Shift must beGENERATED/DECLINED/ASSIGNED/NO_SHOWand not already converted; the shift'sdtstart/durationMinutes/tzidis copied intoposting.schedule,sourceDocumentis set, the posting is always created as Draft, andshift.jobPostingIdlinks back (with rollback soft-delete if the shift save fails) (shifts.service.ts:4919–4993; duplicate-source guard injob-postings.service.ts:398–406). - Publish —
POST /job-postings/:id/publish(JOB_POSTINGS_PUBLISH): setsPublished+publishedAt, then notifies active, identity-verified CareProviders who haven't opted out of jobs (job-postings.service.ts:253–326). - Unpublish —
POST /:id/unpublish: sets status back toDraftunconditionally (also "reopens" aClosedposting to Draft) (job-postings.service.ts:328–348). - Close —
POST /:id/close: setsClosed+closedAt(job-postings.service.ts:350–371). - Close & reject remaining —
POST /:id/close-and-reject(JOB_POSTINGS_APPLICATIONS_BULK_REJECT): closes the posting and bulk-rejects allSubmitted/Reviewedapplications, notifying each applicant (job-applications.service.ts:464–525). - Delete —
DELETE /:id(JOB_POSTINGS_DELETE): soft delete (isDeleted: true) (job-postings.service.ts:373–382).
Application lifecycle
- Apply —
POST /job-postings/:id/apply(JOB_POSTINGS_APPLY, granted to CareProvider by default —packages/shared/src/constants/default-role-permissions.ts:245). Controller pre-checks: opted-out providers get a 403 (controller:514–518); schedule conflicts/daily-hour overruns get a 403 (controller:519–524). Service checks: posting must exist, not deleted, andPublished(job-applications.service.ts:74–82); applicant's role must beCareProvider(lines 85–88); duplicate application → 409 (lines 91–100). Creates the application asSubmitted, notifies management-role users, and emits a platform-log event (lines 102–126). - Review/decide —
PUT /job-postings/applications/:applicationId. The endpoint admits holders of eitherJOB_POSTINGS_APPLICATIONS_REVIEWor..._DECIDE(controller:327–332); the service additionally requiresDECIDEto set the terminal statusesAccepted/Rejected(separation of duties,job-applications.service.ts:276–292). Any status change stampsreviewedBy/reviewedAt;adminNotesmay be updated alongside. Applicant is notified only onAccepted/Rejected(lines 423–462). - No transition guards on application status: the service accepts any enum value at any time — an
Acceptedapplication can later be set toRejected(and would re-trigger acceptance side effects in the other direction is not undone), an application can be accepted while its posting isClosed, and multiple applications on the same posting can each beAccepted. Accepting an applicant does not auto-close the posting; admins use close-and-reject for that.
What acceptance creates
On status: Accepted (job-applications.service.ts:327–418):
- Schedule validation (only if the posting has a
schedule): re-runsShiftsService.checkSchedulingConflictandcheckDailyHourLimit; throws 409/400 and aborts acceptance on failure (lines 332–364). Postings without a schedule skip this entirely. - Business onboarding: if the caregiver has no
business, they are assigned to the posting's business withbusinessAssignmentStatus: ACTIVE, using_bypassBusinessScope(lines 366–395). This is how unaffiliated caregivers join a tenant. - Client auto-assignment (if
posting.clientId): emitsjob-application.accepted-for-client(line 398).CaregiverAutoAssignListener(apps/backend/src/clients/listeners/caregiver-auto-assign.listener.ts:25) callsCaregiverAssignmentService.inviteCaregiver, creating aCaregiverAssignmentdocument (collectioncaregiver_assignments) withcurrentStatus: ASSIGNEDand note "Auto-assigned from job posting: …" (client-caregiver-assignments.service.ts:121–185). A pre-existing active assignment is logged and skipped. - Shift fill (if
sourceDocument.sourceType === 'Shift'): emitsjob-application.accepted-for-shift(line 409).ShiftAssignFromJobPostingListener(apps/backend/src/shifts/listeners/shift-assign-from-job-posting.listener.ts:42) setsshift.caregiver,currentStatus: ASSIGNED,assignmentMethod: VOLUNTEER_PICKED,jobPostingId, and appends status history — unless the shift is already filled (ACCEPTED/IN_PROGRESS/ended statuses), in which case it skips.
No ClientSchedule or new shifts are created by acceptance — only the caregiver-client link and (optionally) assignment of the one originating shift. Both listeners are fire-and-forget: errors are logged but the acceptance still succeeds (listener catch blocks).
Schedule-conflict / "16-hour" indicator (critical pain point)
Stated requirement: "a care provider must NOT exceed 16 hours of scheduled care in a single day, and reviewers should see an indicator." What the code actually does:
There is no 16-hour limit anywhere in the codebase. The implemented caps are SHIFT_LIMITS.MAX_DAILY_HOURS = 12 (cross-client) and SHIFT_LIMITS.MAX_DAILY_HOURS_SAME_CLIENT = 24 (packages/shared/src/constants/common.ts:294–305). A grep for 16 in the job-postings module, shifts service, and job screens finds nothing hour-related.
Care-provider side (browse/apply) — enforced by this module's controller, only for postings that have a schedule:
hasSlotConflict/resolveSlotConflict(controllers/job-postings.controller.ts:61–121) callShiftsService.checkSchedulingConflict(overlap with existingASSIGNED/ACCEPTED/IN_PROGRESSshifts,shifts.service.ts:977–1018) andcheckDailyHourLimit(12h/24h dual caps with midnight-spanning handling,shifts.service.ts:5216–5286).GET /job-postings/featuredandGET /job-postings/searchsilently hide conflicting postings from the list (controller:358–365, 419–426).GET /job-postings/:idreturns a structuredunavailabilityReason(not-accepting-jobs|shift-conflict|daily-limit-cross-client|daily-limit-same-client,packages/shared/src/types/job-posting.ts:164–168); the mobile detail screen shows the banner and disables Apply ("Can't Apply") (apps/mobile/app/(app)/(jobs)/[id].tsx:175–176, 526–570).POST /:id/applyrejects with 403 on conflict (controller:519–524).- Postings without a
scheduleare never checked anywhere.
Reviewer side (applicant list) — NOT computed by the job-postings backend. GET /job-postings/:id/applications (findByJobPosting, job-applications.service.ts:171–213) returns applications with caregiver contact data only — no conflict flag, no hours. The indicator that exists is composed on the web frontend: each ApplicationCard renders a CaregiverAvailabilityCalendar (apps/web/app/(app)/(admin)/dashboard/job-postings/_components/application-card.tsx:262–265) which calls two shifts-module endpoints:
GET /shifts/availability/check→{ hasConflict, wouldExceed, currentHours, projectedHours, isEligible }(apps/backend/src/shifts/shifts.controller.ts:228–263), rendered as a green "Available" / red "Conflict" / amber "Exceeds 12h limit" badge (caregiver-availability-calendar.tsx:244–275).GET /shifts/caregiver/:caregiverId/calendar→ the caregiver's shifts around the job date with daily totals (shifts.controller.ts:265–283,shifts.service.ts:5292+).
Caveats of the current indicator: it only renders when the posting has a schedule (enabled: !!jobSchedule); and the web check omits the clientId query param (caregiver-availability-calendar.tsx:85–95), so the same-client 24h cap is never engaged — the indicator evaluates everything against the 12h cross-client cap, which can disagree with the acceptance-time check that does pass targetClientId (job-applications.service.ts:341–347).
Acceptance time is the hard backstop: accepting an application on a scheduled posting re-runs both checks server-side and blocks with a descriptive error (job-applications.service.ts:332–364, message built by formatDailyLimitMessage, shifts.service.ts:5129–5145).
The hour-limit and conflict machinery itself lives in the scheduling module — see 05-scheduling-and-shifts.md.
Business Rules & Constraints
- Tenancy: both schemas carry
business, so the global business-scope plugin auto-filters every query by the CLS business id; when CLS has no business (e.g., an unaffiliated CareProvider), no filter is injected — which is what lets unassigned providers browse postings across all businesses (apps/backend/src/common/plugins/business-scope.plugin.ts:40–66). - Compensation:
minHourlyRate ≤ maxHourlyRateenforced on create, update, and convert (job-postings.service.ts:44–52, 223–234, 408–413); DTO bounds 0–1000 (dto/compensation.dto.ts). - Only Published postings are visible/appliable to caregivers (
findPublished,findPublishedById, apply check,job-postings.service.ts:160–168, 200–217;job-applications.service.ts:74–82). - Only CareProviders can apply — both via the
JOB_POSTINGS_APPLYpermission and a role check in the service (job-applications.service.ts:85–88). - One application per caregiver per posting — service check + unique index (
job-applications.service.ts:91–100; entity line 81). - Opt-out respected everywhere: providers with
CareProviderProfile.isAcceptingJobs === falseget empty featured/search results, anot-accepting-jobsreason on detail, no publish notification, and a 403 on apply (controller:128–137, 353–355, 404–416, 486–492, 514–518;job-postings.service.ts:296–302). No profile ⇒ treated as accepting. - Separation of duties:
REVIEWpermission can triage (Reviewed, notes);DECIDEis required forAccepted/Rejected(job-applications.service.ts:276–292). - Featured feed = Published postings that are
isFeaturedorUrgent, sorted featured-first (job-postings.service.ts:170–183). - Skill-match enrichment (CareProviders only): each posting response gains
skillsWithMatch[].matched,matchedSkillsCount,totalSkillsRequiredby comparing against the provider'sCareProviderProfile.skills(controller:144–196). - Publish notifications go to active CareProviders with
verificationStatus: ACTIVEwho haven't opted out (job-postings.service.ts:279–326); application-submitted notifications go toMANAGEMENT_ROLES(Owner/Admin/SuperAdmin/System —packages/shared/src/enums/user-role.ts:42–53) (job-applications.service.ts:134–169). - Applicant caregiver populate uses
match: { business: { $exists: true } }(job-applications.service.ts:49–53) — applicants whose user document lacks thebusinesspath populate asnullin review lists.
Surfaces (Web & Mobile)
Web — admins / care managers (apps/web/app/(app)/(admin)/dashboard/job-postings/)
- List
page.tsx+_components/job-postings-list.tsx/job-posting-card.tsx— usesGET /job-postings/admin/list(apps/web/lib/job-postings-api.ts:29–35). - Create/Edit
add/page.tsx,[id]/edit/page.tsx,_components/job-posting-form.tsx. - Detail
[id]/page.tsx; publish/unpublish/close/close-and-reject/delete actions vialib/job-postings-api.ts:60–91. - Applications review
[id]/applications/page.tsx— status tabs (All/Submitted/Reviewed/Accepted/Rejected), accept/reject with an optional notes dialog whose text is saved asadminNotes(_components/application-card.tsx:282–316), and a close-and-reject confirmation dialog. - Frontend-only logic in
application-card.tsx: caregiver-to-job distance is computed client-side with a Haversine formula from stored coordinates (lines 39–55, 138–147); the availability badge/calendar described above is composed client-side from shifts endpoints. - Convert dialog:
apps/web/components/job-postings/convert-to-job-posting-dialog.tsx, launched from the shifts UI to callPOST /shifts/:shiftId/convert-to-job-posting. - Platform view (SuperAdmin):
apps/web/app/(app)/(platform)/platform/job-postings/page.tsxlists postings via the sameadmin/listendpoint with unpublish/close/delete actions (lines 94–112, 264).
Mobile — care providers (apps/mobile/app/(app)/(jobs)/)
- Browse/search
index.tsx(list + filter sheet) andmap.tsx(map view) —GET /job-postings/searchviaapps/mobile/lib/job-postings-api.ts:23–40. Unassigned providers also get a dedicated Jobs tab (app/(app)/(tabs)/jobs/index.tsx); assigned providers reach jobs through the "Care Corner" entry (route-group doc in(jobs)/_layout.tsx). - Detail + apply
[id].tsx— shows skill-match badges, application status card withadminNotes, and the backend-providedunavailabilityReasonbanner; Apply button is disabled to "Can't Apply" when a reason is present or already applied (lines 175–176, 514–570). Apply submits an optional cover message viaPOST /job-postings/:id/apply. - My applications
application/[id].tsxandGET /job-postings/my/applications(lib/job-postings-api.ts:53–56). - A duplicate route group
apps/mobile/app/(jobs)/exists with near-identical screens (see Gaps).
Families and medical professionals have no job-posting surfaces.
Cross-Module Dependencies
- job-postings → shifts (
forwardRef,job-postings.module.ts:30):ShiftsService.checkSchedulingConflict/checkDailyHourLimit/formatDailyLimitMessagefor browse-, apply-, and accept-time validation; shift→posting conversion (shifts.service.ts:4919);job-application.accepted-for-shiftevent consumed byshift-assign-from-job-posting.listener.ts. See 05-scheduling-and-shifts.md. - job-postings → clients:
job-application.accepted-for-clientevent consumed bycaregiver-auto-assign.listener.ts→CaregiverAssignmentService.inviteCaregiver(createscaregiver_assignmentsdoc);clientIdref on postings. - job-postings → notification:
NotificationService.sendUnifiedNotificationfor posting-published, application-submitted, application-accepted/rejected (job-postings.service.ts:309,job-applications.service.ts:153, 443). - job-postings → users:
Usermodel (role/verification checks, business onboarding on acceptance) andCareProviderProfile(isAcceptingJobsopt-out, skills for match enrichment) (controller:128–196). - job-postings → platform-logs: emits
PLATFORM_LOG_ACTIVITY_EVENTwithJOB_APPLICATION_SUBMITTEDon apply (job-applications.service.ts:115–126). - job-postings → skills: posting
skillsrefs populated for display and matching. - @anaya/shared: all enums,
SHIFT_LIMITS,UnavailabilityReason,BusinessPermissionset (packages/shared/src/constants/default-role-permissions.ts:155–166), response types (packages/shared/src/types/job-posting.ts).
Open Questions & Gaps
- The "16-hour daily limit" is not implemented as specified. The code enforces 12h cross-client and 24h same-client daily caps (
packages/shared/src/constants/common.ts:294–305); no 16-hour constant exists anywhere. Whether 16h was superseded by the 12/24 split or simply never implemented cannot be determined from code. - No backend applicant-eligibility indicator in this module.
GET /job-postings/:id/applicationsreturns no conflict/hours data per applicant; the web review screen composes the indicator client-side from two shifts endpoints, one call per applicant card. Postings without ascheduleget no indicator and no acceptance-time hour check at all. - Web availability indicator ignores the same-client cap.
caregiver-availability-calendar.tsx:85–95omitsclientIdfrom/shifts/availability/check, so the badge tests against the 12h cross-client cap only, while acceptance (job-applications.service.ts:341–347) passestargetClientIdand uses both caps — the badge can show "Exceeds 12h limit" for a same-client job that acceptance would actually allow. - No application state machine. Any status→status change is allowed (
job-applications.service.ts:265–324): re-accepting, un-accepting (with no rollback of acceptance side effects), accepting on aClosedposting, and accepting multiple applicants on one posting are all possible. Accepting does not auto-close the posting. - Unpublish can resurrect a Closed posting to
Draftwith no guard (job-postings.service.ts:328–348);closedAtis not cleared, andpublishedAtis not cleared on unpublish. - Publish notifications likely miss the primary audience.
notifyJobPostingPublishedqueries theUsermodel inside an admin's CLS business scope (business-scope plugin), so CareProviders withbusiness: null— the unassigned providers the marketplace targets — are never matched by the scopedfind(job-postings.service.ts:283–294). Cannot determine from code whether this is intentional. - Possible cross-tenant notification on apply.
notifyApplicationSubmittedqueriesMANAGEMENT_ROLESusers with no explicit business filter (job-applications.service.ts:139–146); when the applicant is an unaffiliated caregiver, CLS business is null and the plugin injects nothing, so admins of all businesses may be notified instead of only the posting's business. getApplicationCountByStatusaggregation likely broken. It$matches{ jobPosting: jobPostingId }with a string id (job-applications.service.ts:557–559); aggregation pipelines bypass Mongoose casting (noted inbusiness-scope.plugin.ts:70–72), so the match against stored ObjectIds would return zero counts. Runtime behavior cannot be fully confirmed from code.- Conflict filtering distorts pagination.
search/featuredfilter conflicting postings after the paginated query (controller:358–365, 419–426), so pages can return fewer items thanlimitwhilepagination.totalstill counts hidden postings; checks run sequentially per posting (N+1 onshift_assignments). schedule.rruleis stored but never evaluated — conflict and hour checks only consider the singledtstart + durationMinutesoccurrence; recurring schedules are not expanded anywhere in this module.- Duplicate mobile route groups:
apps/mobile/app/(jobs)/andapps/mobile/app/(app)/(jobs)/contain parallel screens with identical layout docs; which is live (and whether the root group is dead code) cannot be determined from code alone. - Unused permissions:
JOB_POSTINGS_DUPLICATEandJOB_POSTINGS_GENERATEare defined and granted (default-role-permissions.ts:158, 162) but no backend endpoint requires them. - Dead transform code: the posting
toJSON"business rename" assignsret.business = ret.businessthen deletes it (job-posting.entity.ts:54–58), meaning populatedbusinessobjects are stripped from JSON output even thoughfindAllpopulatesbusiness(job-postings.service.ts:135). - Applicant populate quirk:
CAREGIVER_POPULATEmatchesbusiness: { $exists: true }(job-applications.service.ts:49–53), so applicants without the field render ascaregiver: nullin admin lists; combined with plugin scoping on populate queries, cross-business applicants' visibility in review lists cannot be fully determined from code.