Commit graph

231 commits

Author SHA1 Message Date
DJP
1de8985507 Outcome-branch routing: only one path fires per approval decision
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>
2026-05-15 21:16:22 -04:00
DJP
200228f79d Workflow test: cover arbitrary shapes (linear, fan-out, fan-in,
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>
2026-05-15 20:30:24 -04:00
DJP
e80ce5b32f Add workflow logic smoke test (19/19 passing)
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>
2026-05-15 20:28:14 -04:00
DJP
46cd8f8401 stage-machine: allow IN_PROGRESS → APPROVED shortcut for non-review stages
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>
2026-05-15 20:26:25 -04:00
DJP
5f409c6c46 Resources page: scope toggle for project vs deliverable assignment
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>
2026-05-15 20:11:34 -04:00
DJP
aa897354e7 Request Changes triggers rework + resets intermediate stages
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>
2026-05-15 19:36:50 -04:00
DJP
819288d36c Auto-advance fix + NONE stages = Open/Close only
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>
2026-05-15 19:29:41 -04:00
DJP
f0eb29dd0c Workflow declutter: auto-advance + hide redundant action buttons
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>
2026-05-15 15:59:17 -04:00
DJP
02f18e6eab Version lock on older revisions + settings/box page crash fix
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>
2026-05-13 12:33:37 -04:00
DJP
80a16e68e8 Deliverable: persist omgJobNumber through create / update / edit pre-fill
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>
2026-05-12 22:54:03 -04:00
DJP
0b0b3bfadc docker-compose: mount Box secret at /run/secrets/box-config.json
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>
2026-05-12 22:41:30 -04:00
DJP
8d217aa398 Box webhooks: manage from the /settings/box admin page
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>
2026-05-12 22:31:48 -04:00
DJP
96be120525 OMG number moves from Project to Deliverable for inbound matching
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>
2026-05-12 22:01:02 -04:00
DJP
bb9c7aa02d Box inbound: defensive loop guard on parent folder
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>
2026-05-12 21:45:19 -04:00
DJP
545b0eef05 Stage review panel: "Add first version" + always-visible Start review
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>
2026-05-12 21:32:27 -04:00
DJP
630570caeb Review page: full HP markup surface restored on the single-asset model
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>
2026-05-12 21:06:34 -04:00
DJP
128b5d1d93 Review page: wire AnnotationLayer overlay back onto image revisions
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>
2026-05-12 20:49:34 -04:00
DJP
98ebe4796d Review page: add upload zone when latest revision has no asset
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>
2026-05-12 20:35:34 -04:00
DJP
45dfdcad23 createDeliverable: auto-mirror dynamic stages into legacy seed table
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>
2026-05-12 20:22:19 -04:00
DJP
4aa711c7ae createDeliverable: fall back to org default pipeline when project has none
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>
2026-05-12 20:12:03 -04:00
DJP
bdb133d49a Phase 5: unified versioning + single-asset-per-revision + holding pen
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>
2026-05-12 19:37:06 -04:00
DJP
e8eeb79d9c Sidebar: text wordmark in place of the old navbar PNG
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>
2026-05-12 18:36:02 -04:00
DJP
4362f588dd Add scripts/create-admin.ts — one-shot reset + admin user create
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>
2026-05-12 18:25:57 -04:00
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
DJP
ae6ebc6da2 Deliverables board: drag highlight, pipeline filter, "in pipelines" footer
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.
2026-04-22 12:53:40 -04:00
DJP
bcd1b245bd Calendar → Due Date Calendar (simplified, stage spans gone)
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.
2026-04-21 20:25:43 -04:00
DJP
28b30c60b4 Pipeline clone copies reworks + editor gets a Duplicate button
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.
2026-04-21 20:15:52 -04:00
DJP
d5d8c7560a Self-assignments fire notifications too
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.
2026-04-21 20:11:26 -04:00
DJP
47a65d6498 Keep Deliverable.status in sync with stage state
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.
2026-04-21 19:55:39 -04:00
DJP
926225a05b Breadcrumbs show names + OMG number instead of raw cuids
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.
2026-04-21 19:44:45 -04:00
DJP
5847580bee Project Owner is now a User, not freeform text
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.
2026-04-21 19:43:14 -04:00
DJP
1445fe2c1d Strip HP fields from project dialog + view + make Deliverable editable
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.
2026-04-21 17:28:02 -04:00
DJP
13e069d72c Hardening: Prisma pool bump + webhook rate limiting
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.
2026-04-21 17:01:19 -04:00
DJP
307619ffe6 Nightly pg_dump backups + admin "Export Full XLSX" button
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.
2026-04-21 16:53:49 -04:00
DJP
9b0156afc8 Project detail: always show all fields + admin-editable + owner
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.
2026-04-21 16:46:41 -04:00
DJP
02593ece83 Stage-tagged attachments — per-stage paperclip indicator + general panel
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.
2026-04-21 16:06:48 -04:00
DJP
553f1f8fa1 Assign + book hours in one shot from the stage card
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.
2026-04-21 15:31:20 -04:00
DJP
d4bee0e8d3 Assignment notifications + workload approved-filter + My Work fixes
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.
2026-04-21 15:29:10 -04:00
DJP
827ed587bb Attachments panel — files + external links per deliverable
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.
2026-04-21 15:25:48 -04:00
DJP
c12e647546 Gate XLSX + per-stage notes + expanded deliverable info panel
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.
2026-04-21 14:49:07 -04:00
DJP
4fe65f2a61 Gate New Project / New Brief / Add Deliverable behind ADMIN role
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.
2026-04-21 14:37:45 -04:00
DJP
0ba7c2b464 Timeline: default filter to "all" + add missing statuses + team scope
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.
2026-04-21 14:11:16 -04:00
DJP
4fde413aa9 Tune typography bump 1.5× → 1.2×
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.
2026-04-21 13:57:32 -04:00
DJP
ffd91cea04 Bump typography ~1.5× across the app
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).
2026-04-21 13:42:54 -04:00
DJP
f2cdd8bd4c Fix rework drag: reset intermediate stages so the card actually moves
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.
2026-04-21 13:38:22 -04:00
DJP
03cd99b56c Add INTEGRATION.md — handover spec for upstream teams
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.
2026-04-21 13:29:44 -04:00
DJP
f89fb73aff Pipeline editor: rework paths (pass/fail loops) now configurable in the browser
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
2026-04-21 13:19:43 -04:00
DJP
9ff0f03a4d Board drag-and-drop with forward/rework pipeline rules
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.
2026-04-21 12:45:17 -04:00
DJP
e9f8fffdcc Fix board stage columns + add board view to Deliverables
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.
2026-04-21 12:28:11 -04:00
DJP
958de5f3a9 Add Briefs intake + all three upstream webhooks
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.
2026-04-21 12:05:47 -04:00