Anaya Care Docs

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 isDeleted flag; timestamps enabled; toJSON transform renames clientIdclient when populated (lines 48–66).
FieldTypeNotes
businessObjectId → Businessrequired, indexed; auto-injected/tenant-scoped by global business-scope plugin (apps/backend/src/common/plugins/business-scope.plugin.ts)
clientIdObjectId → Clientoptional; links the posting to a specific client (line 79–85)
titlestringrequired
descriptionstringoptional
skillsObjectId[] → Skillrequired-skill list (line 93)
locationembedded Addressrequired (line 96)
compensationembedded { minHourlyRate, maxHourlyRate, isNegotiable }required (lines 31–43)
statusJobPostingStatusdefault Draft (line 102–108)
urgencyJobPostingUrgencyNormal/Urgent, default Normal
genderJobPostingGenderoptional, client gender (Male/Female/Other)
isFeaturedbooleandefault false
createdByObjectId → Userrequired
isDeletedbooleandefault 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)
scheduleembedded { dtstart, durationMinutes, rrule?, tzid }optional concrete time slot (lines 13–29, 153–154)
publishedAt / closedAtDateset 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)
FieldTypeNotes
businessObjectId → Businessrequired, indexed; copied from the posting at apply time (job-applications.service.ts:105)
jobPostingObjectId → JobPostingrequired
caregiverObjectId → Userrequired (the applicant)
statusJobApplicationStatusdefault Submitted
coverMessagestringoptional, max 2000 chars (dto/create-job-application.dto.ts)
adminNotesstringoptional reviewer notes, max 2000 chars; returned to the applicant on the posting-detail endpoint (controllers/job-postings.controller.ts:498)
reviewedBy / reviewedAtObjectId → User / Dateset 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 | Closed
  • JobPostingUrgency: Normal | Urgent
  • JobPostingGender: Male | Female | Other
  • JobApplicationStatus: 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):

  • CreatePOST /job-postings (JOB_POSTINGS_CREATE). Defaults to Draft, but the DTO accepts a status, so a posting can be created directly as Published, which immediately notifies care providers (job-postings.service.ts:62–64).
  • Convert from shiftPOST /shifts/:shiftId/convert-to-job-posting (JOB_POSTINGS_CONVERT, apps/backend/src/shifts/shifts.controller.ts:1462). Shift must be GENERATED/DECLINED/ASSIGNED/NO_SHOW and not already converted; the shift's dtstart/durationMinutes/tzid is copied into posting.schedule, sourceDocument is set, the posting is always created as Draft, and shift.jobPostingId links back (with rollback soft-delete if the shift save fails) (shifts.service.ts:4919–4993; duplicate-source guard in job-postings.service.ts:398–406).
  • PublishPOST /job-postings/:id/publish (JOB_POSTINGS_PUBLISH): sets Published + publishedAt, then notifies active, identity-verified CareProviders who haven't opted out of jobs (job-postings.service.ts:253–326).
  • UnpublishPOST /:id/unpublish: sets status back to Draft unconditionally (also "reopens" a Closed posting to Draft) (job-postings.service.ts:328–348).
  • ClosePOST /:id/close: sets Closed + closedAt (job-postings.service.ts:350–371).
  • Close & reject remainingPOST /:id/close-and-reject (JOB_POSTINGS_APPLICATIONS_BULK_REJECT): closes the posting and bulk-rejects all Submitted/Reviewed applications, notifying each applicant (job-applications.service.ts:464–525).
  • DeleteDELETE /:id (JOB_POSTINGS_DELETE): soft delete (isDeleted: true) (job-postings.service.ts:373–382).

Application lifecycle

  • ApplyPOST /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, and Published (job-applications.service.ts:74–82); applicant's role must be CareProvider (lines 85–88); duplicate application → 409 (lines 91–100). Creates the application as Submitted, notifies management-role users, and emits a platform-log event (lines 102–126).
  • Review/decidePUT /job-postings/applications/:applicationId. The endpoint admits holders of either JOB_POSTINGS_APPLICATIONS_REVIEW or ..._DECIDE (controller:327–332); the service additionally requires DECIDE to set the terminal statuses Accepted/Rejected (separation of duties, job-applications.service.ts:276–292). Any status change stamps reviewedBy/reviewedAt; adminNotes may be updated alongside. Applicant is notified only on Accepted/Rejected (lines 423–462).
  • No transition guards on application status: the service accepts any enum value at any time — an Accepted application can later be set to Rejected (and would re-trigger acceptance side effects in the other direction is not undone), an application can be accepted while its posting is Closed, and multiple applications on the same posting can each be Accepted. 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):

  1. Schedule validation (only if the posting has a schedule): re-runs ShiftsService.checkSchedulingConflict and checkDailyHourLimit; throws 409/400 and aborts acceptance on failure (lines 332–364). Postings without a schedule skip this entirely.
  2. Business onboarding: if the caregiver has no business, they are assigned to the posting's business with businessAssignmentStatus: ACTIVE, using _bypassBusinessScope (lines 366–395). This is how unaffiliated caregivers join a tenant.
  3. Client auto-assignment (if posting.clientId): emits job-application.accepted-for-client (line 398). CaregiverAutoAssignListener (apps/backend/src/clients/listeners/caregiver-auto-assign.listener.ts:25) calls CaregiverAssignmentService.inviteCaregiver, creating a CaregiverAssignment document (collection caregiver_assignments) with currentStatus: ASSIGNED and note "Auto-assigned from job posting: …" (client-caregiver-assignments.service.ts:121–185). A pre-existing active assignment is logged and skipped.
  4. Shift fill (if sourceDocument.sourceType === 'Shift'): emits job-application.accepted-for-shift (line 409). ShiftAssignFromJobPostingListener (apps/backend/src/shifts/listeners/shift-assign-from-job-posting.listener.ts:42) sets shift.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) call ShiftsService.checkSchedulingConflict (overlap with existing ASSIGNED/ACCEPTED/IN_PROGRESS shifts, shifts.service.ts:977–1018) and checkDailyHourLimit (12h/24h dual caps with midnight-spanning handling, shifts.service.ts:5216–5286).
  • GET /job-postings/featured and GET /job-postings/search silently hide conflicting postings from the list (controller:358–365, 419–426).
  • GET /job-postings/:id returns a structured unavailabilityReason (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/apply rejects with 403 on conflict (controller:519–524).
  • Postings without a schedule are 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 ≤ maxHourlyRate enforced 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_APPLY permission 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 === false get empty featured/search results, a not-accepting-jobs reason 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: REVIEW permission can triage (Reviewed, notes); DECIDE is required for Accepted/Rejected (job-applications.service.ts:276–292).
  • Featured feed = Published postings that are isFeatured or Urgent, sorted featured-first (job-postings.service.ts:170–183).
  • Skill-match enrichment (CareProviders only): each posting response gains skillsWithMatch[].matched, matchedSkillsCount, totalSkillsRequired by comparing against the provider's CareProviderProfile.skills (controller:144–196).
  • Publish notifications go to active CareProviders with verificationStatus: ACTIVE who haven't opted out (job-postings.service.ts:279–326); application-submitted notifications go to MANAGEMENT_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 the business path populate as null in 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 — uses GET /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 via lib/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 as adminNotes (_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 call POST /shifts/:shiftId/convert-to-job-posting.
  • Platform view (SuperAdmin): apps/web/app/(app)/(platform)/platform/job-postings/page.tsx lists postings via the same admin/list endpoint with unpublish/close/delete actions (lines 94–112, 264).

Mobile — care providers (apps/mobile/app/(app)/(jobs)/)

  • Browse/search index.tsx (list + filter sheet) and map.tsx (map view) — GET /job-postings/search via apps/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 with adminNotes, and the backend-provided unavailabilityReason banner; 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 via POST /job-postings/:id/apply.
  • My applications application/[id].tsx and GET /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 / formatDailyLimitMessage for browse-, apply-, and accept-time validation; shift→posting conversion (shifts.service.ts:4919); job-application.accepted-for-shift event consumed by shift-assign-from-job-posting.listener.ts. See 05-scheduling-and-shifts.md.
  • job-postings → clients: job-application.accepted-for-client event consumed by caregiver-auto-assign.listener.tsCaregiverAssignmentService.inviteCaregiver (creates caregiver_assignments doc); clientId ref on postings.
  • job-postings → notification: NotificationService.sendUnifiedNotification for posting-published, application-submitted, application-accepted/rejected (job-postings.service.ts:309, job-applications.service.ts:153, 443).
  • job-postings → users: User model (role/verification checks, business onboarding on acceptance) and CareProviderProfile (isAcceptingJobs opt-out, skills for match enrichment) (controller:128–196).
  • job-postings → platform-logs: emits PLATFORM_LOG_ACTIVITY_EVENT with JOB_APPLICATION_SUBMITTED on apply (job-applications.service.ts:115–126).
  • job-postings → skills: posting skills refs populated for display and matching.
  • @anaya/shared: all enums, SHIFT_LIMITS, UnavailabilityReason, BusinessPermission set (packages/shared/src/constants/default-role-permissions.ts:155–166), response types (packages/shared/src/types/job-posting.ts).

Open Questions & Gaps

  1. 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.
  2. No backend applicant-eligibility indicator in this module. GET /job-postings/:id/applications returns 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 a schedule get no indicator and no acceptance-time hour check at all.
  3. Web availability indicator ignores the same-client cap. caregiver-availability-calendar.tsx:85–95 omits clientId from /shifts/availability/check, so the badge tests against the 12h cross-client cap only, while acceptance (job-applications.service.ts:341–347) passes targetClientId and uses both caps — the badge can show "Exceeds 12h limit" for a same-client job that acceptance would actually allow.
  4. 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 a Closed posting, and accepting multiple applicants on one posting are all possible. Accepting does not auto-close the posting.
  5. Unpublish can resurrect a Closed posting to Draft with no guard (job-postings.service.ts:328–348); closedAt is not cleared, and publishedAt is not cleared on unpublish.
  6. Publish notifications likely miss the primary audience. notifyJobPostingPublished queries the User model inside an admin's CLS business scope (business-scope plugin), so CareProviders with business: null — the unassigned providers the marketplace targets — are never matched by the scoped find (job-postings.service.ts:283–294). Cannot determine from code whether this is intentional.
  7. Possible cross-tenant notification on apply. notifyApplicationSubmitted queries MANAGEMENT_ROLES users 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.
  8. getApplicationCountByStatus aggregation likely broken. It $matches { jobPosting: jobPostingId } with a string id (job-applications.service.ts:557–559); aggregation pipelines bypass Mongoose casting (noted in business-scope.plugin.ts:70–72), so the match against stored ObjectIds would return zero counts. Runtime behavior cannot be fully confirmed from code.
  9. Conflict filtering distorts pagination. search/featured filter conflicting postings after the paginated query (controller:358–365, 419–426), so pages can return fewer items than limit while pagination.total still counts hidden postings; checks run sequentially per posting (N+1 on shift_assignments).
  10. schedule.rrule is stored but never evaluated — conflict and hour checks only consider the single dtstart + durationMinutes occurrence; recurring schedules are not expanded anywhere in this module.
  11. Duplicate mobile route groups: apps/mobile/app/(jobs)/ and apps/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.
  12. Unused permissions: JOB_POSTINGS_DUPLICATE and JOB_POSTINGS_GENERATE are defined and granted (default-role-permissions.ts:158, 162) but no backend endpoint requires them.
  13. Dead transform code: the posting toJSON "business rename" assigns ret.business = ret.business then deletes it (job-posting.entity.ts:54–58), meaning populated business objects are stripped from JSON output even though findAll populates business (job-postings.service.ts:135).
  14. Applicant populate quirk: CAREGIVER_POPULATE matches business: { $exists: true } (job-applications.service.ts:49–53), so applicants without the field render as caregiver: null in admin lists; combined with plugin scoping on populate queries, cross-business applicants' visibility in review lists cannot be fully determined from code.

On this page