Four phases shipped together. Each is a logical deploy unit on its own;
keeping the diff atomic so the rename runbook + migrations stay aligned.
Phase 1 — restore HP's formal review workflow
- Prisma: FeedbackItem, ReviewSession, ReviewSessionItem + enums
- New ApprovalType (NONE | SIMPLE | FORMAL) on PipelineStageDefinition
and PipelineStageTemplate. Stage row UI branches per type.
- feedback-service + review-session-service ported from HP (no ColorProbe)
- annotation-service auto-creates a FeedbackItem; revision-service
carries forward unresolved action items into the new revision.
- API: /api/reviews/*, /api/stages/[id]/feedback, /api/feedback/[id]
- Hooks: use-feedback, use-review-sessions
- UI: feedback-checklist, feedback-item-card, feedback-progress-bar,
create-session-dialog, session-builder, session-presenter,
session-summary, plus a new stage-review-panel
- Pages: /reviews list + detail, deliverable annotation review page
- Pipeline editor gets the approvalType select; sidebar gets Reviews
Phase 2 — full Dow Jones → L'Oréal rebrand + slug rename
- URL slug /dow-prod-tracker → /loreal-prod-tracker (next.config,
base path, redirects)
- docker-compose name + DB → loreal_prod_tracker; server path
/opt/loreal-prod-tracker; apache template renamed
- All visible strings → L'Oréal; sidebar bg #002B5C → black
- docs/RENAME_RUNBOOK.md describes the one-shot server migration
- Internal modules dow-excel-service/dow-import + OMG webhook domain
dowjones.com deliberately preserved (orthogonal to the rebrand)
Phase 3 — external /api/v1 for projects + deliverables
- API-key auth already in middleware; finished idempotency support
via new IdempotencyRecord model + src/lib/api/idempotency.ts
- Default-pipeline fallback in createProject when no template id given
- POST/GET /api/v1/projects + POST /api/v1/projects/[id]/deliverables
- docs/EXTERNAL_API.md with curl examples
Phase 4 — Box bidirectional integration
- JWT app-auth via jose (no extra deps). Config mounted as a docker
compose secret; deploy.sh stubs an empty {} so compose can start
before the operator drops the real JSON.
- Outbound: pushDeliverableToBox auto-fires on !APPROVED → APPROVED
in deliverable-status-service; "Send to client (Box)" manual button
on the approval stage row. Folder naming
{omgJobNumber}_{slug}_v{round}. 3-attempt exp backoff. BoxPushLog
audit.
- Inbound: /api/webhooks/box receives Box's signed events, matches by
OMG # + slug, creates a new Revision, routes to assignee or notifies
project owner. BoxInboundLog audit + two new NotificationType
values (BOX_UNMATCHED_FILE, NEW_FILE_AWAITING_REVIEWER).
- Naming-convention logic isolated in external-delivery-service so an
OMG-API transport can swap in later without touching matchers.
- Admin /settings/box page surfaces config status + recent activity.
Three Prisma migrations to apply on next deploy:
20260512000000_restore_review_workflow
20260512100000_idempotency_records
20260512200000_box_integration
URL rename is a one-shot — see docs/RENAME_RUNBOOK.md.
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 /loreal-prod-tracker. Examples
in this doc use https://optical-dev.oliver.solutions/loreal-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/loreal-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/loreal-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/loreal-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/loreal-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/loreal-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/loreal-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/loreal-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/loreal-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/loreal-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" }