Adds PipelineBranchKind (NONE/APPROVED/DECLINED) on stage definitions
so producers can tag the two routes downstream of a FORMAL-approval
stage. The engine then picks exactly one branch per decision:
• Approve → APPROVED-branch children auto-open, DECLINED-branch
siblings auto-SKIPPED (grayed out, unreachable)
• Request Changes → DECLINED-branch children auto-stamped APPROVED
(passive record of the decline), APPROVED-branch siblings auto-
SKIPPED, then the existing rework edge fires as before
Also fixes a quiet bug in pipeline-template-service.addStage where
approvalType was being dropped from new stages (whitelist didn't
include it).
UI: dropdown on the stage edit sheet + branch-kind badges on the
deliverable detail page. SKIPPED rendering already grays things out.
Smoke test extended: 65/65 passing including the user's split-on-
decision case, N-way split, regression assertion that untagged
pipelines still open all children, and an idempotency check.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
diamond, long chain) + rework reset for any pipeline graph
Expands the smoke test from one canonical shape to every pattern a
producer might build:
- linear A → B → C → D (4 stages, full cascade)
- fan-out A → {B, C, D} (all branches open simultaneously)
- fan-in {A, B, C} → D (D stays BLOCKED until *all* parents finish)
- diamond A → {B, C} → D (both legs must finish before convergence)
- long chain (10 stages, full cascade walk)
- rework D → A (full reset of intermediate stages, head spared)
- rework D → A with B SKIPPED (SKIPPED stays SKIPPED across rework)
- rework D → C single-step (only D resets)
- user's pipeline: rework Declined → Inputfile resets
Internal-Approval + Approved + Declined; Inputfile untouched
- state-machine: all 7 new shortcut edges + 2 rejection guards
Plus a pure `planReworkReset` helper that mirrors applyRework's
where-clause math, so any pipeline + rework target combination can
be asserted offline without a DB.
Run: npx tsx scripts/test-workflow-logic.ts
57/57 passing at HEAD.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Self-contained tsx script that exercises the pure state-machine +
dependency-engine logic against the user's exact 5-stage pipeline
shape (Inputfile → Internal Approval ⇉ Approved/Declined → Delivery)
— no DB needed.
Catches the regression from earlier today where `Cannot transition
from IN_PROGRESS to APPROVED` blocked the NONE-stage Mark Complete
shortcut, plus verifies:
- Order-based dependency fallback (when no V2 edges are drawn)
- Multi-branch unblock (approving Internal Approval opens BOTH
Approved and Declined simultaneously)
- Downstream-of-downstream stays correctly BLOCKED until its
specific parent completes (Delivery waits for Approved, not for
Internal Approval)
Run: npx tsx scripts/test-workflow-logic.ts
Non-zero exit on any failure. 19/19 assertions pass against current
HEAD.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
NONE-approval stages bypass the review surface by design — the UI shows
"Mark Complete" which sets APPROVED directly. The state machine still
enforced the legacy "go through IN_REVIEW first" rule, so the click
returned `Cannot transition from IN_PROGRESS to APPROVED`.
Expands the allowed transitions to cover the producer-friendly
shortcuts they'd otherwise need to bounce through:
IN_PROGRESS → +APPROVED, +CHANGES_REQUESTED, +SKIPPED
CHANGES_REQUESTED → +APPROVED
BLOCKED → +IN_PROGRESS
IN_REVIEW → +IN_PROGRESS (cancel review path)
DELIVERED → +APPROVED (downgrade if mistakenly delivered)
SKIPPED → +IN_PROGRESS (unskip directly without NOT_STARTED)
FORMAL/SIMPLE stages still flow naturally through IN_REVIEW because
the StageReviewPanel buttons set those explicitly — the state-machine
loosening doesn't change their UX, just stops blocking the NONE path.
Auto-advance (819288d) already treats APPROVED / DELIVERED / SKIPPED
as terminal so this Mark-Complete click on Inputfile correctly
cascades and unblocks Internal Approval downstream.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Producers wanted to be able to book a person against a specific
deliverable's OMG #, not just a whole project's. The data model
already supports it (ResourceBooking.jobNumber is just a string),
but the UI only sourced project numbers.
Service (booking-service.listKnownJobNumbers):
- Returns rows from BOTH projects and deliverables that have an
omgJobNumber, each tagged with a `scope` discriminator
("project" | "deliverable") and (for deliverable rows) the
parent project name.
Hook (use-bookings.useKnownJobNumbers):
- Exposes the new shape via the KnownJobRow type.
UI (resources page AssignPopover):
- New segmented Scope toggle at the top of the popover. Default is
Project (the more common high-level case); switching to
Deliverable filters the list to deliverable-scope rows.
- Search filter now also matches the parent project name when
scope=deliverable.
- Deliverable rows render with a small "in <Project Name>" subtitle
so producers can pick by context, not just by an opaque OMG #.
- Label flips between "Project OMG #" and "Deliverable OMG #" so
the field meaning is unambiguous.
No schema change. ResourceBooking.jobNumber is unchanged; rows just
get a different value depending on the picked scope, and the existing
display + reporting paths continue to work.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The rework transition logic already existed (applyRework in
stage-transition-service correctly resets stages between the rework
target and the current stage to NOT_STARTED). What was missing was
the wiring: the "Request Changes" button on the FORMAL StageReviewPanel
just set the stage status to CHANGES_REQUESTED in place — it never
fired the rework transition.
New server entry point:
- src/lib/services/stage-transition-service.ts: requestChangesOnStage()
looks up rework edges from this stage's PipelineStageDefinition.
When ≥1 edge exists, picks the lowest-order target (walks furthest
back — conservative default) and delegates to
transitionDeliverableToStage, which handles the proper rework
transition + intermediate stage reset.
No edge configured → falls back to legacy CHANGES_REQUESTED in
place. Preserves behaviour for pipelines without rework drawn.
New route:
- POST /api/stages/:stageId/request-changes — wraps the service.
Maps TransitionError to 400 with the error's message.
UI:
- stage-review-panel.tsx: "Request Changes" button now calls the new
endpoint instead of mutating status directly. Toast tells the
producer which stage we walked back to ("sent back to <Stage Name>
— intermediate stages reset") on rework, or plain "Changes
requested" on in-place. Disables during the round-trip; relevant
queries invalidate on success.
Net effect on a 5-stage pipeline (Inputfile → Internal Approval →
Approved → Declined → Delivery) with a rework edge
Internal Approval → Inputfile:
- On Internal Approval, click "Request changes"
- Deliverable transitions back to Inputfile (IN_PROGRESS)
- Any intermediate stage approvals are reset to NOT_STARTED
- Producer iterates on Inputfile, marks complete, the pipeline
cascades forward again via auto-advance (819288d)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The earlier auto-advance commit (f0eb29d) didn't actually fire on
dynamic pipelines because the dependency engine was still reading
template.dependsOn — the legacy seed graph, almost always empty for
pipelines built in the new editor. Producers saw downstream stages
stay BLOCKED forever no matter how many terminal transitions fired.
dependency-engine.ts:
- Resolves prerequisites against three sources in priority order:
1. stageDefinition.dependsOn (V2 dynamic edges, by stageDefinitionId)
2. template.dependsOn (legacy seed graph)
3. order fallback (depend on the immediately previous-order stage)
- getStageIdsToUnblock now also considers NOT_STARTED stages, not
just BLOCKED ones — when a gate completes, every waiting stage
flips to IN_PROGRESS.
stage-service.ts:
- All four allStages.map blocks now include `order` + a normalised
`stageDefinition` so the engine sees the dynamic graph.
- Stage fetches include stageDefinition.dependsOn.
- Bulk update path: unblock target is IN_PROGRESS now (was
NOT_STARTED) to match the single-update behaviour.
deliverable detail page:
- NONE-approval stages use a minimal transition set:
NOT_STARTED → IN_PROGRESS → APPROVED → IN_PROGRESS (reopen).
No more Submit-for-Review / Approve / Mark-Delivered / Request-
Changes / Skip noise on stages that have no review concept.
- The APPROVED button on a NONE stage reads "Mark Complete" so the
affordance signals workflow close, not a review approval.
Net effect on a typical 5-stage pipeline:
Click Start Work on stage 1 → Mark Complete → stage 2 auto-opens
→ Mark Complete → stage 3 auto-opens, and so on. One terminal
click per stage. FORMAL/SIMPLE stages still go through the
StageReviewPanel for the review flow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Producer feedback: too many "Start Work" buttons everywhere, unclear
forward path, lots of clicking. This commit collapses the common cases.
Server (stage-service.updateStageStatus):
- Any TERMINAL transition (APPROVED / DELIVERED / SKIPPED) now
auto-opens every downstream stage whose prereqs are now satisfied.
Previously only `isCriticalGate` stages did this, and the unblock
landed them in NOT_STARTED — still requiring a Start Work click.
Now the gate requirement is dropped and unblocked stages flip
straight to IN_PROGRESS with a startDate stamp.
UI (deliverable detail stage rows):
- BLOCKED rows: no transition buttons. The blocked hint + lock icon
explain why nothing's actionable here (path forward is to finish
the upstream prereq).
- Stages with approvalType != NONE: hide the inline transition
buttons entirely — StageReviewPanel below the row owns Approve /
Request changes / Send to client / New revision, so duplicates
just cluttered the view.
- Everywhere else: same buttons as before, with the muted "Skip /
Reopen" outline so the forward action is the obvious primary.
UI (stage-review-panel):
- Approve button relabelled "Approve & advance" with a tooltip
explaining that it auto-opens the next stage.
Net effect: a typical 5-stage pipeline that previously needed
~10 button clicks to walk through (Start, Submit, Approve, Start,
Submit, Approve, ...) now needs ~5 — the first Start Work, then
each terminal transition cascades automatically.
What's NOT yet in this commit (acknowledged as follow-ups):
- Resources page deliverable-level assignment toggle.
- Revision-rework: when a stage requests changes via a configured
rework path, the intermediate stages between rework target and
current should reset. The rework transition itself still works;
just the intermediate cleanup is pending.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a newer revision exists on a stage, the older one becomes
immutable except for feedback status changes (resolve / verify /
reopen). New annotations, edits, deletes, and feedback content edits
are blocked server-side. Audit trail stays clean across versions.
Server enforcement:
- New src/lib/services/revision-lock.ts — isRevisionLocked +
assertRevisionUnlocked + RevisionLockedError (code: REVISION_LOCKED).
- annotation-service: createAnnotation / updateAnnotation /
deleteAnnotation all gate on the lock.
- feedback-service: createFeedbackItem / updateFeedbackItem /
deleteFeedbackItem gate on the lock; resolveFeedbackItem /
verifyFeedbackItem / reopenFeedbackItem deliberately don't —
those are the "completion" actions the user wants kept open.
- api-utils.serverError maps the lock error to HTTP 409 so the
UI sees a clean status instead of generic 500.
UI:
- Review page: passes readOnly = !canEdit || activeRevisionLocked
to the image AnnotationLayer (hides the drawing toolbar on
older revisions).
- New amber "Locked — newer revision exists" badge in the stage
header when the active revision isn't the latest.
Settings / Box page crash fix:
- "Subscribe watch folder" → "Something went wrong / triggers.join"
came from a webhook entry rendered without a triggers array.
- Defensive guards on w.triggers / w.target across the list.
- Surfaces list.error message inline instead of crashing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The OMG # field was registered on the form + validator but never made
it onto the prisma write — `createDeliverable` destructured a fixed
list of columns and the new field wasn't on it. Same gap on the
update path. Also the edit dialog never pre-filled the current value
when reopening.
- createDeliverable: include omgJobNumber (empty string → null).
- updateDeliverable: normalise omgJobNumber the same way before
passing to prisma.deliverable.update.
- Deliverable detail page edit dialog: defaultValues now seeds
omgJobNumber from the loaded row.
Empty-string normalisation matters because of the unique-per-org
constraint — two manual deliverables both clearing the field would
otherwise collide on `""`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Short-form `- box-config` mounted the file at /run/secrets/box-config
(no extension), but BOX_CONFIG_FILE everywhere references the .json
suffix. Switch to long-form `target:` so the mount path matches the
configured env value and isBoxConfigured() reads the right file.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds list / create / delete for V2 webhook subscriptions, gated on
ADMIN. Solves the "is the subscription set up correctly?" diagnostic
without minting an access token by hand.
GET /api/settings/box/webhooks — list
POST /api/settings/box/webhooks — create (defaults: BOX_WATCH_FOLDER_ID
as target, current host + base path
as address, FILE.UPLOADED/COPIED/MOVED
as triggers)
DELETE /api/settings/box/webhooks?id=… — remove
On the settings/box page: a new "Webhooks (V2)" section shows existing
subscriptions and a "Subscribe watch folder" button. When create
succeeds the response's primary + secondary signing keys render in a
one-time copy block — operator pastes into .env, restarts the app.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The leading digits in the inbound filename convention identify the
deliverable, not the project. Adds Deliverable.omgJobNumber (unique
per org), flips inbound-ingest-service to resolve the deliverable
directly by that number (no more project hop), points box-outbound's
folder name at the deliverable's OMG, and surfaces the field in the
deliverable form + detail page.
Schema:
- NEW Deliverable.omgJobNumber String?
- @@index([omgJobNumber]) + @@unique([organizationId, omgJobNumber])
- Migration 20260512400000_deliverable_omg_number
Inbound matcher:
- Skips project lookup, resolves deliverable directly.
- Slug capture from the filename becomes a sanity-check warning
instead of a hard requirement (OMG # is authoritative now).
- Unmatched message reads "No deliverable found with OMG # X".
Outbound:
- buildDeliveryNaming now takes deliverable.omgJobNumber.
- "Missing OMG number on deliverable" failure case replaces the
old project-level check.
UI:
- Deliverable form dialog: new "OMG Deliverable #" input with a
one-line hint explaining the inbound matching convention.
- Deliverable detail metadata: shows "OMG Deliverable #" alongside
the existing "OMG Project #" (relabelled from "OMG Job #").
Project.omgJobNumber is kept as-is — still used for OMG webhook
project upserts; just no longer drives the file matcher.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reject inbound events whose Box file isn't a direct child of the
configured BOX_WATCH_FOLDER_ID, and explicitly skip any file whose
parent IS BOX_OUT_FOLDER_ID. Box webhooks are folder-scoped already
so a normal subscription won't fire on our own outbound writes —
this is defence in depth in case a future webhook is mis-subscribed
or the two folder ids ever drift into the same value.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two small UX fixes for manual-start clarity on FORMAL stages:
1. "New revision" button label is "Add first version" when the stage
has no revisions yet (was the cryptic "+ V0.1"). After the first,
subsequent labels keep the "+ V0.2", "+ V0.3" pattern. The first
button is also rendered as primary (default variant) to make the
start-here action obvious.
2. "Open review" is now visible on FORMAL stages whenever the
approvalType is set, not only when a revision exists. The label
flips to "Start review" when there are no revisions yet, so
producers can navigate to the review surface immediately and
upload from there.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the transitional stub with the full HP-style annotation surface
adapted to one-asset-per-revision. Image + video annotation, full
toolset (rect / ellipse / arrow / freehand / text / pin / screenshot),
cross-revision comparison (any pair from the chain, defaults latest vs.
prior), all comparison modes (side-by-side / wipe / overlay / toggle),
video player with timeline scrubbing + frame-aligned annotation markers,
sidebar (revision timeline + feedback checklist), keyboard shortcuts,
read-only path for CLIENT_VIEWER.
Adaptation from HP:
- HP read attachments.{current,reference,video,referenceVideo,screenshot}.
Collapsed to revision.asset (single image OR video, MIME-detected).
- HP compared current-vs-reference within a single revision. Now
enumerates one RevisionOption per revision and compares any pair
from the chain.
- ColorProbe / CMF eyedropper dropped (out of scope for L'Oréal).
- ImageGallery + image/video toggle removed (one asset per revision
makes them redundant; the sidebar timeline lists every revision).
- AssetUploadZone (the unified zone) replaces the four HP upload zones.
- Send-to-client wired into the top bar via useSendToClient.
- Query params: ?stage= and ?revision= both preselect.
- Polls every 3s while any video is still "processing" so the HLS
transcode result auto-renders when ready.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Image revisions now render with the full annotation toolset on top of
the asset (rectangle / ellipse / arrow / freehand / text / pin /
screenshot). Tracks natural image dimensions on load and resizes the
overlay alongside the displayed copy via ResizeObserver, so SVG
coordinates stay aligned even when the image is scaled down to fit
the viewport.
Read-only for CLIENT_VIEWER. Video annotation + cross-revision
comparison still pending — image is the common case so it ships first.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surface the AssetUploadZone directly on the deliverable review page
when the latest revision is asset-less, plus a "Replace asset" toggle
when one is already uploaded. CLIENT_VIEWER stays read-only.
Closes the gap where producers were stuck on "No asset uploaded yet
for V0.2" with no way to upload from this page (they had to go back
to the deliverable detail panel).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DeliverableStage.templateId is a NOT-NULL FK to pipeline_stage_templates
(HP-era seed). On a fresh DB with no seed run, that table is empty —
so even with a dynamic pipeline attached, the FK has nothing to point
at and stage creation silently fails (deliverable lands with 0 stages).
When createDeliverable detects this state, mirror each dynamic stage
definition into pipeline_stage_templates (idempotent upsert on slug)
so the FK is satisfied. No schema change; no operator action required.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
If a deliverable is created on a project that has no `pipelineTemplateId`,
look up the org's default `PipelineTemplate` and use its stages — mirrors
the existing fallback in `createProject`. Also persists the chosen
template back onto the project so future deliverables don't re-resolve
and the deliverable detail UI can show "Pipeline: …".
Closes the gap where a deliverable created before a pipeline was attached
to its project landed with zero stages.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reshapes the Revision model around how producers + clients actually
work: one upload per round (image OR video, no references), per-stage
version chain V0.1 → V0.2 → V1 → V1.1 → V1.2 → V2 …, holding pen for
inbound files received while a previous version is in client approval,
and per-pipeline filename matching for inbound ingest.
Schema:
- Revision: drop roundNumber + multi-key attachments. Add major/minor/
sentToClient/sentAt/asset (single image-or-video object) + unique
(deliverableStageId, major, minor).
- PipelineTemplate: add inboundFilenameRegex (per-pipeline matcher).
- NEW HoldingPenFile model + HoldingPenSource enum (MANUAL/API/BOX).
- Empty prod DB → clean ALTER TABLE migration, no backfill.
Send-to-client semantics:
- The latest internal revision IS the V{n} — promote in place
(major+=1, minor=0, sentToClient=true). Chain reads V0.1, V0.2, V1,
V1.1, V1.2, V2, ...
- sendToClient is the ONLY Box-push trigger. Auto-on-APPROVED removed
from deliverable-status-service. APPROVED is now an internal "done
iterating" state, separate from "shipped to client".
Three input channels, one matcher:
- NEW src/lib/services/inbound-ingest-service.ts — consolidates Box
webhook, /api/v1/upload, and manual upload-by-filename. One regex
resolver, one project/deliverable matcher, one routing + notification
fan-out.
- box-inbound-service is now a thin wrapper that fetches Box metadata
and delegates.
- external-delivery-service.parseInboundFileName takes an optional
regex override. Default: ^(\d+)_([a-z0-9-]+)(?:_v(\d+))?(?:\.[a-z0-9]+)?$
captures (1) OMG #, (2) slug, (3) optional version.
- buildDeliveryNaming now uses {omg}_{slug}_V{major} for Box folders.
Holding pen:
- When a deliverable is IN_REVIEW (a V{n} is awaiting client decision)
and a new file arrives via any channel, it lands in HoldingPenFile —
NOT in the active chain. Producer manually promotes (creates the
next minor on the chosen stage) or discards.
- Held files render in a new HoldingPenPanel on the deliverable detail
page when present. Source pill (MANUAL/API/BOX), parsed identifiers,
target-stage picker, Promote + Discard buttons.
Per-pipeline regex UI:
- NEW InboundMatchingRules section in the pipeline editor. Live regex
compile, sample-filename match test with echoed captures, save with
server-side regex-compile validation.
Upload simplification:
- storeRevisionAsset replaces the old processAndStoreImage +
processAndStoreVideo + multi-key attachment merge. MIME-detects kind,
preserves PNG alpha flatten + TIFF→PNG + thumbnail for images, and
keeps the async HLS transcode pipeline for videos.
- The single revision upload route drops the `type=` parameter.
- Three legacy components deleted: image-gallery, image-upload-zone,
video-upload-zone. NEW asset-upload-zone (unified drop zone).
New API:
- POST /api/stages/:stageId/revisions/:revisionId/send-to-client
- GET /api/deliverables/:id/holding-pen
- POST /api/deliverables/:id/holding-pen/:fileId (promote)
- DELETE /api/deliverables/:id/holding-pen/:fileId (discard)
- POST /api/v1/upload (multipart; same matcher as Box webhook)
UI label rollup:
- src/lib/format-revision-label.ts is the single source of truth.
Sent revisions render `V{major}`; internal `V{major}.{minor}`.
- Revision node/timeline, session presenter/builder/summary, revision
list, stage review panel — all read the helper.
- Comparison toolbar simplified to cross-revision picker (no more
reference-vs-current within a single revision).
The deliverable annotation review page is a temporary stub (links back
to the deliverable detail page where the in-row controls live). The
full annotation overlay + comparison surface will be rebuilt against
the single-asset model in a follow-up.
Run on next deploy:
docker compose -p loreal-prod-tracker exec app npx prisma migrate deploy
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The legacy navbar-logo.png still shipped the previous client's graphic.
Until a real L'Oréal logo asset is supplied, render the wordmark as
text — white on the black sidebar background, both desktop and mobile.
Drop a navbar-logo.png into public/ later and swap back to <img> if you
want the graphic back.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wipes all other users and upserts a single admin with the given email +
password. Use after the rename when you want a clean slate.
docker compose -p loreal-prod-tracker exec app \
npx tsx scripts/create-admin.ts admin@loreal.com 'YourPasswordHere'
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Three connected improvements on the Deliverables board:
1. Drag highlight. When you start dragging a card, every column
previews whether a drop would be allowed. Same rules as the
server validator:
- forward-by-order (target.order > current.order) → green
- backward + declared rework edge exists → green
- same column → no-op, no tint
- backward without a rework edge → red
- cross-pipeline / "__none__" bucket → red
Columns the drop can't target also fade to 50% opacity to pull
focus toward the valid ones. Status mode is always green since
Deliverable.status is a direct write.
Drag validity uses rework edges pulled live from the selected
pipeline's detail (usePipelineTemplate), so configurator changes
in /settings/pipelines show up immediately on the board.
2. Pipeline filter. New "All Pipelines" select in the filter row,
shown only when >1 pipeline template exists (noise otherwise).
When set, filters deliverables by project.pipelineTemplateId AND
drives the board columns + rework edges from that pipeline.
3. "In pipelines" footer. At the base of each stage column, shows
which pipeline templates include that stage slug — but only when
the stage is shared across ≥2 pipelines. Single-pipeline labels
would be redundant.
Plumbing:
- getAllDeliverables service + AllDeliverableRow now include
project.pipelineTemplateId so the page can filter without a
second round-trip.
- DeliverableBoard accepts two new optional props: pipelineStages
extended with reworkToSlugs (per-stage rework targets) and
allPipelines (list of { id, name, stageSlugs } for the footer).
- Deliverables page computes boardPipelineStages from the active
pipeline's detail (falls back to the default when filter is
"all") and allPipelinesSummary from the list response.
Old calendar plotted every in-flight stage of every deliverable as
a horizontal span. With Dow's 11-stage pipeline that's 4+ bars per
deliverable per week — unreadable after just a handful of jobs.
Rewrote as a month grid of due-date pills:
- One pill per deliverable on its due date, coloured by priority.
- Filters: project + priority.
- Prev / Today / Next month nav.
- Click a pill → deliverable detail.
- Cells show up to 4 pills + "+N more" overflow indicator.
Plumbing:
- calendar-view.tsx rewritten top-to-bottom (fresh ~230 LOC).
Uses the existing useAllDeliverables hook — no new API surface.
- Deleted ~1300 LOC of orphaned components: calendar-grid,
calendar-event-pill, calendar-day-detail, calendar-heatmap,
calendar-filters. Plus the /api/calendar route and
use-calendar hook (nothing else referenced them).
- Page title + breadcrumb label → "Due Date Calendar".
- Sidebar nav label → "Due Dates" to save space.
Net −1100 LOC. The calendar now earns its place ("what's due this
week?") without fighting the Timeline view for the Gantt job.
duplicatePipelineTemplate was written before the PipelineStageRework
table existed, so cloning a pipeline silently lost every declared
pass/fail pushback rule. Board-drag rework on the clone would then
reject every backward move because no rework edges existed. Now
copies reworkFrom edges alongside dependsOn, reusing the old→new
stage-id map so remapping is free.
Also added a "Duplicate" button to the pipeline editor header
(next to "Set Default") so admins editing a pipeline can fork and
edit the copy without going back to the /settings/pipelines list.
On success we push into the new clone — the admin's next action
is almost always "tweak the copy", no need to make them navigate.
Existing "Duplicate" button on the list page (per-card hover)
stays — this just adds a second entry point.
Previously notifyAssignment bailed early when userId ===
assignedByUserId on the logic "you just did it, you know".
But producers use the /notifications page as their source-of-
truth for "everything I'm on", and a self-pick silently not
landing there broke that mental model.
Always notify now. Copy branches to match the case:
other → "New assignment" / "You were assigned to …"
self → "You picked up an assignment" / "You assigned
yourself to …"
Also keeps the notification bell unread counter accurate — was
always 0 after someone self-assigned a dozen stages.
Root cause of the mismatch: Deliverable.status is a denormalised
column that was only written at create-time (default NOT_STARTED)
and never refreshed when stages moved. The Projects board read it
live and showed "Not Started" while the pipeline ring + dominant-
stage view correctly showed "at Client Feedback (6/11 stages
complete)".
Fix in two parts:
1. New deliverable-status-service with:
- computeDeliverableStatus(stageStatuses[]) — pure function with
the summary rule:
all stages terminal → APPROVED
any IN_REVIEW → IN_REVIEW
any IN_PROGRESS/CHANGES_REQUESTED → IN_PROGRESS
else → NOT_STARTED
ON_HOLD is producer-managed and never overwritten.
- recomputeDeliverableStatus(deliverableId, txClient?) —
executes the rule + writes if different. Accepts an optional
Prisma tx client so callers can run inside their own
transaction.
2. Wired into every stage-write path:
- stage-service.updateStageStatus (single-stage transitions)
- stage-service bulk transaction (bulkUpdateStages) — dedups
touched deliverable IDs so we don't recompute twice.
- stage-transition-service forward + rework (board drag) —
inline inside the same $transaction so the board bucket is
correct on the next refetch.
3. Backfill script scripts/recompute-deliverable-statuses.ts —
one-off sweep to fix existing stale rows:
npx tsx scripts/recompute-deliverable-statuses.ts
Run once after deploy.
Path like /projects/cmo8zgk46000g01rt473bhbdy/deliverables/cmo8zj688...
used to render the opaque cuid segments verbatim, giving a header
like "Projects / cmo8zgk46000g01rt473bhbdy / Deliverables / ...".
Breadcrumb now detects the ID segments by position (any segment
right after "projects" / "deliverables" / "briefs") and swaps in
the human name.
- Projects → "#<OMG#> — <name>" when omgJobNumber is set, bare name
otherwise. Most Dow projects come in with an OMG number so the
prefixed form is the common case.
- Deliverables → name only (no OMG equivalent on deliverables).
- Briefs → title.
Queries are conditional on the URL shape and share cache keys that
won't collide with the page-level useProject / useDeliverable /
useBriefs hooks. Page visits usually hit the same endpoints first,
so the crumb resolves from cache with no extra round-trip. Truncate
overlong titles at 240/320px with a title attr for the full string
on hover.
Edit Project dialog's Owner/Project Manager field is now a dropdown
of real users on the system instead of a text box — so the value is
a FK to a real User row rather than a hand-typed string that drifts
from the roster.
Schema:
- Project.requestorUserId (nullable FK → User) + relation
"ProjectOwner" on both sides. Migration 20260425000000_project_owner_fk.
- Existing freeform `requestor` column stays put. It's now the
FALLBACK for ingest-sourced rows where the upstream Owner name
doesn't match a user on the system yet; the UI prefers the linked
user's display name when one exists.
Plumbing:
- createProjectSchema accepts requestorUserId.
- project-service listProjects + getProject both include
requestorUser { id, name, email } so the UI doesn't need a second
round-trip.
UI:
- ProjectFormDialog swaps the Input for a Select populated by
useUsers(). "— Unassigned —" at the top clears the link. When a
project has a legacy freeform requestor and no linked user, the
dialog shows it as a hint ("Currently unlinked (from intake):
<name> — pick a user from the list to replace") so admins can
see what came in from the XLSX/webhook and bind it.
- Project detail view + list view both prefer
requestorUser.name ?? requestorUser.email ?? requestor for the
"Owner" column. Sort key on the list uses the same chain.
User flagged the Edit Project dialog showed fields that weren't on
the detail view (and vice versa), and that several fields were
HP-era leftovers that Dow doesn't use at all (Form Factor, Quarter,
Agency, NPI/Refresh, costs). Same complaint: couldn't edit
deliverable info even as admin.
Project dialog:
- Dropped the "References" tab entirely. Workfront ID + BMT ID are
HP-era tracking fields Dow doesn't use; keeping them made the
form disagree with the detail view.
- Moved "Owner / Project Manager" from References → Details so it's
alongside the other first-class fields (OMG#, team, category,
status, priority, owner).
- Dialog is now two tabs: Details + Dates. Fields stay in lockstep
with the view.
Project detail view:
- Trimmed the metadata grid to match the dialog 1:1: OMG Job #,
Project Code, Client Team, Status, Priority, Owner, Category,
Brief Accepted, Due Date. Dropped Quarter, Form Factor, Code
Name, NPI / Refresh, Agency, Workfront ID, OMG Code, BMT ID,
Estimated Cost, Actual Cost — those stay in the schema for
back-compat but no longer clutter the UI.
- Edit button's defaultValues trimmed to match.
Deliverable detail:
- New Edit button on the "Deliverable Info" panel (admin-only,
same canEditAttachments gate as the attachments panel).
- Reuses the existing DeliverableFormDialog — now smart about
edit mode (derives from prefilled name; submit button says
"Save"/"Saving" instead of "Create"/"Creating", More Fields
section auto-opens so producers can see everything on edit).
- Dropped "WF Input Date" from both the form and the view — HP
Workfront intake date, not part of the Dow workflow.
- Edit dialog pre-fills from the loaded deliverable, submits via
the existing useUpdateDeliverable hook, toasts on success/fail.
Two production-readiness fixes called out in the scaling review
for ~40 concurrent users + daily upstream webhook traffic:
1. Prisma connection pool
DATABASE_URL now carries ?connection_limit=20&pool_timeout=10
both in docker-compose.yml (prod) and .env.example (local).
Default is cpus*2+1 (~5-9 inside a container) which can exhaust
at peak when mutations + TanStack Query polling coincide.
Postgres max_connections is 100 so 20 × a couple of app replicas
leaves headroom.
2. Webhook rate limiter
New src/lib/webhooks/rate-limit.ts — in-memory sliding-window
limiter keyed on "<scope>:<ip>". 100 req/min per IP per webhook
(omg / deliverables / briefs). Applied to all three POST
handlers; over the limit returns 429 with a Retry-After header.
Dev-mode bypass honours the matching *_WEBHOOK_ALLOW_INSECURE
env so stub testing isn't throttled.
Single-process only — swap the Map for Redis if we scale to
multiple Next.js instances. Single-instance dow-prod-tracker on
optical-dev is the target today, so in-memory is sufficient.
Also updated INTEGRATION.md with a rate-limiting section so the
upstream integrator knows what to expect + how to handle 429s.
Two complementary safety nets for the business-critical DB:
1. Host-side nightly backup
- scripts/backup-db.sh drives pg_dump through docker compose exec,
gzips to /srv/backups/dow-prod-tracker/, auto-prunes >30 days.
Env-overridable (BACKUP_DIR / RETAIN_DAYS / COMPOSE_DIR / PGUSER
/ PGDATABASE) for anyone running a different layout.
- Runs from host cron at midnight; crontab snippet + restore
procedure + optional off-site (S3 / rsync) pattern documented
in DEPLOY.md.
2. On-demand admin XLSX export
- New GET /api/projects/export?format=xlsx — ADMIN-only; builds a
two-sheet workbook via new buildFullExportWorkbook():
- "Job Tracker": one row per project, header strings chosen to
round-trip through the Dow bulk-import endpoint so a dump can
be re-ingested in a worst case. Owner / Risk / OMG Number /
Team / etc., mirroring the importer's fuzzy HEADER_MATCHERS.
- "Deliverables": one row per deliverable with project OMG #,
status, priority, dates, CMF/SKU, current stage, assignees,
notes — enough to reconstitute pipeline state.
Respects visibility scoping (ADMIN sees everything).
- Dashboard shows an "Export Full XLSX" button in the header for
admins; streams the workbook with a date-stamped filename using
the standard blob-download pattern from ExportButton.
Both are additive — no schema, no migration, no deploy breakage.
Three fixes the user flagged on the /projects/:id page:
- Metadata panel rendered only populated fields; empty ones vanished
entirely. A freshly-created project showed "Business Unit: Display"
and nothing else, making admins think half the fields didn't exist.
Now all fields always render with an em-dash ("—") for empty —
obvious what's missing, and fmt helpers keep dates/money clean.
- Default the collapsible "Project Details" to OPEN. Was collapsed,
which hid the whole form behind a click.
- New Edit button inside the details panel header (admins only).
Reuses the existing ProjectFormDialog in edit mode with
defaultValues populated from the loaded project; submit goes via
useUpdateProject (already existed). Toast on success/error.
- "Owner" is now a labeled field in the panel — maps to the
existing Project.requestor column (same field the XLSX intake
column is named "Owner"; the form dialog already labeled it that
way but the read view called it "Requestor").
Also added Description block above the metadata grid so narrative
context isn't hidden behind the Edit dialog.
Splits the attachments experience in two:
Top panel (AttachmentsPanel):
- Renamed "Attachments & Links" → "General Assets" with a subtitle
calling out the split ("Applies to the whole deliverable —
stage-specific assets live on each pipeline stage").
- List filtered to only show attachments with
stageDefinitionId == null. Uploads from here default to no-stage.
- Empty-state copy updated to describe the new purpose.
Per-stage indicator (new StageAttachmentIndicator):
- Small paperclip icon + count rendered on each stage row, right
alongside the status badge.
- Zero + canEdit: paperclip + small "+" so the affordance to add is
still visible. Zero + read-only: component renders nothing so
CLIENT_VIEWERs don't see empty affordances they can't act on.
- Click opens a Popover listing attachments tagged to that stage,
with inline upload + link-add buttons at the bottom. Uploads auto-
tag to the stage via useUploadFileAttachment({ stageDefinitionId })
— the mutation already accepted this field, no API change needed.
- Delete allowed on rows from the popover, same canEdit gate.
- Figma embeds preview inline in the popover just like the main panel.
No schema / migration / API / service changes — DeliverableAttachment
already had the stageDefinitionId field, the POST endpoint already
accepted it, and useAttachments already returned stageDefinition.
Pure UI work; rides on the existing query cache so the indicator
counts update instantly when the main panel or a sibling stage
popover writes.
Producers assigning work can now optionally book the artist's hours
in the same popover — no round-trip to the Resources tab.
Popover changes (AssignArtistPopover):
- Accepts an optional `jobNumber` prop. When provided, a "Also book
hours" checkbox appears between the role selector and user search.
Expanded state reveals a date input + 1/2/3/4/6/8h chip group.
Collapsed by default so the common assign-only flow stays one-click.
- On user pick, the assign mutation fires first; on success, if the
book-hours toggle was on we chain a ResourceBooking mutation. A
booking failure keeps the assignment in place and surfaces a
specific error toast ("Assigned, but booking failed") so producers
aren't left wondering which half went wrong.
Plumbing:
- useCreateBookingAnyWeek() — booking mutation not bound to a
specific week's query key. Blast-invalidates every ["bookings",*]
query instead, since the deliverable detail page doesn't know
which week the chosen date falls in.
Wiring:
- deliverable detail page passes `deliverable.project.omgJobNumber`
as `jobNumber` — getDeliverable already includes project context
in its response (added earlier for the info panel).
- Deliverables without an OMG job number keep the old assign-only UI.
Four connected UX improvements in one commit:
1. Assignment creates a Notification row. notifyAssignment() was
already written in notification-service but nothing called it.
Wired into assignUserToStage right after the upsert — fires
only on NEW assignments (not role updates), never self-notifies,
non-blocking. The assignee sees it on /notifications with a
link straight to the deliverable.
2. Workload drops approved stages from weekly totals. Previously
terminal stages (APPROVED / DELIVERED / SKIPPED) stayed in the
week they completed, making people look busier than they were.
Now they drop the moment the stage goes terminal, matching the
user's intuition ("if it's approved it shouldn't count as my
workload any more").
3. My Work rows are clickable — each row is now a <Link> to the
deliverable detail page. Hover state too.
4. My Work has a completed-window toggle. Pill group in the header:
"Active only" (default) / "+ completed 1w" / 2w / 4w. Switches in
APPROVED / DELIVERED / SKIPPED assignments whose completedDate
falls inside the chosen window. No "all time" option — that list
grows without bound.
New first-class attachments concept on the deliverable detail page,
replacing the "stash a Figma URL in the notes" workaround. Two kinds
share one row:
- FILE — uploaded via the existing /data/uploads volume, served
through /api/uploads/deliverables/:id/:filename. 100MB cap.
Images render a thumbnail inline; PDFs/docs get a file icon
+ download link. Blob cleaned up on delete.
- LINK — external URL (Figma / Drive / Dropbox / ad-hoc). Figma URLs
detected against the file/proto/design/board path pattern
and get an in-line iframe preview via Figma's embed host.
Other URLs open in a new tab with an external-link icon.
Schema:
- DeliverableAttachment model + migration. Cascade-deletes with the
parent deliverable. Optional stageDefinitionId tag scopes an asset
to a specific stage (null = whole deliverable).
- Extended serving route's MIME allowlist: PDF, Office docs, CSV,
text, ZIP, AI/PSD. Was tight to video/image-only before.
Plumbing:
- attachment-service with listAttachments / createLink /
createFile / delete. Visibility-scoped (assertProjectVisible via
deliverable.projectId).
- POST /api/deliverables/:id/attachments — content-type discriminates
multipart (file) vs JSON (link). DELETE at the nested route.
- useAttachments / useCreateLinkAttachment / useUploadFileAttachment /
useDeleteAttachment hooks; invalidate ["attachments", deliverableId].
UI panel renders above Pipeline Stages so assets are always in sight.
Read-only for CLIENT_VIEWER; all other roles can upload/paste/delete.
1. /projects Import XLSX now behind the same isAdmin gate as New
Project. Producers won't see either — bulk + manual intake are
both admin-only escape hatches.
2. Per-stage notes. Backend was already wired (DeliverableStage.notes
+ updateStageSchema.notes + PATCH handler); just no UI.
- New StageNotes component — inline editor per stage row. Empty
state shows a "+ Add note" link; existing notes render as
muted italic text, click anywhere on them to edit. Save on
blur or ⌘/Ctrl+Enter, Esc to cancel.
- useUpdateStageNotes hook for the PATCH.
- Wired into each stage row on the deliverable detail page.
3. Deliverable info panel expanded + promoted above Pipeline Stages.
- getDeliverable service now includes parent project context
(id, name, projectCode, omgJobNumber, clientTeam) so the UI
doesn't need a second round-trip.
- Metadata grid always renders; empty fields show em-dash so
producers can spot what's missing. Project row at the top
surfaces project name, code, OMG #, client team. Deliverable
notes are now shown as a block alongside the rest.
Producers don't need to create these by hand — they'll arrive via the
three upstream webhooks. Manual creation is now an ADMIN-only escape
hatch (corrections, test data, etc.).
Plumbing:
- New GET /api/me endpoint exposes session fields (role, org, teams)
to client components. Minimal — just what UI gates need.
- New useCurrentUser() + useIsAdmin() hooks in src/hooks/.
Gates:
- /projects: "New Project" hidden for non-ADMIN (Import XLSX left open)
- /briefs: "New Brief" hidden for non-ADMIN
- /projects/🆔 "Add Deliverable" hidden for non-ADMIN
Server-side permission model (PROJECT_CREATE on PRODUCER, etc.)
unchanged — if we want to tighten server-side too we can sweep
DEFAULT_PERMISSIONS in seed-dow.ts separately.
Three fixes for an empty Production Timeline:
1. Default statusFilter flipped from ACTIVE → "all". Fresh imports
land at PIPELINE status (brief-accepted), so ACTIVE-default hid
every project on first load.
2. Status dropdown was missing PIPELINE and CANCELED — so even
after you realised ACTIVE was wrong you couldn't pick PIPELINE.
Added both.
3. /api/timeline was scoping by organizationId only, not ClientTeam
visibility. Non-ADMIN users would have seen cross-team projects.
AND-in visibleProjectsWhere(ctx) now — same pattern as the rest
of the service layer.
Previous 1.5× felt oversized on laptop screens. This commit:
1. Reverts the 1.5× sweep back to original sizes
2. Reapplies a gentler 1.2× multiplier — both in the @theme text
scale (text-xs → 14.4px, text-base → 19.2px, etc.) and via the
arbitrary-size sweep across 420 tokens in 81 files
3. .label-upper utility bumped 10px → 12px
Net effect: everything is ~20% larger than the pre-bump baseline,
~20% smaller than the previous 1.5× state.
Approach avoids compound-multiplication rounding drift: a true
"undo then reapply" rather than scaling the already-scaled values.
Producers were reading the UI at a squint. Two pieces:
1. @theme in globals.css now overrides every --text-* token (xs
through 7xl) to 1.5× the Tailwind v4 default, with matching
line-heights. This covers everything using the named classes
(text-xs / text-sm / text-base / …) without touching markup.
Also bumped .label-upper from 10px → 15px.
2. Scripted sweep of every arbitrary text-[Npx], fontSize={N}, and
inline `fontSize: N` (recharts ticks) — 421 occurrences across
82 files. Each N was rounded to round(N × 1.5). Idempotency is
NOT preserved; running the sweep twice would scale twice.
Script lives at /tmp/scale-text-sizes.mjs for reference — not
committed. If we need to roll back, `git revert` this commit is
safe (pure display change, no schema/service impact).
The rework path was "working" on the server (target went IN_PROGRESS,
success toast fired) but the card stayed in the source column. Root
cause: applyRework() only touched the target row, leaving the source
stage's status untouched. The board's currentStage() picker takes the
highest-order ACTIVE stage, so with both the target (e.g., In Progress
Creative, order 5) and the source (e.g., Client Feedback, order 7)
both IN_PROGRESS, the source won and the card never moved.
Fix: on rework, reset every stage strictly between target.order and
source.order (inclusive of source) to NOT_STARTED if they were
APPROVED/IN_PROGRESS/IN_REVIEW/CHANGES_REQUESTED/DELIVERED. This
matches the real-world semantic too — any approvals downstream of the
rework point are approving content that has now gone back upstream
for fixes, so they need to be re-earned.
SKIPPED stages are preserved (a conscious skip shouldn't be silently
un-skipped by a rework). Both stage writes happen in one transaction.
Single-document spec for teams pushing Briefs, Projects, and
Deliverables. Each of the three gets its own section with:
- Webhook URL + signature header + HMAC signing example
(bash/python/node)
- REST equivalent (auth'd user path) for human-operator cases
- Annotated JSON body with every field, which are required, and
what they map to on our side
- Idempotency behaviour (externalId, omgJobNumber, project+name
natural keys)
- Error responses with the exact JSON shape they'll see
The doc is pitched at a dev at an upstream system, not a producer,
so it's spec-first: curl-ready examples, enum values spelled out,
header names called out in a table.
Admins can draw backward transitions on the same ReactFlow canvas
that already handles forward dependencies. With this, the pipeline
editor is a full workflow builder — new pipelines for new clients
can be assembled in the browser without code changes.
How it works:
- "Workflow Graph" panel now has a mode toggle: Dependencies /
Rework paths. Both edge types always render — the toggle just
scopes what dragging between stages creates.
- Forward (solid) and rework (dashed red) edges coexist on the
canvas; arrows point source→target in both cases.
- Drag validation: rework drops where target.order >= source.order
are rejected client-side (silent — a round-trip error would just
confirm the same thing).
- Click the × on any edge to delete it.
Plumbing:
- pipeline-template-service: addReworkPath / removeReworkPath with
same-pipeline + not-self + strictly-backward checks.
getPipelineTemplate includes reworkFrom + reworkTo so the canvas
renders reworks alongside dependencies without a second fetch.
- POST/DELETE /api/pipelines/:id/reworks
- useAddRework / useRemoveRework hooks
- New ReworkEdge component — dashed, red, curvier arc to keep
pass/fail visually distinct from the sequential forward flow
Producers can now drag cards on both boards. Three distinct write
paths, each with validation appropriate to what it's moving:
Projects board (status lens only):
- Drop updates Project.status via PATCH /api/projects/:id.
- Stage lens stays read-only — Project.stage is derived from the
dominant deliverable stage, there's no single write target.
Deliverables board (status lens):
- Drop updates Deliverable.status directly.
Deliverables board (stage lens) — the interesting one:
- Drop hits new POST /api/deliverables/:id/transition endpoint,
validated by new stage-transition-service.
- Forward transitions: current → APPROVED, optional intermediates
→ SKIPPED, target → IN_PROGRESS. If a REQUIRED intermediate
isn't already done, the drop is rejected ("walk through it
first") — toast shows the blocking stage name.
- Rework (backward) transitions: only allowed if the pipeline
template declares an explicit rework path from current → target.
Otherwise rejected. No cross-pipeline drops possible because the
target slug is looked up inside the deliverable's own pipeline.
Schema:
- New PipelineStageRework self-join on PipelineStageDefinition —
per-template declaration of "from stage X you can push back to
stage Y". Migration 20260423000000_pipeline_stage_reworks.
- Seed populates Dow's canonical rework paths: Client Review
(Copy) → Copywriter, Internal Review → In Progress Creative,
Client Feedback → In Progress Creative, Final Approval → In
Progress Creative, Completed → In Progress Creative.
Pipeline template editor UI (where producers would add their own
rework paths) is deferred; the seed covers Dow out of the box.
Hooks: added useUpdateProjectById, useUpdateDeliverableById,
useTransitionDeliverable — all take ids at mutation time so the
boards don't need one hook per card.
Two related bugs + one feature the user flagged:
1. **dominantStage picked Canceled for every fresh project**
The old logic included BLOCKED in "in-flight" and picked the
highest-order match. Dow's pipeline puts "On Hold" (order 10)
and "Canceled" (order 11) as parking stages with NO prereqs —
so on fresh deliverables they start as NOT_STARTED, which made
"Canceled" the highest-order in-flight stage for every deliverable.
Result: board view only rendered a Canceled column.
Fix — same two-step pick in both project-service.ts and
deliverables/page.tsx currentStage():
1) highest-order ACTIVE stage (IN_PROGRESS/IN_REVIEW/CHANGES_REQUESTED)
2) else lowest-order NOT_STARTED (next-up)
BLOCKED is skipped entirely — it means "prereqs not done", not
"where work is". The lowest-order NOT_STARTED rule naturally
keeps parking stages out of the dominant pick unless they're
actually being worked.
2. **Board hid empty stage columns**
In stage-grouped mode the board only rendered columns for
stages seen in the data, so when the dominantStage bug bucketed
everything into Canceled, all other columns disappeared.
ProjectBoard + the new DeliverableBoard now accept a
pipelineStages prop (from the default pipeline template) and
render every stage as a column in canonical order, empty or not.
3. **Deliverables page: Board view**
New component src/components/deliverables/deliverable-board.tsx.
Grid/Board toggle in the header, group-by selector (Stage/Status)
next to the filters. Cards show OMG #, priority dot, team, name,
project, primary assignee, deadline. Clicking a card navigates
to the deliverable detail page.
Briefs are pre-project requests. Three intake paths land in one place:
1. Manual — "New Brief" dialog on /briefs
2. REST — POST /api/briefs (auth'd)
3. Webhook — POST /api/webhooks/briefs (HMAC-signed)
Once triaged, "Promote to Project" flips Brief.status → CONVERTED,
creates the Project, and links them via convertedProjectId so the
audit trail stays intact.
Schema:
- New BriefStatus enum + Brief model, indexed on org/status/team
- Unique on (organizationId, externalId) so webhook replays are
idempotent — same upstream id = update, not insert
- Migration 20260422000000_briefs, hand-written SQL
Webhooks — now three total, each with its own secret and header:
- /api/webhooks/omg (projects — existing, unchanged)
- /api/webhooks/deliverables (NEW — keyed on project OMG # + name)
- /api/webhooks/briefs (NEW — keyed on externalId)
Extracted a shared HMAC verifier at src/lib/webhooks/hmac.ts so the
two new routes don't copy-paste the crypto code from the OMG route.
Deliverables webhook looks up the parent project by OMG job number
(the canonical key from the projects webhook); returns 404 with a
hint if the project hasn't been created yet. Brief webhook source
records "webhook:<system>" so we can tell where briefs come from.
UI:
- /briefs page: filterable/searchable table, inline status dropdown
per row, New Brief dialog, Promote to Project dialog
- Sidebar nav entry for Briefs above Projects
Env: added DELIVERABLE_WEBHOOK_SECRET / ALLOW_INSECURE and
BRIEF_WEBHOOK_SECRET / ALLOW_INSECURE alongside the existing OMG
pair in .env.example.