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

15 KiB
Raw Permalink Blame History

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

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

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

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

{
  // 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
  }
}

Success200 OK

{
  "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

{
  // 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
  }
}

Success200 OK

{
  "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 — essentials:

{
  "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

{
  "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"
  }
}

Success200 OK

{
  "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).

{
  "name": "Homepage desktop hero — variant A",  // REQUIRED
  "priority": "HIGH",                            // REQUIRED
  "dueDate": "2026-05-10",
  "notes": "...",
  "cmfSku": "CMF-8829-A",
  "assetCount": 5
}

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:

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}.