---
title: "Placements Overview Engineering"
slug: placements-overview-engineering
section: Placement & Matching
status: draft
description: "Frozen contract for the Layer 7 People & Placements dashboard in the live Globus app — endpoints, denormalized row shape, filter semantics, RBAC, audit, performance targets, and rollout."
last-updated: 2026-10-07
---

# Placements Overview Engineering

Frozen contract for the Layer 7 People & Placements dashboard in the live Globus app — endpoints, denormalized row shape, filter semantics, RBAC, audit, performance targets, and rollout. This page is the build checklist for the P5 team; the dashboard itself ships in the Globus app, not on this docs site.

> **Who reads this**
>
> Globus platform engineers building the People & Placements dashboard, plus downstream consumers (BI, exports). This brief is the authoritative wire and behavior contract; the page stays draft until the dashboard ships.

> **Status check (verified 2026-07-04) — dashboard NOT built; substrate live**
>
> None of the endpoints this page freezes exist yet: GET /api/placements, /summary, /export.csv, /export.xlsx, and the /placements frontend route are all unregistered. The substrate is live and differs from the spec in places: the placements table (shared/models/partner.ts) carries status (default 'confirmed'; active set = confirmed | active), confirmedAt / cancelledAt / frozenAt, start/end dates and a partial unique index on active applications — there is no placed_at column, so the spec's placedAt must map to confirmedAt. Reservation expiry lives on reservation_holds.expiresAt (statuses: active | confirmed | cancelled | declined | expired), not on placements. Already-live adjacent routes: POST /api/placements/:id/cancel (perm placement.cancel, requires reason) and the admin reservation cancel/release/expire family (perm reservations.manage). The admin placements read routes GET /api/admin/placements and GET /api/admin/placements/:id are also live and return an additive placementDetails projection on each row (hostAddress chosen by season kind, canonical job-order offer terms, host/program arrival, job-order-linked housing leads filtered to the hired host/program/season, and host contacts); placementDetails is null for cancelled placements, and each section may independently be null or empty. The same rows also carry `hostOrgName`, `jobTitle` and a `jobOrder` summary object (`id`, `title`, `status`, `startDate` and `endDate` taken from the placement, `positionsTotal`, `positionsPlaced`, `wageAmount`, `wageFrequency`, `locationCountry`, `submittedAt`, `approvedAt`, `activatedAt`, `closedAt`, `hostOrgId`, `hostOrg`, checklist `sections` and an optional `overallPct`), null when the placement has no linked job order row. The Applicant Workspace Placement tab renders offer, arrival, housing and contacts from `placementDetails`. Permissions placements.view / placements.manage exist. Also real: frozenAt + PLACEMENT_FROZEN_HOST_SUSPENDED 409 from the host-suspend cascade (Task #487) — add to the row contract when this builds.

## Purpose & Scope

The People & Placements dashboard is the operational landing page in Globus for staff who need a single view of who is placed where, this season and last. It is read-only; placement creation, cancellation, and capacity release happen elsewhere (Layer 7 reservation flow + Tasks #157, #160). This brief defines the contract so the dashboard, the underlying APIs, and downstream consumers (BI, exports) all agree on shape, semantics, and access.

## System of Record & Boundaries

Owns

- Rendering
- Filter state
- Summary aggregation
- Export generation
Reads From

- placements
- applications
- applicants
- partner_organizations (host)
- job_orders
- programs
- seasons
Does NOT Own

- Placement state transitions (Layer 7 engine)
- Capacity counters (Layer 6)
- Eligibility (Layer 5)
- Notifications (Layer 8)
> **Stale-data SLA**
>
> Read-after-write within 1s for the row that just changed. Eventual consistency for summary counts is acceptable up to 5s. The dashboard does not implement its own cache in v1 — see Performance.

## Data Model — Denormalized PlacementRow

Returned by the list endpoint. Every field is required unless marked optional.

| Field | Type | Source |
| --- | --- | --- |
| field | type | source |

_(row above is a template, repeated for each data row)_

## Endpoints

### GET /api/placements

Query params (all optional, all combinable):

| Param | Type | Description |
| --- | --- | --- |
| name | type | desc |

_(row above is a template, repeated for each data row)_

Response:

"rows": PlacementRow[],

"page": int,

"pageSize": int,

"total": int,

"appliedFilters": ,

"sort": "placedAt" | "-placedAt" | ...

> **Echoed filters & sort**
>
> appliedFilters and sort are echoed so the UI and exports stay in sync — same pattern Task #306 uses for talent-pool. The frontend should display whatever the server echoed, not its own local copy of the request, so any server-side normalization (case-folding, default substitution) is visible.

### GET /api/placements/summary

Same query params as the list endpoint except sort, page, pageSize. Computes counts off the same filtered set so the cards always match the table.

"total": int,

"byStatus": reserved / confirmed / cancelled / expired / pending,

"byProgram": [ programId / programCode / count ],

"bySeason": [ seasonId / seasonLabel / count ],

"appliedFilters":

### GET /api/placements/export.csv and /api/placements/export.xlsx

Same query params as the list endpoint. Server streams the result so big seasons don't OOM. CSV is RFC 4180. XLSX uses exceljs streaming writer. Both honor the same column order as the table (Applicant, Host, Program, Season, Status, Placed Date, Created Date) plus a hidden id column at the end for reconciliation.

> **Filename pattern**
>
> placements_all_.

## Filter Semantics

## Sorting & Pagination

Default sort

-placedAt (newest placements first), with -createdAt as the tie-breaker.

Sortable columns

placedAt, createdAt, applicantName, hostName, programCode, seasonLabel, status.

Pagination

Offset-based for v1. Cursor pagination is a v2 concern when seasons exceed 10k rows.

## RBAC

| Permission | Endpoints | Notes |
| --- | --- | --- |
| perm | endpoints | notes |

_(row above is a template, repeated for each data row)_

> **Server-side scope enforcement**
>
> Permission scope is filtered by brand → program → season exactly like the rest of the ecosystem. A coordinator scoped to Brand A / Program CC sees only placements for that scope; the API enforces this server-side and the summary numbers are scoped to match. Never trust client-side filters for authorization.

## Audit & Events

List / summary calls emit no domain events

This is a read view. No placement.* events fire from the dashboard.

Each export writes one placements.exported audit entry

Fields: actor, timestamp, applied filters, row count, format (csv/xlsx). The audit entry must be written before the stream closes so a network failure doesn't lose the record.

Underlying placement events are owned by Layer 7

Layer 7 state changes already emit the audit actions reservation.created, placement.confirmed, placement.cancelled and reservation.expired. The dashboard depends on those events being durable and the underlying rows being updated synchronously — confirm with the Layer 7 owners (see Capability Layers → Layer 7 ) before launch.

## Performance

Composite index

Add a composite index (program_id, season_id, status, host_id) joined via applications and job_orders. Verify with EXPLAIN that the canonical filtered list query stays under 100ms p95 at 50k placements.

Single SQL for summary

The summary endpoint must use a single SQL query with GROUP BY for byStatus, byProgram, bySeason rather than three round-trips.

Streaming exports

Exports stream row-by-row; no full materialization in memory.

No cache in v1

Counts are cheap enough at expected volume. Revisit if a season exceeds 25k placements.

## Errors

| Code | When |
| --- | --- |
| code | reason |

_(row above is a template, repeated for each data row)_

## Frontend Contract

Route

/placements (Globus Core sidebar entry, icon LayoutDashboard).

Single shared filter object

One React Query key per call, e.g. ['/api/placements', filters, sort, page] and ['/api/placements/summary', filters]. Both queries take the same filters object so they cannot diverge.

Debounced filter bar

300ms before re-querying.

Loading state

Skeleton rows for table, skeleton numbers for cards.

Empty state

Explicit “No placements match these filters” with a “Clear filters” link.

Export buttons

Trigger a direct window.location to the export URL with the current filter querystring; show a toast when the request returns 422 so the user knows to narrow filters.

Every interactive element gets a data-testid:

| data-testid | Purpose |
| --- | --- |
| id | purpose |

_(row above is a template, repeated for each data row)_

## Acceptance Criteria (Verbatim from source)

## Test Plan

| Layer | Coverage |
| --- | --- |
| layer | coverage |

_(row above is a template, repeated for each data row)_

## Rollout

## Cross-References

> **Related tasks**
>
> Task #157 — Placement Cancel with Reason + Audit + Capacity Release. Task #160 — One-active-operating-agreement-per-host safeguard. Task #298 — Placement status history & audit. Task #299 — Fill-rate dashboard. Task #300 — Reservation timer auto-expire. Task #301 — Placement state machine spec.
