dow-prod-tracker/INTEGRATION.md
DJP 1b73d6b8db L'Oréal rebuild: restore review workflow, full rename, /api/v1, Box integration
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>
2026-05-12 17:51:53 -04:00

459 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Dow Jones Studio Tracker — Upstream Integration Guide
**For teams pushing data into the Studio Tracker.** You can create
Briefs, Projects, and Deliverables either via signed webhooks (push —
recommended) or via REST calls made by an authenticated user account.
This document is the spec you can work against directly; a worked
example curl + signing snippet is included for each endpoint.
---
## Base URL
```
https://optical-dev.oliver.solutions/loreal-prod-tracker
```
Every URL in this doc is relative to that base.
---
## At-a-glance
| Resource | Push (webhook) | Pull-equivalent REST |
| --- | --- | --- |
| Brief | `POST /api/webhooks/briefs` | `POST /api/briefs` |
| Project | `POST /api/webhooks/omg` | `POST /api/projects` |
| Deliverable | `POST /api/webhooks/deliverables` | `POST /api/projects/:projectId/deliverables` |
**Recommendation:** use the webhooks. They're designed for machine-to-
machine integration with HMAC signing, they're idempotent on replay,
and they don't require your system to manage user accounts + session
cookies. REST endpoints are provided for cases where a human operator
is making the call (e.g., internal tooling, back-office corrections).
---
## Webhook authentication (all three)
All three webhooks use the same HMAC-SHA256 signing scheme. Only the
secret and the signature header name differ per endpoint.
| Resource | Secret env var | Signature header |
| --- | --- | --- |
| Brief | `BRIEF_WEBHOOK_SECRET` | `X-Brief-Signature` |
| Project | `OMG_WEBHOOK_SECRET` | `X-OMG-Signature` |
| Deliverable | `DELIVERABLE_WEBHOOK_SECRET` | `X-Deliverable-Signature` |
### Signature format
```
<Header>: sha256=<hex_digest>
```
where `hex_digest` is the lowercase hex of
`HMAC-SHA256(secret, request_body_bytes)`.
> Sign the **raw request body**, byte-for-byte. Do NOT re-serialise the
> JSON before signing — any whitespace or key-order change will break
> the signature.
### Signing examples
**Bash**
```bash
BODY='{"title":"New brief","priority":"HIGH"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$BRIEF_WEBHOOK_SECRET" | awk '{print $2}')
curl -X POST https://optical-dev.oliver.solutions/loreal-prod-tracker/api/webhooks/briefs \
-H "Content-Type: application/json" \
-H "X-Brief-Signature: sha256=$SIG" \
-d "$BODY"
```
**Python**
```python
import hmac, hashlib, json, requests
body = json.dumps({"title": "New brief", "priority": "HIGH"}, separators=(",", ":"))
sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest()
requests.post(
"https://optical-dev.oliver.solutions/loreal-prod-tracker/api/webhooks/briefs",
headers={"Content-Type": "application/json", "X-Brief-Signature": f"sha256={sig}"},
data=body, # pass the SAME string we signed; don't re-serialise
)
```
**Node.js**
```js
import crypto from "node:crypto";
const body = JSON.stringify({ title: "New brief", priority: "HIGH" });
const sig = crypto.createHmac("sha256", secret).update(body).digest("hex");
await fetch("https://optical-dev.oliver.solutions/loreal-prod-tracker/api/webhooks/briefs", {
method: "POST",
headers: { "Content-Type": "application/json", "X-Brief-Signature": `sha256=${sig}` },
body,
});
```
### Error responses
| Status | Meaning |
| --- | --- |
| `401 { error: "missing signature header" }` | No signature sent |
| `401 { error: "signature mismatch" }` | Signature doesn't match body |
| `401 { error: "BRIEF_WEBHOOK_SECRET not configured" }` | Our end has no secret — contact us |
| `400 { error: "invalid JSON body" }` | Body isn't valid JSON |
| `400 { error: "invalid payload", issues: [...] }` | Valid JSON, wrong shape — see `issues` |
| `404 { error: "parent project not found" }` | (deliverables webhook) Project doesn't exist |
| `500 { error: "upsert failed", detail: "..." }` | Our side broke — include the `detail` when reporting |
---
## 1. Briefs
A **brief** is an incoming request that has not yet been accepted as a
project. Briefs land in the `/briefs` inbox and a producer decides
whether to promote them. The original brief stays as audit trail after
promotion, with `convertedProjectId` pointing at the new project.
### Webhook — `POST /api/webhooks/briefs`
**Auth:** `X-Brief-Signature: sha256=<hex>` (HMAC of raw body with
`BRIEF_WEBHOOK_SECRET`).
**Body**
```jsonc
{
// Optional: your stable ID for this brief. Providing it enables
// idempotent retries — the same externalId always updates the same
// Brief row (unique per organisation). Omit it and each call creates
// a new brief.
"externalId": "INTAKE-2026-00042",
// Required
"title": "New homepage takeover for Q2 launch",
// Optional — Markdown is fine; rendered on the /briefs page
"description": "Need 3 variants for A/B test, dark mode required.",
// Optional
"requestorName": "Alex Smith",
"requestorEmail": "alex.smith@dowjones.com",
"priority": "HIGH", // LOW | MEDIUM | HIGH | URGENT
"requestedDueDate": "2026-05-15T17:00:00Z", // ISO-8601
// Optional — the Dow-side team this brief is for. Must match a
// ClientTeam slug (currently: brand, events, b2b, content,
// briefing-team, performance). Unknown slugs are silently ignored
// (the brief is still created, just with no team assigned).
"clientTeamSlug": "events",
// Optional — source tag ("mkto", "jira", "slack-bot"…). We prefix
// with "webhook:" so the brief's source field will read
// "webhook:mkto".
"system": "mkto",
// Optional — any extra fields you want preserved, passed through
// verbatim into Brief.rawPayload. Useful when your system has data
// we don't map to a first-class field yet.
"rawPayload": {
"campaignId": "c-8829",
"internalBudget": 12500
}
}
```
**Success**`200 OK`
```json
{
"ok": true,
"briefId": "clxy12abc...",
"status": "PENDING"
}
```
**Idempotency:** when `externalId` is set, a second call with the same
`externalId` updates the existing brief rather than inserting a new one.
If you're syncing from a source-of-truth upstream system, **always send
`externalId`** so retries are safe.
### REST — `POST /api/briefs`
Same JSON body as the webhook, but authenticated via the signed-in user
(session cookie). The producer hitting this endpoint needs the
`PROJECT_CREATE` permission (ADMIN or PRODUCER role).
Use this when a human operator is creating a brief from an internal
tool that already has a user session; use the webhook otherwise.
---
## 2. Projects
A **project** is an accepted piece of work with deliverables attached.
The canonical key is `omgJobNumber` (the OMG tracking number) — all
intake paths (XLSX upload, webhook, REST) upsert on it.
### Webhook — `POST /api/webhooks/omg`
**Auth:** `X-OMG-Signature: sha256=<hex>` (HMAC of raw body with
`OMG_WEBHOOK_SECRET`).
**Body**
```jsonc
{
// Optional metadata
"event": "job.updated", // job.created | job.updated | job.assigned | job.status_changed | job.completed
"timestamp": "2026-04-22T14:00:00Z",
// Required. Canonical project key.
"job": {
"number": "OMG-12345", // REQUIRED. Project.omgJobNumber (unique)
"name": "Q2 Launch Homepage Takeover", // Human-readable project name
// Optional
"client": "Dow Jones",
"team": "Events", // Mapped to ClientTeam slug (case-insensitive)
"category": "Display", // Dow Project Category — stored on Project.businessUnit
"priority": "high", // "priority" | "high" | "medium" | "low"
"status": "In Production", // Free-form — mapped via internal STATUS_MAP:
// "brief in review" → PIPELINE
// "amends" | "in production" | "client review" → ACTIVE
// "on hold" → ON_HOLD
// Anything unmapped defaults to ACTIVE
"outputs": 3, // # of deliverables this project generates
"notes": "Priority — board sign-off required.",
// Optional — first email here becomes Project.requestor
"assignees": [
{ "email": "alex.smith@dowjones.com", "role": "Project Lead" },
{ "email": "bob.jones@oliver.agency" }
],
// Optional — ISO-8601
"dates": {
"accepted": "2026-04-20T09:00:00Z", // Project.startDate
"externalDeadline": "2026-05-15T17:00:00Z" // Project.dueDate
}
},
// Optional — anything extra lands on Project.customFields JSON
"raw": {
"costCenter": "CC-4821",
"legalReviewRequired": true
}
}
```
**Success**`200 OK`
```json
{
"ok": true,
"projectId": "clyz34def...",
"action": "created" // or "updated"
}
```
**Idempotency:** project is upserted on `job.number` (→
`Project.omgJobNumber`). Replaying the same payload is safe — second
call updates the same project, no duplicates.
### REST — `POST /api/projects`
Auth via session cookie (requires `PROJECT_CREATE`). Full field list in
[API.md](./API.md) — essentials:
```jsonc
{
"projectCode": "DJ-2026-0042", // REQUIRED, unique
"name": "Q2 Launch Homepage", // REQUIRED
"status": "ACTIVE", // REQUIRED: PIPELINE | ACTIVE | ON_HOLD | COMPLETED | CANCELED | ARCHIVED
"priority": "HIGH", // REQUIRED: LOW | MEDIUM | HIGH | URGENT
"omgJobNumber": "OMG-12345", // Optional but strongly recommended — intake key
"clientTeamId": "cm123teamid", // Optional — ClientTeam.id (not slug)
"businessUnit": "Display", // Dow Project Category
"description": "...",
"startDate": "2026-04-20",
"dueDate": "2026-05-15",
"requestor": "alex.smith@dowjones.com"
}
```
---
## 3. Deliverables
A deliverable is a single unit of work attached to a project — e.g.,
"Homepage desktop hero variant A". Stages (Pipeline → New → ... →
Completed) are auto-created the first time a deliverable is inserted.
### Webhook — `POST /api/webhooks/deliverables`
**Auth:** `X-Deliverable-Signature: sha256=<hex>` (HMAC of raw body with
`DELIVERABLE_WEBHOOK_SECRET`).
**Body**
```jsonc
{
"event": "deliverable.updated", // Optional
"timestamp": "2026-04-22T14:30:00Z", // Optional
"deliverable": {
// REQUIRED. Links the deliverable to an existing Project by its
// OMG job number. If no project with that number exists, the call
// returns 404. Create the project first (via the OMG webhook).
"projectOmgJobNumber": "OMG-12345",
// REQUIRED. Natural key within the project — same (project, name)
// on a retry updates the existing deliverable rather than inserting.
"name": "Homepage desktop hero — variant A",
// Optional
"priority": "HIGH", // LOW | MEDIUM | HIGH | URGENT
"status": "IN_PROGRESS", // NOT_STARTED | IN_PROGRESS | IN_REVIEW | APPROVED | ON_HOLD
"dueDate": "2026-05-10T17:00:00Z", // ISO-8601
"notes": "Must match Q2 launch brand guidelines v3.",
"cmfSku": "CMF-8829-A", // Internal SKU if you track one
"assetCount": 5 // Asset files expected for this deliverable
},
// Optional — lands on Deliverable.customFields JSON (pass-through)
"raw": {
"figmaFrameId": "fig-12:345",
"copywriter": "patrick.mccarthy@oliver.agency"
}
}
```
**Success**`200 OK`
```json
{
"ok": true,
"deliverableId": "clab56ghi...",
"action": "created" // or "updated"
}
```
**Idempotency:** keyed on `(projectId, deliverable.name)` within a
project. Replays with the same name update the existing deliverable.
> Parent project **must exist first.** If `projectOmgJobNumber`
> doesn't resolve, you'll get `404 { error: "parent project not
> found", hint: "Project must be created via POST /api/webhooks/omg
> first." }`. The typical integration flow is: OMG sends the project
> webhook on job creation, then fires deliverable webhooks for each
> asset as they're spun up.
### REST — `POST /api/projects/:projectId/deliverables`
Auth via session cookie (requires `DELIVERABLE_CREATE`). Note the URL
shape: deliverables are nested under a specific project by its internal
`id` (not `omgJobNumber`).
```jsonc
{
"name": "Homepage desktop hero — variant A", // REQUIRED
"priority": "HIGH", // REQUIRED
"dueDate": "2026-05-10",
"notes": "...",
"cmfSku": "CMF-8829-A",
"assetCount": 5
}
```
---
## Recommended integration flow
For a typical upstream system sending many briefs/jobs/deliverables per
day:
1. **Create a brief** (`POST /api/webhooks/briefs` with `externalId`).
A producer triages it in the `/briefs` inbox.
2. **When the brief is accepted**, the producer promotes it to a
project inside the UI — we create the Project and link back to the
Brief via `convertedProjectId`. You don't need to do anything here.
3. **If you create a Project directly** (bypassing the brief intake):
`POST /api/webhooks/omg` with `job.number` set. The Project is
created and all its pipeline stages are scaffolded.
4. **For each deliverable under that project**: `POST
/api/webhooks/deliverables` referencing `projectOmgJobNumber`. On
first call the deliverable is created and its 11 pipeline stages are
auto-populated; subsequent calls with the same `(project, name)`
update the existing deliverable.
Retries on any of the three are safe as long as you send a stable
external key (`externalId` / `job.number` / `(project, name)`).
---
## Rate limiting
Each webhook route caps traffic at **100 requests / minute per
source IP**. Over the limit, the response is:
```
HTTP/1.1 429 Too Many Requests
Retry-After: 37
Content-Type: application/json
{"error":"rate limit exceeded"}
```
Normal upstream operation stays well below this — even a morning
burst of 70 projects × a few events each fits comfortably. The
limit is a safety rail against runaway integrations, not a
throttling policy for legitimate load. If you're hitting it, back
off for the number of seconds in the `Retry-After` header and
resume. If you expect sustained throughput that exceeds the cap,
let us know and we'll raise it for your source IP.
---
## Requesting secrets
Each webhook secret is generated per environment and shared out-of-band
(not committed, not in email). To get yours, contact:
- **Dave Porter** — daveporter@oliver.agency
Include:
- Which environment you're integrating with (currently only
`optical-dev.oliver.solutions`; prod URL TBD)
- Which of the three webhooks you need access to (briefs / projects /
deliverables — usually all three)
- A rough idea of your expected call volume (helps us size alerting)
---
## Testing against the live endpoint
Every webhook has a dev-mode bypass for local/stub testing. We will NOT
enable it against shared environments, but if you're building a client
locally you can start your own instance with `BRIEF_WEBHOOK_ALLOW_INSECURE=true`
(or the matching var) — then omit the signature header for unsigned
POSTs. Never enable this in shared environments.
For integration testing against our shared dev environment, use the
real HMAC and a low-volume test batch. Every call is logged with the
`externalId`/job number, so we can tell genuine replays from
duplicates.
---
## Changelog
- **2026-04-22** — initial document. Three webhooks live on
`/loreal-prod-tracker/api/webhooks/{omg,deliverables,briefs}`.