Care Proposals
Part of the Anaya Care product wiki. See 00-overview.md.
Purpose
A care proposal is the agency-side document that turns an intake (initial assessment) into a concrete, priced offer of care for a client: client info, a routine-task assessment, a written care plan, a service definition (type, value-added services, weekly schedule), and a rate computation. It moves through an internal review/approval workflow, is emailed to an external "representative" (the client's contact person, who gets an auto-created account), and — once the representative signs and accepts — is converted into an actual Client record, at which point the proposal enters its terminal In Service state (apps/backend/src/care-proposals/, packages/shared/src/care-proposal-transitions.ts).
Entities & Data Model
CareProposal — collection care_proposals
Defined in apps/backend/src/care-proposals/entities/care-proposal.entity.ts.
| Field | Type | Notes |
|---|---|---|
business | ObjectId ref Business, required, indexed | Tenant scope; auto-set from CLS by the global business-scope plugin (apps/backend/src/common/plugins/business-scope.plugin.ts:718-725 pre-validate hook) |
clientInfo | embedded object | Name, DOB, sex, address, weight/height, pets, contacts[] (with isPrimary), benefitsInquiry, referral source, reason for seeking help (entity lines 65-130) |
assessment | ObjectId ref CareProposalAssessment, nullable, default null | The proposal's own copy of the routine-task inventory (entity lines 132-138) |
carePlan | embedded | plan (cover letter + addressee + careGoal + 9 IdValue[] care-domain lists), lastUpdated, isCompleted (lines 140-203) |
service | embedded | type (In-Home Care / Hospice Care / Respite Care / Placement / null), vas (MD/CT/HA/AF/CB/CM booleans), 7-day schedules, isCompleted (lines 205-249) |
rateComputation | embedded | assessmentScore, hourlyRate, dailyRate, hoursRates keyed '2'..'24' + '24split', isCompleted (lines 251-295) |
statusHistory | array, newest-first | { status, notes, timestamp, author, metadata? }; current status = statusHistory[0].status — there is no separate status field (lines 297-338) |
files | ObjectId[] ref LockCare | Signed PDFs land here on representative acceptance (lines 340-344) |
reviewers | ObjectId[] ref User | Assigned reviewer pool, set on submit-for-approval (lines 346-350) |
emailHistory | array, newest-first | Per-send: sentAt, sentBy, includesAttachment, recipients[] each with Resend emailId and delivery statuses[] (Sent/Delivered/Bounced/Opened/Clicked/…) (lines 352-401) |
author | ObjectId ref User, required | Proposal owner; transferable (lines 403-408) |
client | ObjectId ref Client | Set when a Client is created from the proposal (line 410-411) |
narrative, narrativeGeneratedAt | string/date, nullable | Carried over from the initial assessment (lines 413-417) |
aiSummary, aiSummaryUpdatedAt | string/date | AI-generated summary, auto-refreshed after assessment saves (lines 419-423; care-proposals.service.ts:1105-1111) |
Indexes: {business, createdAt}, {emailHistory.recipients.emailId} (entity lines 430-431).
CareProposalAssessment — collection care_proposal_assessments
apps/backend/src/care-proposals/entities/care-proposal-assessment.entity.ts.
| Field | Type | Notes |
|---|---|---|
business | ObjectId ref Business, required, indexed | |
assessment | SimplifiedRoutineTaskInventory subdocument, default null | The score+notes inventory (SRTI) |
careProposal | ObjectId ref CareProposal | Back-link |
lastUpdated, isCompleted | Date / boolean | lastUpdated auto-set and DTO-validated in a pre('save') hook (lines 58-81) |
One is created and linked for every proposal at creation time — either a blank default doc (constants/initial-assessment.defaults.ts, all scores null) for directly-created proposals, or a copy of the initial assessment's SRTI for converted ones (care-proposals.service.ts:238-241, 410-414, 912-952).
CareProposalReview — collection care_proposal_reviews
apps/backend/src/care-proposals/entities/care-proposal-review.entity.ts.
| Field | Type | Notes |
|---|---|---|
business, careProposal | ObjectId refs, required, indexed | |
round | number, required | Review round (see below) |
section | enum CareProposalReviewSection | One reviewable section: narrative, clientInfo, assessment, coverLetter, careGoal, plus the 9 care-plan domains, etc. (packages/shared/src/enums/domain-status.ts:299+) |
reviewerType | enum CareProposalReviewerType | Records who reviewed (admin vs representative side) |
status | enum CareProposalReviewStatus | Approved or Flagged |
comment, author | string / ObjectId ref User | |
resolved, resolvedBy, resolvedAt | boolean / ref / date | Author "addressed" workflow |
replies[] | embedded CareProposalReviewReply | body, kind (Comment | Resolution), author, versionAtReply (required ref) |
versionAtFiling | ObjectId ref CareProposalVersion, required | Version snapshot the reviewer was looking at |
Indexes: {careProposal, round}, {careProposal, resolved}, {business, author, resolved} (lines 141-143).
CareProposalVersion — collection care_proposal_versions
apps/backend/src/care-proposals/entities/care-proposal-version.entity.ts.
| Field | Type | Notes |
|---|---|---|
careProposal | ObjectId ref, required, indexed | |
version | number | Monotonic per proposal; unique index {careProposal, version} (line 105) |
snapshot | Mixed (CareProposalSnapshot) | Frozen blob of clientInfo, assessment, carePlan, service, rateComputation (care-proposal-versions.service.ts:85-101) |
status | CareProposalStatusType | Status that triggered the snapshot |
triggerAction | enum | submitted_for_review, revision_submitted, sent_to_representative, revisions_needed, approved, representative_accepted (entity lines 15-22) |
createdBy, notes | ref User / string |
Snapshots are created (fire-and-forget) on transitions to PendingApproval, SentToRepresentative, RevisionsNeeded, Approved, RepresentativeAccepted via STATUS_TO_VERSION_TRIGGER (entity lines 28-40; care-proposals.service.ts:2543-2551). A resubmission (PendingApproval whose previous status was RevisionsNeeded) is re-tagged revision_submitted (care-proposal-versions.service.ts:56-70).
Note the directionality of the initial-assessment link: InitialAssessment.careProposal points at the proposal; CareProposal has no field referencing the initial assessment. Lookups go through initialAssessmentService.findByLinkedCareProposalId (care-proposals.service.ts:1978-1979).
Workflows & State Machines
Statuses
CareProposalStatusType (packages/shared/src/enums/domain-status.ts:284-293) — note enum values contain spaces:
Draft, Pending Approval, Approved, Sent to Representative, Revisions Needed, Cancelled, Representative Accepted, In Service.
The state machine
The single source of truth is packages/shared/src/care-proposal-transitions.ts, re-exported by the backend (apps/backend/src/care-proposals/utils/status-transitions.util.ts) and consumed directly by the web UI. There are three live transition maps, selected by permission/role, plus a legacy union:
| From \ Map | Admin/approver (CARE_PROPOSALS_APPROVE; also SuperAdmin/Owner/Admin roles) — USER_FACING_ADMIN_TRANSITIONS (lines 18-50) | Editor-only (CARE_PROPOSALS_EDIT) — SEQUENTIAL_TRANSITIONS (lines 55-69) | Representative (CARE_PROPOSALS_RESPOND / UserRole.Representative) — REPRESENTATIVE_TRANSITIONS (lines 74-90) |
|---|---|---|---|
| Draft | PendingApproval, Cancelled | PendingApproval | — |
| PendingApproval | Draft (withdraw), Cancelled | Draft | — |
| RevisionsNeeded | PendingApproval, Cancelled | PendingApproval | — |
| Approved | Cancelled | — | — |
| SentToRepresentative | Cancelled | — | RepresentativeAccepted, RevisionsNeeded |
| RepresentativeAccepted | InService | — | — |
| InService | — (terminal) | — | — |
| Cancelled | — (terminal) | — | — |
Two statuses are deliberately not reachable by picking a status:
Approved/RevisionsNeeded(admin side) are only written by the review-submit flow (POST /care-proposals/:id/reviews/submit), whichupdateStatustrusts via ametadata.kind === 'review-submit'bypass (care-proposals.service.ts:2412-2438;care-proposal-reviews.controller.ts:149-204).SentToRepresentativeis only written as a side effect ofPOST /care-proposals/:id/send-emailwhen the proposal isApprovedand at least one recipient send succeeded (care-proposals.service.ts:2241-2259).
InService is additionally written by the server itself when a Client is created from the proposal — markInService deliberately bypasses the transition map (care-proposals.service.ts:2620-2693).
The legacy CARE_PROPOSAL_BASE_TRANSITIONS union (transitions lines 96-132) is exported "for legacy consumers" and permits looser moves (e.g. Draft→Approved, SentToRepresentative→PendingApproval); it is not used by updateStatus enforcement, which uses getEffectiveCareProposalNextStatuses (permission-driven; representatives always get the representative map) (care-proposals.service.ts:2421-2425, transitions lines 234-246).
The shared module also exposes an action API (getCareProposalActions, transitions lines 351-461) that maps current status + role/permissions to UI buttons (submit, resubmit, withdraw, start-review, send-email, start-service, accept, request-changes, cancel), so frontends never consult the raw maps.
Happy path, step by step
- Create (Admin/CareManager —
CARE_PROPOSALS_CREATE): either directly (POST /care-proposalswithclientInfo; status history seeded withDraft, blank linked assessment —care-proposals.service.ts:213-278) or by converting a completed initial assessment (POST /initial-assessments/:id/convert-to-care-proposal—initial-assessments.service.ts:744-786), which copies client info, narrative, service schedule, the SRTI assessment, and pre-computes the assessment score (care-proposals.service.ts:312-454). - Author fills sections (CARE_PROPOSALS_EDIT / RATES / GENERATE): client info, assessment (auto-recomputes score + AI summary), service type & schedule, rates (
calculateRatesfrom CityRate/BaseRate tables,overrideHourlyRates), care plan (optionally AI-generated viaPOST :id/generate-care-proposal) (care-proposals.controller.ts:119-230, 294-327). - Submit for approval (author):
PATCH :id/status→Pending Approval. Requires 1-20reviewerIds, each an active same-business user holdingCARE_PROPOSALS_REVIEW(dto/update-care-proposal-status.dto.ts:28-39;care-proposals.service.ts:586-627, 2491-2512). Submission also requires all required fields present (collectMissingFieldsForSubmission,utils/section-completion.util.ts:157+). A version snapshot (submitted_for_review) is created. - Review (reviewers —
CARE_PROPOSALS_REVIEW/CARE_PROPOSALS_APPROVE) on the web review screen: per-section reviews are filed (POST :id/reviews,bulk-approve), each stamped withversionAtFiling(care-proposal-reviews.service.ts:57-145). The reviewer then submits the round (POST :id/reviews/submit, requiresCARE_PROPOSALS_APPROVE): outcome is auto-determined — any unresolved flag →Revisions Needed, otherwiseApproved; at least one section must have been reviewed (care-proposal-reviews.controller.ts:154-204). - Send to representative (
CARE_PROPOSALS_SEND):POST :id/send-email. Requires the proposal complete; auto-creates aRepresentativeuser account (with temp password) for each recipient that matches a listed contact; emails a view link (FRONTEND_URL/view/:id?email=…) and optionally a PDF attachment; on success fromApproved, auto-transitions toSent to Representativeand snapshots (care-proposals.service.ts:2039-2300). - Representative accepts (Representative —
CARE_PROPOSALS_RESPOND) on the web/view/:idpage: signs a signature canvas andPATCH :id/status→Representative Acceptedwith the base64 signature. Backend verifies the actor's email is one ofclientInfo.contactsemails (care-proposals.service.ts:2441-2459), stores a signed PDF in LockCare and pushes it tofiles(2517-2531), snapshots, and sends a welcome email with mobile-app download links to the primary contact (2585-2592,2694+). - Start service / create client (Admin): either the admin presses Start Service (
PATCH :id/status→In Servicevia the admin map) or — the richer path — presses Create Client (POST /clients/from-care-proposal/:careProposalId), which builds theClientand then callsmarkInService(see "Approval side-effects" below).
Rejection / revision paths
There are two distinct sources of Revisions Needed (collapsed into one status; the "who asked" lives on each review's reviewerType — version entity lines 11-14):
- Internal review rejection: reviewer submits a round with unresolved flagged sections →
Pending Approval→Revisions Needed(review-submit flow, step 4 above). The author then resolves each flag (PATCH :id/reviews/:reviewId/resolve, requires a "what changed" reply appended as aResolution-kind reply —care-proposal-reviews.service.ts:247-317) and re-requests review (POST :id/reviews/rerequest). Re-request gating is the shared predicatecanRerequestReview(packages/shared/src/care-proposal-rerequest.ts): allowed only when status isRevisions Needed, the current round has ≥1 flag, and 0 unresolved flags; backend rejects and web disables the button from the same predicate (care-proposal-reviews.service.ts:324-390). Re-request transitions back toPending Approval, reusing the proposal's already-assigned reviewers when none are passed (care-proposals.service.ts:2484-2512), and records{round, addressedFlagIds, kind:'rerequest'}in status-history metadata. - Representative requests changes: from
Sent to Representativethe representative picksRequest Changes→Revisions Needed(representative map, transitions lines 82-86). The web view composes the notes from the representative's flagged sections (apps/web/app/(app)/(view)/view/[id]/_components/representative-review-outline.tsx:185-205). After the team revises, Send Email from the admin is the path back out (subject becomes "Revised Care Proposal …" when anyRevisions Neededappears in history —care-proposals.service.ts:2062-2068, 2148-2152); per the round-precursor rules a SentToRepresentative following RevisionsNeeded counts as a new representative round.
An admin can also withdraw a submission (Pending Approval → Draft) and later resubmit (transitions lines 26-32).
Review rounds (packages/shared/src/care-proposal-review-round.ts)
A "round" groups reviews so resolved flags from a previous cycle don't block the next. getCareProposalReviewRound derives the round number purely from statusHistory (newest-first): it counts Pending Approval entries whose predecessor was Draft/Revisions Needed (admin review cycles) and Sent to Representative entries whose predecessor was Approved/Revisions Needed (representative cycles). Bounce-backs (e.g. PendingApproval after Approved) and resends (SentToRep after SentToRep) do not start a new round. Minimum is 1. The backend wraps this in CareProposalReviewsService.getReviewRound (care-proposal-reviews.service.ts:534-538); all gating (hasUnresolvedReviews, getReviewOutcome, re-request) filters reviews by the current round.
Version snapshots & diffs (packages/shared/src/care-proposal-snapshot-diff.ts)
Snapshots exist to answer the reviewer's question "what changed since I flagged this?". Each snapshot freezes the five reviewable sections (CARE_PROPOSAL_SNAPSHOT_SECTIONS in packages/shared/src/types/care-proposal-snapshot.ts). diffCareProposalSnapshots compares two snapshots at top-level-field-per-section granularity, returning {changed, fieldsChanged?} per section. Baked-in semantics: lastUpdated/updatedAt ignored at any depth; ''/null/undefined equivalent; dates by epoch; IdValue[] arrays compared by id (reordering is not a change); other arrays positional; objects structural (diff file lines 15-18, 23-38). Exposed via GET /care-proposals/:id/versions/diff?reviewId=… (from = that review's versionAtFiling, to = latest) or ?from=&to= for arbitrary pairs (care-proposal-versions.controller.ts:27-52, care-proposal-versions.service.ts:179-253).
CRITICAL: "A care proposal cannot be created unless it originates from an initial assessment"
This rule is NOT enforced in the backend. It is at most a convention, and the product currently has a fully supported direct-creation path. Evidence:
POST /care-proposals(care-proposals.controller.ts:53-63, gated only byCARE_PROPOSALS_CREATE) accepts aCreateCareProposalDtocontaining onlyclientInfo(dto/create-care-proposal.dto.ts:130-135). No assessment id, no reference of any kind to an initial assessment is accepted or required.CareProposalsService.createthen fabricates a blank linkedCareProposalAssessmentfromdefaultInitialAssessmentDoc(all scoresnull) (care-proposals.service.ts:238-241,constants/initial-assessment.defaults.ts).- The
CareProposalschema has no field pointing at an InitialAssessment;assessmentis the proposal's ownCareProposalAssessmentcopy and isnullable, default: null(entities/care-proposal.entity.ts:132-138). The link direction is reversed:InitialAssessment.careProposalis set only by the convert flow. - The assessment-origin path is a separate endpoint:
POST /initial-assessments/:id/convert-to-care-proposal(gated byINITIAL_ASSESSMENTS_CONVERT,initial-assessments.controller.ts:120-131), whose service guards only that the assessment isn't already converted and isn't in Draft status (initial-assessments.service.ts:744-768). - The web UI exposes the direct path: the proposals table's empty state links to
/dashboard/care-proposals/add, which rendersCareProposalClientInfoFormfor any holder ofCARE_PROPOSALS_CREATEand POSTs/care-proposalsdirectly (apps/web/app/(app)/(admin)/dashboard/care-proposals/_components/care-proposals-table.tsx:159-166,add/page.tsx:16-50,apps/web/components/care-proposal/care-proposal-client-info-form.tsx:158). - There is also a third creation path:
POST /care-proposals/:id/duplicateclones an existing proposal into a fresh Draft (care-proposals.controller.ts:65-72, service lines 465-537). - The only place the "from an initial assessment" framing exists in code is UI copy: the permission catalog describes
CARE_PROPOSALS_CREATEas "Start a new care proposal from an initial assessment" (packages/shared/src/constants/permission-groups.ts:267). The label and the enforcement disagree.
Conclusion: enforcement is absent at DTO, service, and schema level; there is no frontend-only block either — the direct add page is live. Whether the stated rule is an unimplemented intention or stale documentation cannot be determined from code. Flagged in Open Questions & Gaps #1.
Approval side-effects: what "fully approved" actually does
- Transitioning to
Approveditself only: validates no unresolved flags and that ≥1 review exists in the current round (care-proposals.service.ts:2469-2483), writes status history, creates a version snapshot (approvedtrigger), and emits notification + platform-log events (2553-2607). No care plan, schedule, or caregiver records are created at Approved. Representative Acceptedadds: signed PDF stored to LockCare and pushed tofiles(2517-2531), welcome email with app download links to the primary contact (2585-2592,2694+).- The real materialization happens at client creation:
POST /clients/from-care-proposal/:careProposalId→ClientsService.createClientFromCareProposal(apps/backend/src/clients/services/clients.service.ts:316-460). It requires the proposal complete, then creates: theClient(Draft status, patientId, narrative, timezone via Google Maps), aClientServicerecord carrying the proposal'sservice.type(start date = now, no shifts), emptyClientCareProvidersandClientMedicalRecordshells, and seeds the client's routine-task inventory from the proposal assessment (clientAssessmentsService.seedFromCareProposal, line 422-426). Finally it callscareProposalsService.markInService(best-effort) which setsproposal.clientand pushesIn Serviceunless already terminal (care-proposals.service.ts:2631-2693). No schedules/shifts and no caregiver assignments are created —caregivers: [],shifts: [](clients.service.ts lines 360, 379). The proposal'scarePlantext is not copied onto the client by this flow. - Note the alternate path: an admin can also
PATCH :id/status→In Servicedirectly fromRepresentative Accepted(admin map, transitions lines 45-47), which marks the proposal in service without creating any client. See gap #4.
Business Rules & Constraints
- Current status is always
statusHistory[0].status; transitions are applied atomically with afindOneAndUpdatepreconditioned on the current head to prevent concurrent writes (care-proposals.service.ts:2440, 2514-2541— returns 400 "modified concurrently" on mismatch). - Status transitions are validated against the permission/role-selected map via
getEffectiveCareProposalNextStatuses; review-submit (metadata.kind === 'review-submit'from aCARE_PROPOSALS_APPROVEholder whilePending Approval) bypasses the map forApproved/Revisions Needed(care-proposals.service.ts:2412-2438). - Every status change except
Cancelledrequires the proposal to pass field validation (collectMissingFieldsForSubmission: client name/DOB/city/region, primary contact name+email+relationship, assessment complete, service type, ≥1 active hourly rate) (care-proposals.service.ts:2461-2468;utils/section-completion.util.ts:157+). - Submitting for approval requires 1-20 unique
reviewerIds; each must be an active user of the proposal's business holdingCARE_PROPOSALS_REVIEW(dto/update-care-proposal-status.dto.ts:28-39;care-proposals.service.ts:586-627). Re-request reuses existing assigned reviewers when none are supplied (2495-2506). Approvedis blocked while unresolved flagged reviews exist in the current round, and (when coming from PendingApproval/RevisionsNeeded) requires at least one filed review (care-proposals.service.ts:177-211, 2469-2483).- Reviews/replies can only be filed once a version snapshot exists; otherwise 400 (
care-proposal-reviews.service.ts:517-527). - Reviewers can only edit/delete their own reviews (
care-proposal-reviews.service.ts:224-226, 448-450); only flagged reviews can be resolved, and resolving requires a reply (268-272;dto/care-proposal-review.dto.ts).resolvehas an explicit cross-tenant guard (258-266);unresolve(APPROVE-only) does not. - Representative actions (
Representative Accepted/Revisions Neededoutside review-submit) require the acting user's email to match aclientInfo.contactsemail (care-proposals.service.ts:2441-2459). - Send Email requires
CARE_PROPOSALS_SENDand a complete proposal (care-proposals.service.ts:2055-2059); fromApprovedit is pre-blocked if unresolved reviews exist (2154-2163); only recipients matching listed contacts get auto-created Representative accounts (2097-2123); successful sends append toemailHistory; Resend webhook statuses update per-recipient viaupdateEmailStatuskeyed onemailId(2855+). - Visibility scoping (
buildViewScopeFilter,care-proposals.service.ts:1924-1961):care_proposals:view:all→ everything in the business;view:own→ author or assigned reviewer;CARE_PROPOSALS_RESPOND→ proposals where the user's email matches a contact or past email recipient. List/read routes gate at the:ownfloor and let the filter narrow (care-proposals.controller.ts:74-111). - All proposal collections carry a required
businessfield auto-scoped by the global Mongoose business-scope plugin (query injection + pre-validate default) (apps/backend/src/common/plugins/business-scope.plugin.ts). - Deleting a proposal also deletes its
CareProposalAssessment, deletes the linkedClientdocument if one exists, and either unlinks or (with?deleteLinkedInitialAssessment=true) deletes the originating initial assessment (care-proposals.service.ts:1963-2037). - Signed-PDF download (
GET :id/get-care-proposal-pdf?signed=true) requires status in {Approved, SentToRepresentative, RepresentativeAccepted, InService} viacanDownloadCareProposalPdfand a signed file present; S3 URL is SSRF-validated (care-proposals.service.ts:2357-2384;care-proposals.controller.ts:340-371; transitions lines 465-477). The unsigned PDF preview is intentionally ungated server-side — "download gating is enforced on the frontend" (care-proposals.service.ts:2338-2340). - Authorship can only be transferred to management-level roles, never to the current author (
care-proposals.service.ts:673-700). - Cancel availability in the shared action API: EDIT-or-APPROVE holders, any non-terminal status (
Cancelled/InServiceexcluded) (transitions lines 309-329) — but see gap #5 for the EDIT-only mismatch. - Default role grants: Admin gets the full proposal suite including REVIEW/APPROVE; CareManager (migration set) gets VIEW_ALL/CREATE/EDIT/DUPLICATE/SEND but not RATES, REVIEW, APPROVE, DELETE, or TRANSFER_AUTHORSHIP; Representative gets only
CARE_PROPOSALS_RESPOND(packages/shared/src/constants/default-role-permissions.ts:32-44, 260-266, 382-392).
Surfaces (Web & Mobile)
Web — admin dashboard (apps/web/app/(app)/(admin)/dashboard/care-proposals/)
- List
/dashboard/care-proposals— table/card list with status tabs and counts, filters (search, city, dates), status-history dialog, transfer-authorship and cancel dropdown items, PDF link (page.tsx,_components/care-proposals-table.tsx,care-proposal-status-tabs.tsx,StatusHistoryDialog.tsx). The "Create Care Proposal" button (empty state) links to/addand is shown only withCARE_PROPOSALS_CREATE(care-proposals-table.tsx:159-166). - Create
/dashboard/care-proposals/add— client-info form →POST /care-proposals, then redirects into the editor's assessment step (add/page.tsx;components/care-proposal/care-proposal-client-info-form.tsx:158-171). Renders a permission-denied panel withoutCARE_PROPOSALS_CREATE(frontend-only gate mirroring the backend guard). - Editor
/dashboard/care-proposals/[id]/edit/*— stepped sections:client-info,assessment,service-information,rate-computation,proposal(care plan, incl. AI generation),view-pdf(directory under[id]/edit/). Action bar renders the shared action API (_components/CareProposalActionBar.tsx), disabling submit-class intents until all sections are complete (line 106) — frontend-only mirror of the backend submission validation. Status changes go through a confirm dialog driven byaction.targetStatus(CareProposalActionDialog.tsx:144-152). - Review
/dashboard/care-proposals/[id]/review— reviewer surface with per-section outline, client-info/assessment/narrative viewers, and diff viewers (assessment-diff-viewer.tsx,section-diff-viewer.tsx,text-diff.tsx) backed by the versions diff endpoint; submitting callsPOST :id/reviews/submit. - Read-only view
/dashboard/care-proposals/[id]/view. - Create Client button/dropdown — shown when current status is
Representative Accepted; callsPOST /clients/from-care-proposal/:id(_components/CreateClientButton.tsx:36-42). - Statistics tab for SuperAdmin at
/dashboard/statisticsincludes a care-proposals tab (statistics/_components/tabs/care-proposals-tab.tsx).
Web — external representative view (apps/web/app/(app)/(view)/view/[id]/)
The link emailed to representatives. Unauthenticated visitors get an email-prefilled sign-in (ViewPdfSignIn.tsx); signed-in representatives (a "mobile-only role" exempted from web onboarding — page.tsx:40-47) see the proposal PDF (client-care-proposal-pdf.tsx), an Accept form with a signature canvas posting PATCH /care-proposals/:id/status with Representative Accepted + base64 signature (accept-care-proposal-form.tsx:39-143), and a Request Changes outline that files flagged sections and posts Revisions Needed with composed notes (representative-review-outline.tsx:185-205).
Mobile (apps/mobile/)
Mobile has no care-proposal screens. Footprint is limited to:
- Admin home dashboard shows a "Recent Care Proposals" list fed by
GET /care-proposals/recent-updated, with status color chips (components/home/admin-home.tsx:45-180;components/home/dashboard-shared.tsx:150-157). Items are display-only. - Care-proposal push notifications deep-link to the client overview when
metadata.clientIdexists, otherwise navigate nowhere (components/notifications/notification-navigation.ts:63-72).
Families do not review proposals on mobile; representative acceptance is a web flow (/view/:id). (Representatives are described as mobile-onboarded roles, but no proposal UI exists in the mobile app — see gap #9.)
Cross-Module Dependencies
- Initial Assessments (02-assessments.md):
convertToCareProposal→createFromInitialAssessment(forwardRef'd modules —care-proposals.module.ts:117,care-proposals.service.ts:134-135); proposal delete unlinks or cascades to the assessment (care-proposals.service.ts:1978-1992); assessment delete can cascade to the proposal (initial-assessments.service.ts:788-810). - Clients / Care Plans (04-care-plans.md):
ClientsService.createClientFromCareProposalconsumes the proposal, seeds the client SRTI viaclientAssessmentsService.seedFromCareProposal, and calls back intomarkInService(clients.service.ts:316-460). Proposal delete deletes the linked client (care-proposals.service.ts:2005-2011). - Scheduling (05-scheduling.md): only indirectly — client creation makes a
ClientServicewith emptyshifts(clients.service.ts:373-380); proposals carry a weekly hours grid but no shift records. - Rates:
calculateRatesreadsCityRate/BaseRatemodels fromsrc/base-rates/(care-proposals.service.ts:118-121, 1321+). - Users/Auth: representative auto-provisioning via
usersService.findOrCreateRepresentativeUseron email send (care-proposals.service.ts:2110-2118); reviewer eligibility viaPermissionService.hasPermission(612-623); route guardsJwtAuthGuard+@RequirePermissionsthroughout the controllers. - Notifications:
care-proposal.status-changedandcare-proposal.authorship-transferredevents handled bynotification/listeners/notification.listener.ts:45-93→ in-app/push notifications. - Platform Logs: every create/update/status-change/review action emits
PLATFORM_LOG_ACTIVITY_EVENTaudit entries (e.g.care-proposals.service.ts:256-266, 2596-2607). - Email: Resend via
EmailSenderServicewith React Email templates (CareProposalEmail,CareProposalRevisedEmail); webhook delivery statuses recorded per recipient (updateEmailStatus,care-proposals.service.ts:2855+). - PDF / Files / LockCare:
PdfService.generateCareProposalPdffor previews/attachments; signatures uploaded viaFilesServiceto S3; signed PDFs stored asLockCaredocuments on acceptance (care-proposals.service.ts:2310-2384, 2742+). - AI:
CareProposalAiSummaryService(auto-summary after assessment saves) andAIClientCareProposalService(care-plan generation,generateCareProposal) fromsrc/ai/.
Open Questions & Gaps
- The "must originate from an initial assessment" rule is not enforced anywhere. Backend accepts
POST /care-proposalswith onlyclientInfo; the schema has no initial-assessment reference; the web add page is live. The only trace of the rule is UI copy on the permission label (permission-groups.ts:267). Whether direct creation is intended or a gap cannot be determined from code. POST /clients/from-care-proposal/:careProposalIdhas no@RequirePermissionsdecorator (clients.controller.ts:175-184) — any authenticated business user can convert an accepted (or even merely complete) proposal into a client and force itIn Service. Completeness is checked, but status is not:createClientFromCareProposalnever verifies the proposal isRepresentative Accepted(clients.service.ts:316-323); the frontend hides the button until acceptance, which is frontend-only gating.- Deleting a proposal cascades to its linked Client (
care-proposals.service.ts:2005-2011) — deleting anIn Serviceproposal hard-deletes the live client document. No status guard prevents deleting in-service proposals; the comment at lines 1971-1973 claims custom-role restrictions "enforced via permission-based guards" but the route only checksCARE_PROPOSALS_DELETE, not the status. - Two divergent paths to
In Service: the adminStart Servicestatus action marks the proposal in service without creating a client, while Create Client does both. A proposal can therefore be "In Service" withclient: null. Cannot determine from code which is canonical. - EDIT-only users see a Cancel action that the backend will reject:
getCareProposalActions.canCancelis true forCARE_PROPOSALS_EDITholders (transitions lines 309-329), butSEQUENTIAL_TRANSITIONScontains noCancelledtarget, soupdateStatus400s the attempt (care-proposals.service.ts:2421-2438). UI affordance and enforcement disagree. - Version snapshots are fire-and-forget (
.catchlogging only —care-proposals.service.ts:2543-2551), yet filing a review hard-requires a version (care-proposal-reviews.service.ts:517-527). A failed/slow snapshot write after first submission leaves reviewers unable to file reviews; there is no retry. unresolvelacks the cross-tenant guard thatresolvehas (care-proposal-reviews.service.ts:392-407vs 258-266): anyCARE_PROPOSALS_APPROVEholder can un-resolve reviews on another business's proposal by id (query-level scoping by the business plugin mitigates this only when CLS business id is set; SuperAdmin context bypasses it).- Withdraw requires a complete proposal: field validation runs on every non-Cancel transition, including
Pending Approval → Draft(care-proposals.service.ts:2461-2468). Intent unclear — withdrawing an incomplete submission is impossible, though such a submission largely can't exist since submit validates too. - Representatives are flagged as mobile-onboarded roles (
isMobileOnlyRole, view page lines 40-47) but mobile has no proposal screens — acceptance only works on the web view route. The welcome email after acceptance promotes mobile app downloads (sendRepresentativeWelcomeEmail). The intended representative mobile journey cannot be determined from code. - Legacy
CARE_PROPOSAL_BASE_TRANSITIONSstill exported (transitions lines 93-132, 195) with looser edges (Draft→Approved, SentToRep→PendingApproval); any consumer still reading it would render/permit transitions the backend rejects. No backend usage found, but the export remains. findAllstatus sorting is acknowledged broken-ish: sorting by'statusHistory.0.status'"might need aggregation for better results" (care-proposals.service.ts:1730-1733), and the status-countsbaseFilterrebuilds the city/search regexes without theescapeRegExpused for the main query in thecitycase (1760-1762) — counts and rows can diverge on regex-special characters.getAssessment(GET :id/assessment) performs no view-scope filtering beyond theCARE_PROPOSALS_VIEW_OWNfloor and tenant plugin (care-proposals.controller.ts:113-117; service lines 996-1019) — aview:ownuser can read the assessment of any proposal in their business by id, unlikefindOnewhich appliesbuildViewScopeFilter.- Unsigned PDF endpoint is deliberately ungated by status ("download gating is enforced on the frontend",
care-proposals.service.ts:2338-2340) — a Draft proposal's PDF is fetchable server-side despitecanDownloadCareProposalPdfexisting in shared code. create()/createFromInitialAssessment()rely on the CLS business-scope plugin to setbusiness(required field). If invoked outside an HTTP request context (CLS inactive — e.g. jobs/migrations), the save would fail validation;createFromInitialAssessmentinherits whatever CLS context the converting request had, not the assessment's own business. Behavior in mismatched contexts cannot be determined from code.