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>
459 lines
15 KiB
Markdown
459 lines
15 KiB
Markdown
# 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}`.
|