---
title: "Placement Plans"
slug: placement-plans
section: Placement & Matching
status: stable
description: "Placement plans are a small admin-managed record in the Hub (BackOfficePrototype): a named plan, optionally tied to one program and one season, with a free-text status."
last-updated: 2026-10-07
---

# Placement Plans

## Overview

Placement plans are a small admin-managed record in the Hub (BackOfficePrototype): a named plan, optionally tied to one program and one season, with a free-text status. The route module is `BackOfficePrototype server/routes/placementPlans.ts`, registered by `registerPlacementPlanRoutes` in `server/routes.ts`. The table is declared in `shared/models/partner.ts` and created by migration `migrations/20260414100000_placement_plans.sql`. The users are back office staff who open the Placement Plans page.

### What a placement plan is in code

A row holds `name`, `description`, `program_id`, `season_id`, `status`, `created_by` and two timestamps. It has no column for an applicant, a host, a job order or a placement. The seeded in-app guide page (`server/seed/guide/pages/applicantsPlacement.ts`, slug `placement-plans`) describes a plan as an applicant, host and job order record with a draft, submitted, approved, active, completed lifecycle that feeds the DS-2019. That description does not match the table or the handler: the handler does not validate status transitions, and the table stores none of those links.

### Relationship to other placement surfaces

- Partner placements: the `placements` table (same file, `shared/models/partner.ts`) holds applicant-to-host placements. `placement_plans` has no foreign key to it and no code in `server` or `shared` joins the two.
- Placements Overview: see [Placements Overview Engineering](/docs/placements-overview-engineering). That brief reads `placements`, `applications`, `applicants`, `partner_organizations`, `job_orders`, `programs` and `seasons`; `placement_plans` is not in its read list.
- Job orders: see [Job Orders](/docs/job-orders). A job order workflow step `jo_linked_placement_plans` and a section id `linked_placement_plans` exist, and the packed requirements carry a `placementPlanTemplate` object (`shared/jobOrderRequirements.ts`). That is job order data; it does not reference rows of `placement_plans` (no `placement_plans` or `placementPlans` match in those files). The step is hidden for camp job orders (`CAMP_HIDDEN_STEP_KEYS` in `server/seed/jobOrderWorkflow.ts`).
- Fill rate: see [Placement Fill Rate Dashboard](/docs/placement-fill-rate-dashboard) for the separate dashboard topic.

## Routes

All five routes live in `server/routes/placementPlans.ts`. The base path is `/api/admin/placement-plans`.

| Verb | Path | Permission | Audit action | Success |
| --- | --- | --- | --- | --- |
| GET | `/api/admin/placement-plans` | `dashboard.view` | none | 200 |
| GET | `/api/admin/placement-plans/:id` | `dashboard.view` | none | 200 |
| POST | `/api/admin/placement-plans` | `dashboard.view` | `placement_plan.create` | 201 |
| PATCH | `/api/admin/placement-plans/:id` | `dashboard.view` | `placement_plan.update` | 200 |
| DELETE | `/api/admin/placement-plans/:id` | `dashboard.view` | `placement_plan.delete` | 200 |

### Authentication and permission

Every route runs `isAuthenticated` then `requirePermission("dashboard.view")`. Reads and writes use the same permission, `dashboard.view`. There is no separate manage permission on these routes. `requirePermission` (`server/rbac.ts`) returns 401 `UNAUTHORIZED` when there is no user, and 403 `FORBIDDEN` ("Permission denied") when the permission is missing. For API key callers it checks key scopes and returns 403 `FORBIDDEN` with `details.required`. Users with the god-mode grant pass without the explicit permission. The sidebar entry for the page is gated on a different permission, `placements.view` (`client/src/components/app-sidebar.tsx`), so a user can see the menu item and still get 403 from the API.

### List and get

`GET /api/admin/placement-plans` accepts two optional query parameters.

| Param | Behaviour |
| --- | --- |
| `search` | Trimmed. Case-insensitive substring match (`ILIKE`) on `name` or `description`. |
| `status` | Exact match on `status`. Any string is accepted. |

Rows are left-joined to `programs` and `seasons`, ordered by `created_at` descending, and capped at 100 with no offset or page parameter. Each item has `id`, `name`, `description`, `programId`, `seasonId`, `status`, `createdBy`, `createdAt`, `updatedAt`, `programName`, `seasonName`. The response data is `{ items: [...] }`.

`GET /api/admin/placement-plans/:id` returns the same fields as a single object.

### Create, update, delete

POST body is parsed with `insertPlacementPlanSchema`, the drizzle-zod insert schema for the table with `id`, `createdAt` and `updatedAt` omitted. Accepted fields: `name` (required string), `description`, `programId`, `seasonId`, `status`, `createdBy`. The handler spreads the body first and then sets `createdBy` from `req.user.id` (or null), so a client-supplied `createdBy` is overwritten.

PATCH accepts `name`, `description`, `status` only. The body is checked with a loose schema (`passthrough`, all three optional strings) and then only those three keys are copied into the update, together with `updatedAt`. `programId` and `seasonId` cannot be changed through PATCH. A field set to `undefined` is skipped; an explicit `null` for `description` fails the optional-string check and returns 400.

DELETE hard-deletes the row and returns `{ success: true, data: { success: true } }` (the envelope wrapping `{ success: true }`).

### Response envelope

Successful responses use `createSuccessResponse` (`server/lib/errors.ts`): `{ success: true, data }`.

## Validation and error codes

| HTTP | Where | Body | Notes |
| --- | --- | --- | --- |
| 400 | POST, PATCH | `error.code` = `VALIDATION_ERROR`, `message` "Request validation failed", `details.issues` = Zod issues | Top-level code. Raised by the ZodError branch in `server/middleware/errorHandler.ts`. Example triggers: missing or non-string `name` on POST, non-string `name`, `description` or `status` on PATCH. |
| 401 | all | `error.code` = `UNAUTHORIZED` | From `requirePermission` when no user is attached. `isAuthenticated` runs first and may answer before it. |
| 403 | all | `error.code` = `FORBIDDEN` | Missing `dashboard.view`. |
| 404 | GET by id, PATCH, DELETE | `{ "error": "Placement plan not found" }` | Plain string in `error`, not the coded envelope, so there is no `error.code`. |
| 500 | POST, PATCH | `error.code` = `INTERNAL_ERROR` | An unmapped Postgres error (for example a non-existent `programId` or `seasonId` violating the foreign key) is converted to a generic 500 by the global handler with the raw SQLSTATE logged server side. The route has no explicit foreign key handling. |

There are no `details.code` values under a generic CONFLICT or VALIDATION_ERROR for this route family. There is no status value check, so any string is stored as `status`.

## Configuration

No environment variables are read by this route module or its schema. The only fixed values are the page cap of 100 rows and the default `status` of `draft`.

## Data model

Table `placement_plans`.

| Column | Type | Null | Default | Notes |
| --- | --- | --- | --- | --- |
| `id` | varchar | no | `gen_random_uuid()` | Primary key. |
| `name` | text | no | none | Required. |
| `description` | text | yes | none | |
| `program_id` | varchar | yes | none | References `programs(id)`. No `ON DELETE` clause. |
| `season_id` | varchar | yes | none | References `seasons(id)`. No `ON DELETE` clause. |
| `status` | text | no | `draft` | Free text; no CHECK constraint. |
| `created_by` | varchar | yes | none | No foreign key. Set to the creating user id by POST. |
| `created_at` | timestamp | yes | `now()` | |
| `updated_at` | timestamp | yes | `now()` | Set to the current time by PATCH only. |

Indexes: `idx_placement_plans_program`, `idx_placement_plans_season`, `idx_placement_plans_status`.

## Behaviour and edge cases

- The audit middleware `auditMutation` records the three write routes after a 2xx response, asynchronously. A failure to write the audit row is logged and does not fail the request. DELETE audits with a null new value and the response body as the previous value.
- PATCH does not use a `previousValueResolver`, so the audit row for an update has a null previous value.
- Deleting a program or season that a plan references is blocked by the foreign key (no cascade is declared); deleting a plan has no dependents in the code searched.
- A `search` value that is only whitespace is ignored. The list endpoint does no pagination, so plans beyond the newest 100 are not reachable through it.
- The list page does not send `search` or `status`; it requests `/api/admin/placement-plans` with no query and filters client side on plan name, description, program name and season name over the returned items.
- The edit dialogs on both pages send `description: null` when the description box is empty. PATCH validates `description` as an optional string, so that request fails with 400 `VALIDATION_ERROR`; saving an edit with an empty description is rejected by the API as coded.

## Back office UI

The pages are `client/src/pages/placement-plans.tsx` (route `/placement-plans`) and `placement-plan-detail.tsx` (route `/placement-plans/:id`), registered in `client/src/App.tsx`. The create dialog sends `name`, `description` (or null), `programId` (or null) and `seasonId` (or null). The edit and detail forms offer the statuses `draft`, `active`, `completed` and `archived`; these four values are a UI convention, not enforced by the API. The page links to the in-app guide at `/guide/placement-plans`.

## Known gaps

- The in-app guide text describes applicant, job order and DS-2019 links and a five-state lifecycle that the table and handlers do not implement. Whether another service writes those details is not shown in the code checked.
- No code found reads `placement_plans` outside this route module and the schema, so what consumes a plan's status is not confirmed.
- Permission mismatch between the sidebar entry (`placements.view`) and the API (`dashboard.view`) is as coded; whether it is intended is not confirmed.
- No test for these routes was located in `server/routes`; a coverage report file for `placementPlans.ts` exists in `coverage-out` but was not inspected.
- The in-app UI behaviour on a 400 from an empty-description edit (toast text) was not exercised; it is read from the code only.
- The `dashboard.view` permission definition and its role grants were not traced; only its use is confirmed.
