dow-prod-tracker/API.md
DJP 18ae429924 Dow-ify project form + seed placeholder roster + Resource Manager + docs
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>
2026-04-20 22:31:19 -04:00

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.

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 Deliverable is spawned per unit of # of outputs and 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 succeeded
  • 401 { "error": "Invalid signature" } — HMAC mismatch
  • 400 { "error": "Invalid payload" } — body didn't parse
  • 500 { "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 input
  • 401 { "error": "Unauthorized" } — no valid session / API key
  • 403 { "error": "Forbidden" } — authenticated but lacks the required permission
  • 404 { "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" }