You flagged three concrete gaps after the deploy went live — all
addressed in this commit, plus the API + how-to docs you asked for.
A) Create Project dialog was still HP-centric
Placeholders like "HP Envy x360 Renders" / "HP-2026-001" / "NPI /
Refresh" / "Form Factor" etc. bore no relation to Dow's actual XLSX
columns and the form had no ClientTeam selector — so any
admin-created project was orphaned from the visibility layer.
- src/lib/validators/project.ts: added clientTeamId + omgJobNumber;
status enum now includes PIPELINE and CANCELED
- src/components/projects/project-form-dialog.tsx: rewritten around
the Dow XLSX schema. Three tabs (Details / Dates / References)
instead of four. Placeholders reference real Dow values
(Celena / Yzabella etc. for Owner, 2337959 for OMG #, Brand / Events
etc. for Team, Copywriting/Display/... for Category). ClientTeam
selector populated from /api/client-teams with a "no teams — add
one in Settings" fallback. Category is a typed enum dropdown with
the 8 XLSX values. Risk/Priority wording mirrors the XLSX labels
(Priority = URGENT). Dropped HP-only fields from the UI
(formFactor, codeName, npiOrRefresh, businessUnit placeholder,
agency, Financial tab, Workfront ID placeholder). Legacy fields
are still in the Zod schema for back-compat but not rendered.
B) Users invisible because only the admin was seeded
The plan flagged "real Dow/Oliver roster — open question" and we
never got the list, so the seed only created admin@dowjones.com.
prisma/seed-dow.ts now also creates the 9 placeholder resources
from the Resources.html prototype (Alice Chen, Ben Marsh, Cara Wu,
Dan Koch, Eva Stone, Frank Osei, Grace Lee, Hiro Tanaka, Isla Reeve),
distributed round-robin across the three placeholder pods. Each has
role + department + maxCapacity set but no passwordHash, so they
show up in the UI immediately but can't log in until an admin
invites them via Settings → Team (which issues a reset link).
Swap for the real roster whenever Zia delivers it — the emails are
@example.com so they're safe to delete.
C) Resource Manager page (matching Resources.html)
New capacity planner UI — daily hours-per-job grid.
- Schema: new ResourceBooking model { userId, date, jobNumber,
hours, note, organizationId, createdById }. Migration at
prisma/migrations/20260421000000_resource_bookings.
- Validator (src/lib/validators/booking.ts): create + list schemas
with date-only coercion.
- Service (src/lib/services/booking-service.ts): week window
helpers, create/list/delete + known-job-numbers lookup for the
popover autocomplete.
- API: GET/POST /api/resources/bookings, DELETE
/api/resources/bookings/[id], GET /api/resources/job-numbers.
Writes gated to ADMIN + PRODUCER; reads open to any signed-in
member of the org (capacity view is a shared studio-level thing,
not per-team visibility).
- Hook (src/hooks/use-bookings.ts) with TanStack Query wiring +
week-scoped cache keys.
- Page (src/app/(app)/resources/page.tsx) ports the Resources.html
design to the app's Tailwind + shadcn primitives: Resource × Day
grid grouped by department, week navigator, click-to-assign
popover with job-number autocomplete + hour chips (1/2/3/4/6/8 +
custom), capacity bar per cell, week total column with over-cap
warning, collapsible role bands. Matches the prototype's
color-hashed job chips so the same job number gets a consistent
color across the grid.
- Sidebar nav: added "Resources" entry next to Workload.
D) Docs — full README + API reference + how-to
- API.md: complete REST + webhook reference. Three auth modes
documented (session cookie / X-API-Key / OMG HMAC). XLSX upload
header map with the Dow XLSX column correspondences. OMG webhook
has the speculative payload shape + a working bash example that
signs + sends a request. Common flows at the bottom: bootstrap
from zero, OMG publishes a status change, update a job from an
external script.
- HOWTO.md: end-to-end runbook. Mental model, local dev, prod
deploy pointers, first-login ritual, add-users flow (UI + API),
client teams + pods config, XLSX ingest (UI + curl + idempotency
notes), OMG webhook wiring (secret gen through verification),
producer daily workflow, client-viewer experience, resource
planning walk-through, RBAC matrix, common-problems table, and
"change the model" pointer map for future edits.
- README.md: top intro now points at API.md / HOWTO.md / DEPLOY.md.
Verified: npx tsc --noEmit ✓ zero errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
14 KiB
Dow Jones Studio Tracker — API Reference
All endpoints live under the app's base path /dow-prod-tracker. Examples
in this doc use https://optical-dev.oliver.solutions/dow-prod-tracker as the
origin — substitute your own when self-hosting.
The canonical key for jobs is omgJobNumber. XLSX uploads and the OMG
webhook both upsert projects on this field. The REST API exposes it on every
project payload.
Authentication
Three ways to call the API.
1. Session cookie (browser / user-flow)
After a user signs in via POST /api/local-auth/login, the response sets an
authjs.session-token cookie. Subsequent requests in the same browser use
that cookie automatically. Session gets per-team visibility scoping
(admins see everything; others see only their ClientTeam's projects).
2. API key (machine-to-machine)
Send X-API-Key: <value> where <value> matches the server's API_KEY env
var. The caller is authenticated as the first ADMIN in the resolved org.
The X-Org-Id: <cuid> header can narrow to a specific org (defaults to the
first one found).
3. HMAC (webhooks only — see the OMG section below)
Scoped to /api/webhooks/*, not the rest of the API.
Data model — what is a "job"?
In Dow terms: a job ≈ one row in the Studio Tracker XLSX ≈ one Project
in the system. Key fields:
| App field | XLSX column | Notes |
|---|---|---|
omgJobNumber |
Number | Canonical key. String. Upsert primary. |
name |
Project Name | E.g. "2337959 — BRND-CMKT-WSJ-March Education Media Pack" |
status |
Status | Enum: PIPELINE, ACTIVE, ON_HOLD, COMPLETED, CANCELED, ARCHIVED |
priority |
Risk | Enum: LOW, MEDIUM, HIGH, URGENT (from Low / Medium / High / Priority) |
clientTeamId |
Team | FK → ClientTeam. Drives visibility. |
businessUnit |
Project Category | Copywriting / Display / GIF / PDF / Print / Social / Static / Video |
requestor |
Owner | Project manager name |
startDate |
Brief Acceptance Date | ISO 8601 |
dueDate |
External Deadline | ISO 8601 |
description |
Status Details | Free-form |
Under each project are deliverables (1:N), under each deliverable are pipeline stages (N per template), which carry revisions, comments, annotations.
Endpoints
Health
GET /api/health → 200, liveness only (DB + env vars)
GET /api/health?strict=1 → 200 only if seeded (org + pipeline templates present)
Return shape:
{
"status": "healthy",
"mode": "liveness",
"checks": {
"database": { "status": "ok" },
"pgvector": { "status": "ok", "detail": "v0.8.2" },
"organization": { "status": "ok", "detail": "1 org(s)" },
"pipeline_templates": { "status": "ok", "detail": "1 template(s)" },
"dev_bypass": { "status": "ok" },
"auth_secret": { "status": "ok" }
},
"timestamp": "2026-04-21T..."
}
Use the liveness endpoint for Docker/Kubernetes health checks; strict=1 is
for post-deploy verification.
Projects (CRUD)
List
GET /api/projects
Scoped by visibility. Returns JSON array of:
[
{
"id": "cmo7...",
"omgJobNumber": "2337959",
"projectCode": "BRND-CMKT-WSJ-03",
"name": "2337959 — BRND-CMKT-WSJ-March Education Media Pack",
"status": "ACTIVE",
"priority": "HIGH",
"startDate": "2026-03-10T00:00:00.000Z",
"dueDate": "2026-04-03T00:00:00.000Z",
"businessUnit": "PDF",
"requestor": "Celena",
"clientTeam": { "id": "...", "name": "Brand", "slug": "brand" },
"_count": { "deliverables": 42 },
"updatedAt": "2026-04-20T..."
}
]
Get one
GET /api/projects/:projectId
Returns the project with nested deliverables[].stages[] — enough data to
render the Kanban / deliverable detail page in one fetch.
Create
POST /api/projects
Content-Type: application/json
{
"projectCode": "BRND-CMKT-WSJ-03",
"name": "2337959 — BRND-CMKT-WSJ-March Education Media Pack",
"omgJobNumber": "2337959",
"clientTeamId": "cmo7...",
"businessUnit": "PDF",
"priority": "HIGH",
"status": "ACTIVE",
"startDate": "2026-03-10",
"dueDate": "2026-04-03",
"requestor": "Celena",
"description": "Timeline notes go here"
}
Responses: 201 + project body, 400 on validation errors, 403 if caller
lacks PROJECT_CREATE. Caller needs an existing client team — fetch via
GET /api/client-teams.
Update
PATCH /api/projects/:projectId
Same body shape, all fields optional. Visibility pre-checked.
Delete
DELETE /api/projects/:projectId
Cascades deliverables + stages.
XLSX upload — the "Job Tracker " sheet
The bulk-import endpoint parses the Dow Studio Tracker workbook and upserts
one project per row (keyed on omgJobNumber).
Preview (dry-run, nothing written)
curl -X POST \
-H "X-API-Key: $API_KEY" \
-F "file=@Dow Jones_Studio Tracker_Example.xlsx" \
"https://optical-dev.oliver.solutions/dow-prod-tracker/api/projects/bulk-import?commit=false"
Response:
{
"preview": true,
"totalRows": 27,
"validRows": 26,
"errors": [
{ "row": 21, "reason": "omgNumber: Invalid input" }
],
"rows": [
{
"omgNumber": "2337959",
"projectName": "2337959 - BRND-CMKT-WSJ-March Education Media Pack-26",
"clientTeamSlug": "brand",
"status": "ACTIVE",
"priority": "HIGH",
"externalDeadline": "2026-04-03T00:00:00.000Z"
}
]
}
The first 25 normalized rows ship in the response so you can show a preview UI. Errors are row-scoped and don't abort the batch.
Commit (actually write)
curl -X POST \
-H "X-API-Key: $API_KEY" \
-F "file=@Dow Jones_Studio Tracker_Example.xlsx" \
"https://optical-dev.oliver.solutions/dow-prod-tracker/api/projects/bulk-import?commit=true"
Response:
{
"preview": false,
"totalRows": 27,
"imported": 26,
"created": 18,
"updated": 8,
"deliverablesCreated": 267,
"errors": [
{ "row": 21, "reason": "omgNumber: Invalid input" }
]
}
- Idempotent on
omgJobNumber— re-upload the same file tomorrow and rows with unchanged numbers update in place; rows with new numbers create. - Rows that already exist: only fields that are set on the new row overwrite; blank cells don't clobber producer-edited data.
- On create, one
Deliverableis spawned per unit of# of outputsand the default Dow pipeline stages are attached.
Expected XLSX structure
Sheet name: Job Tracker (trailing space is tolerated — parser
normalizes headers). Header row detected automatically within the first
5 rows. Row 2 (the example/instructions row) is skipped.
Column mapping is substring-matched on normalized headers:
| Header (any case / trailing space) | Maps to |
|---|---|
Owner |
requestor |
Risk |
priority (Priority→URGENT, High→HIGH, etc.) |
Creative Team Member… |
assignee (not persisted on Project; flagged in errors) |
Number |
omgJobNumber (required) |
Team |
clientTeamId (resolved by slug; auto-creates if missing) |
Status |
status (Brief in Review→PIPELINE, Amends/In production/Client Review→ACTIVE, On hold→ON_HOLD) |
Project Category |
businessUnit |
Project Name |
name (required) |
Brief Acceptance Date |
startDate |
External Deadline |
dueDate |
# of outputs |
count of deliverables created |
Status Details |
description |
OMG webhook — near-real-time ingest
For the OMG platform to push job updates directly. Same upsert path as the XLSX importer, so the two can never drift.
Endpoint
POST /api/webhooks/omg
Content-Type: application/json
X-OMG-Signature: sha256=<hex HMAC-SHA256 of the raw body>
HMAC is computed with the server's OMG_WEBHOOK_SECRET (a shared-secret
ops hands to the OMG team). Timing-safe compare.
Dev bypass: set OMG_WEBHOOK_ALLOW_INSECURE=true in .env to skip
signature verification. DO NOT use in production.
Route is exempt from session auth; middleware passes all /api/webhooks/*
through.
Payload shape (speculative — confirm with Shashank)
{
"event": "job.created", // or job.updated | job.assigned | job.status_changed | job.completed
"timestamp": "2026-04-21T12:34:56Z",
"job": {
"number": "2337959", // → omgJobNumber. REQUIRED.
"name": "2337959 — BRND-CMKT-WSJ-March Education Media Pack",
"client": "Dow Jones",
"team": "Brand", // → clientTeamId, resolved by name
"category": "PDF", // → businessUnit
"status": "In production", // → ProjectStatus, mapped
"priority": "High", // → Priority, mapped
"assignees": [
{ "email": "foo@dowjones.com", "role": "Creative" }
],
"dates": {
"accepted": "2026-03-10T00:00:00Z",
"externalDeadline": "2026-04-03T00:00:00Z"
},
"outputs": 42
},
"raw": {
// any OMG-native fields we haven't mapped — land on Project.customFields
}
}
Responses
200 { "ok": true, "projectId": "cmo7..." }— upsert succeeded401 { "error": "Invalid signature" }— HMAC mismatch400 { "error": "Invalid payload" }— body didn't parse500 { "error": "Internal server error" }— something went wrong server-side
Replay safety
Idempotent: same (omgJobNumber, timestamp) replayed is effectively a noop
(the upsert just writes the same fields back). OMG can replay a queue
without worrying about duplicates.
Example — signing a payload in bash
SECRET="$(grep OMG_WEBHOOK_SECRET .env | cut -d= -f2- | tr -d '"')"
BODY='{"event":"job.updated","timestamp":"2026-04-21T12:00:00Z","job":{"number":"2337959","name":"Test","team":"Brand"}}'
SIG=$(printf "%s" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -X POST \
-H "Content-Type: application/json" \
-H "X-OMG-Signature: sha256=$SIG" \
-d "$BODY" \
https://optical-dev.oliver.solutions/dow-prod-tracker/api/webhooks/omg
Client Teams
GET /api/client-teams # list
POST /api/client-teams # admin only
PATCH /api/client-teams/:teamId # admin only
DELETE /api/client-teams/:teamId # admin only (blocks if team has projects)
POST /api/client-teams/:teamId/members # body: { userId, isPrimary? }
DELETE /api/client-teams/:teamId/members?userId=... # remove
Team slugs drive the XLSX/webhook resolution — keep them stable.
Pods
GET /api/pods
POST /api/pods # { name, slug?, leadUserId? }
PATCH /api/pods/:podId
DELETE /api/pods/:podId
POST /api/pods/:podId/members # body: { userId } — sets homePodId
DELETE /api/pods/:podId/members?userId=...
Pods are orthogonal to client teams — they drive internal capacity planning, not project visibility.
Invitations (add users)
GET /api/org/invitations
POST /api/org/invitations # body: { email, role, isExternal? }
DELETE /api/org/invitations/:id
POST creates/upserts the placeholder User, issues a password-reset token,
and returns acceptUrl — the /reset-password/<token> link you hand to the
invitee.
Resource bookings (capacity planner)
GET /api/resources/bookings?weekStart=YYYY-MM-DD
POST /api/resources/bookings # ADMIN or PRODUCER only
DELETE /api/resources/bookings/:id
GET /api/resources/job-numbers # autocomplete source for the UI
Body for POST:
{
"userId": "cmo7...",
"date": "2026-04-21",
"jobNumber": "2337959",
"hours": 4,
"note": "Client review prep"
}
Dashboards / reads
GET /api/dashboard/stats
GET /api/my-work
GET /api/workload?numWeeks=8
GET /api/calendar?startDate=...&endDate=...
GET /api/reports/weekly?date=2026-04-21
POST /api/search/semantic # { query }
All visibility-scoped — non-admins see only their client-team projects.
Common flows
Bootstrap a tenant from zero
# 1. Health
curl https://optical-dev.oliver.solutions/dow-prod-tracker/api/health
# 2. Invite the first producer (needs X-API-Key — you're the admin who ran seed)
curl -X POST \
-H "Content-Type: application/json" \
-H "X-API-Key: $API_KEY" \
-d '{"email":"producer@oliver.agency","role":"PRODUCER"}' \
https://optical-dev.oliver.solutions/dow-prod-tracker/api/org/invitations
# response: {"id":"...","email":"...","acceptUrl":".../reset-password/<token>"}
# hand that URL over for the producer to set a password.
# 3. Upload the current XLSX tracker
curl -X POST \
-H "X-API-Key: $API_KEY" \
-F "file=@Dow Jones_Studio Tracker_2026_04_20.xlsx" \
"https://optical-dev.oliver.solutions/dow-prod-tracker/api/projects/bulk-import?commit=true"
OMG publishes a status change
OMG side (pseudocode):
import hmac, hashlib, json, requests
secret = os.environ["DOW_TRACKER_SECRET"]
body = json.dumps({
"event": "job.status_changed",
"timestamp": datetime.utcnow().isoformat() + "Z",
"job": {
"number": "2337959",
"name": "…",
"team": "Brand",
"status": "Client Review",
}
}).encode("utf-8")
sig = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
requests.post(
"https://optical-dev.oliver.solutions/dow-prod-tracker/api/webhooks/omg",
data=body,
headers={
"Content-Type": "application/json",
"X-OMG-Signature": f"sha256={sig}",
},
)
Returns 200 { "ok": true, "projectId": "..." } — project updated in place
if the number already exists, created otherwise.
Update a single job from an external script
JOB="2337959"
PROJECT_ID=$(curl -s -H "X-API-Key: $API_KEY" \
"https://optical-dev.oliver.solutions/dow-prod-tracker/api/projects" \
| jq -r ".[] | select(.omgJobNumber == \"$JOB\") | .id")
curl -X PATCH \
-H "Content-Type: application/json" \
-H "X-API-Key: $API_KEY" \
-d '{"status":"COMPLETED","description":"Delivered 2026-04-21"}' \
"https://optical-dev.oliver.solutions/dow-prod-tracker/api/projects/$PROJECT_ID"
Error conventions
All endpoints return JSON on error:
400 { "error": "<human message>" }— validation / bad input401 { "error": "Unauthorized" }— no valid session / API key403 { "error": "Forbidden" }— authenticated but lacks the required permission404 { "error": "Not found" }— resource doesn't exist or isn't visible to this caller (visibility-scoped endpoints return 404 instead of 403 so ACLs aren't leaked)500 { "error": "Internal server error" }— server-side bug; check logs
Permission errors surface the missing permission:
{ "error": "Missing permission: PROJECT_CREATE" }