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>
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.
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.
New ProjectBoard component — Kanban columns of project cards with a
view toggle in the header. Two grouping modes:
- Group by: Status — fixed columns (Pipeline / Active / On Hold /
Completed / Canceled / Archived). Empty columns still render so
producers can see "nothing here" at a glance.
- Group by: Pipeline Stage — columns derived from the dominant
in-flight stage already computed server-side
(pipelineProgress.dominantStage). Ordered by stage.order so the
flow reads Pipeline → Completed.
Cards show OMG #, priority dot, team badge, name, deliverable
count, deadline (red if overdue), a progress bar against completed
deliverables, and the dominant stage label. Click navigates to the
project detail page.
Read-only for now. Drag-to-move on the status lens would be
straightforward; the stage lens can't be dragged since a project's
"stage" is derived from its deliverables, not a project field.
Deliverable stages:
- Killed the broken "Review" link that pointed to a route removed in
the initial HP→Dow port (was producing 404s on click)
- Rebuilt the 2-col grid of big Card components as a tight vertical
stack of rows. Status badge pinned right, dates + assignees +
action buttons inline on a single secondary row, left accent
stripe preserved for quick status scanning. Roughly 3× the stages
fit on screen now.
Workload × Resources:
- Resources page's ResourceBooking hours were invisible on the
Workload page — the service only counted StageAssignments.
- getWorkloadData now runs a single bookings query for the full
horizon, aggregates by (user, week-start) via a Map, and exposes
bookedHours + weeklyCapacityHours per week.
- A week is overloaded if EITHER assignment count exceeds daily cap
OR booked hours blow past weekly cap. Capacity cell shows both
numbers (count, +Nh booked) and uses max(assignmentUtil,
bookingUtil) for the colour tint so booking-only weeks still
render as loaded.
Deliverables view (new /deliverables):
- listAllDeliverables service uses visibleDeliverablesWhere so the
flat list respects per-team scoping automatically
- GET /api/deliverables returns the flat list joined with project +
stages + assignments
- useAllDeliverables hook + typed AllDeliverableRow
- New page: searchable/sortable table, filter by project, stage,
team, status, priority. Current-stage column uses the same
"highest-order in-flight stage" logic as the Projects grid so
both views agree on what counts as "where this deliverable is"
- Sidebar: new "Deliverables" nav entry between Projects and My Work
Resources page filters:
- Extended listUsers to include homePod + clientTeams so the page
can filter/group without an extra round-trip
- Added Role + Team filters, Group-by selector (Team / Role / Pod /
Department), Sort-by selector. Default is Group by Team + Sort by
Name — matches how the roster is organised on the team-list xlsx
- primaryTeam() picks the isPrimary membership so Shared users
(members of multiple teams) land in one bucket when grouped
Three touchpoints:
- layout.tsx: import Public_Sans from next/font/google instead of
Inter, same --font-sans CSS variable so the rest of the app picks
it up without changes
- globals.css: --font-sans stack heads with "Public Sans"
- annotation-renderer.tsx: SVG text nodes had a hardcoded
"Montserrat, Inter, sans-serif" family — updated to match
Previously the primary brand color was Oliver's forest green
(#08402c light / #0fa968 dark) — left over from the HP fork. Now:
- --primary, --ring, --chart-1 → #002B5C (Dow navy) in light mode;
#5D9AD6 (lighter Dow blue, for contrast on dark bg) in dark mode
- --status-approved moves off the Oliver brand hex onto standard
success green (#16A34A) so approval badges still read as "success"
without being brand-coded
- Print overrides updated to match
Kept: semantic/universal greens across the app (approval badges,
emerald "healthy capacity" heatmap, CheckCircle success icons, role
colors) — those aren't brand-identity uses.
- Skeleton.tsx was inheriting bg-accent from the Oliver/HP theme
(#ee5540 coral), so every loading state read as a red error.
Switched to bg-[#002B5C]/15 — soft Dow navy tint, matches the
logo background.
- Logo PNG is 400x48 (8.33:1) rendered at h-7 w-auto, giving an
intrinsic 233px width. Inside a flex parent narrower than that,
Tailwind preflight's max-width:100% kicked in while h-7 pinned
the height, so the image was being stretched horizontally.
Adding object-contain + max-w-full lets the image scale
proportionally instead of distorting.
Three fixes that should have been in earlier commits but weren't:
1) project-form-dialog.tsx — REWRITTEN for real this time. Earlier Write
call in commit 18ae429 claimed success but didn't persist, so every
HP placeholder was still live in prod. Verified: zero HP/Envy/NPI/
Form Factor/Business Unit strings remain; 10 new Dow-specific
bindings (clientTeam, omgJobNumber, DOW_CATEGORIES, useClientTeams).
Three tabs (Details / Dates / References) with ClientTeam selector,
OMG Job Number field, Risk/Priority labels, Dow category dropdown,
full Dow ProjectStatus enum including PIPELINE and CANCELED. Labels
point at the exact Dow XLSX column names so producers recognise
the mapping.
2) Projects page: new Pipeline Stage column.
listProjects() now aggregates pipeline progress per project: for
each deliverable it finds its highest-order in-flight stage (the
where-it-currently-is); the dominant stage across all deliverables
is the one shown on the row. Deliverables whose every stage is
APPROVED/DELIVERED/SKIPPED count as completed. New
PipelineProgressCell renders 'Stage Name - N/Total' plus a mini
progress bar. Green 'Done' pill when every deliverable is past
the pipeline. Column is sortable by stage order.
3) Resources popover: replaced the dense pill-strip with a proper
vertical list of available jobs. Each row shows the color dot +
the OMG number (mono, colored) + the project name. Scrollable,
sticky header with a count, full-width clickable. Double-click
a row to commit. Empty states explain where to upload the XLSX
or how to enter freeform. Popover widened 280->340px.
tsc --noEmit zero errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two smalls:
1) package.json db:seed still said seed-dow.cjs from the earlier
CJS→ESM rename — file bump missed it because I hadn't read the file
in that tool pass. With the Dockerfile now producing .mjs the seed
would have failed again for a different, dumber reason. Fixed.
2) Dow Jones wordmark is white on transparent, so it vanished into the
sidebar's light background. Added bg-[#002B5C] (Dow Jones brand
navy) to the logo header in both the desktop sidebar and the mobile
sheet. Now the logo actually reads.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two runtime-surface fixes surfacing from the optical-dev deploy.
1) Seed's `postgres-array/index.js` kept missing even after --no-cache
rebuilds. Root cause: the Next.js standalone output traces package
manifests aggressively but sometimes leaves the `main` file body
out when the package isn't reachable from the app's import graph
(postgres-array is a transitive of pg, only reached via the seed
script, not the app runtime). Our `npm install --no-save
@prisma/adapter-pg` at build time then saw the partial install and
short-circuited. Runtime tsx resolution then blew up on `require
postgres-array`.
Fixed two ways, layered:
a) In the builder stage, after `npm run build`, esbuild the seed
into a self-contained CJS bundle at prisma/seed-dow.cjs.
`--packages=external` keeps npm packages as runtime require()s
so native .node files (via @prisma/client) work, but everything
else is bundled so the seed no longer depends on runtime module
resolution in the fragile standalone tree.
b) In the runner stage, `npm install --no-save --force` (plus
explicit `pg@8` which pulls its postgres-* deps cleanly)
overwrites any partial packages the standalone shipped with.
Belt-and-braces with the bundled seed.
c) package.json `db:seed` now prefers `node prisma/seed-dow.cjs`
and falls back to `tsx prisma/seed-dow.ts` if the .cjs isn't
there (e.g. when running the seed from a dev box where no build
happened). Both paths produce identical output.
No more runtime tsx install — dropped `npm install -g tsx@4` from
the runner image. That was always a workaround; the bundle is the
actual fix.
2) Sidebar logo not rendering — Next.js's `<Image>` with basePath +
the standalone image optimizer is finicky; the image file IS at
/app/public/navbar-logo.png in the container but Next's
`/_next/image?url=...` pipeline was returning 404 for it. Swapped
to a plain `<img>` (with the eslint-disable comment so the rule
doesn't whine). The file is ~4KB, image optimization added
nothing. Desktop + mobile sidebars both use the plain tag now.
Verified: tsc --noEmit ✓ zero errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
You flagged three concrete gaps after the deploy went live — all
addressed in this commit, plus the API + how-to docs you asked for.
A) Create Project dialog was still HP-centric
Placeholders like "HP Envy x360 Renders" / "HP-2026-001" / "NPI /
Refresh" / "Form Factor" etc. bore no relation to Dow's actual XLSX
columns and the form had no ClientTeam selector — so any
admin-created project was orphaned from the visibility layer.
- src/lib/validators/project.ts: added clientTeamId + omgJobNumber;
status enum now includes PIPELINE and CANCELED
- src/components/projects/project-form-dialog.tsx: rewritten around
the Dow XLSX schema. Three tabs (Details / Dates / References)
instead of four. Placeholders reference real Dow values
(Celena / Yzabella etc. for Owner, 2337959 for OMG #, Brand / Events
etc. for Team, Copywriting/Display/... for Category). ClientTeam
selector populated from /api/client-teams with a "no teams — add
one in Settings" fallback. Category is a typed enum dropdown with
the 8 XLSX values. Risk/Priority wording mirrors the XLSX labels
(Priority = URGENT). Dropped HP-only fields from the UI
(formFactor, codeName, npiOrRefresh, businessUnit placeholder,
agency, Financial tab, Workfront ID placeholder). Legacy fields
are still in the Zod schema for back-compat but not rendered.
B) Users invisible because only the admin was seeded
The plan flagged "real Dow/Oliver roster — open question" and we
never got the list, so the seed only created admin@dowjones.com.
prisma/seed-dow.ts now also creates the 9 placeholder resources
from the Resources.html prototype (Alice Chen, Ben Marsh, Cara Wu,
Dan Koch, Eva Stone, Frank Osei, Grace Lee, Hiro Tanaka, Isla Reeve),
distributed round-robin across the three placeholder pods. Each has
role + department + maxCapacity set but no passwordHash, so they
show up in the UI immediately but can't log in until an admin
invites them via Settings → Team (which issues a reset link).
Swap for the real roster whenever Zia delivers it — the emails are
@example.com so they're safe to delete.
C) Resource Manager page (matching Resources.html)
New capacity planner UI — daily hours-per-job grid.
- Schema: new ResourceBooking model { userId, date, jobNumber,
hours, note, organizationId, createdById }. Migration at
prisma/migrations/20260421000000_resource_bookings.
- Validator (src/lib/validators/booking.ts): create + list schemas
with date-only coercion.
- Service (src/lib/services/booking-service.ts): week window
helpers, create/list/delete + known-job-numbers lookup for the
popover autocomplete.
- API: GET/POST /api/resources/bookings, DELETE
/api/resources/bookings/[id], GET /api/resources/job-numbers.
Writes gated to ADMIN + PRODUCER; reads open to any signed-in
member of the org (capacity view is a shared studio-level thing,
not per-team visibility).
- Hook (src/hooks/use-bookings.ts) with TanStack Query wiring +
week-scoped cache keys.
- Page (src/app/(app)/resources/page.tsx) ports the Resources.html
design to the app's Tailwind + shadcn primitives: Resource × Day
grid grouped by department, week navigator, click-to-assign
popover with job-number autocomplete + hour chips (1/2/3/4/6/8 +
custom), capacity bar per cell, week total column with over-cap
warning, collapsible role bands. Matches the prototype's
color-hashed job chips so the same job number gets a consistent
color across the grid.
- Sidebar nav: added "Resources" entry next to Workload.
D) Docs — full README + API reference + how-to
- API.md: complete REST + webhook reference. Three auth modes
documented (session cookie / X-API-Key / OMG HMAC). XLSX upload
header map with the Dow XLSX column correspondences. OMG webhook
has the speculative payload shape + a working bash example that
signs + sends a request. Common flows at the bottom: bootstrap
from zero, OMG publishes a status change, update a job from an
external script.
- HOWTO.md: end-to-end runbook. Mental model, local dev, prod
deploy pointers, first-login ritual, add-users flow (UI + API),
client teams + pods config, XLSX ingest (UI + curl + idempotency
notes), OMG webhook wiring (secret gen through verification),
producer daily workflow, client-viewer experience, resource
planning walk-through, RBAC matrix, common-problems table, and
"change the model" pointer map for future edits.
- README.md: top intro now points at API.md / HOWTO.md / DEPLOY.md.
Verified: npx tsc --noEmit ✓ zero errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Caught on the first real deploy to optical-dev. Two separate bugs.
Dockerfile — runner stage was missing tsx + @prisma/adapter-pg + bcryptjs
The Next.js standalone bundle covers the app, but prisma/seed-dow.ts
is a separate .ts file executed via tsx (not bundled). Runner only
explicitly installed prisma + dotenv, so `npm run db:seed` failed with
"sh: tsx: not found" and deploys couldn't run the one-time seed.
→ Added tsx, @prisma/adapter-pg (seed uses PrismaPg directly), and
bcryptjs (seed hashes the admin's temp password) to the
`npm install --no-save` line in the runner stage. Adds ~15 MB to
the final image — worth it for a working seed path.
/api/health was 503 pre-seed, which made deploy.sh unwillingly block itself
The probe in deploy.sh uses `curl -sf` and treats any non-2xx as
"not ready". The health endpoint flipped the entire `healthy` flag to
false when `organizations` or `pipeline_templates` counted zero —
meaning a freshly-migrated-but-not-yet-seeded app was classified as
unhealthy, deploy.sh gave up at Step 6, and we never got to Step 7
(Apache config) or Step 8 (UFW). End result: the URL 404'd because
Apache wasn't proxying anything to the container.
→ Split liveness from readiness:
- GET /api/health (default) — DB reachable, pgvector installed,
AUTH_SECRET set, DEV_BYPASS off. Empty tables are reported as
"warn" but do NOT 503. This is what deploy.sh waits on.
- GET /api/health?strict=1 — same checks PLUS org + templates
present. Use post-seed to verify everything landed.
- Added a "mode" field ("liveness" | "strict") so which mode was
used is visible in the response.
- Pre-seed content-level checks now return status: "warn" with a
hint to run `npm run db:seed`, instead of hard-failing.
Net effect for a fresh deploy:
./deploy.sh → builds, runs migrations, reports healthy once DB +
env are good, configures Apache, DONE. Then you can
`docker compose -p dow-prod-tracker exec app npm run db:seed`
at your leisure.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The static prerender step bails on a CSR-only component that reads
useSearchParams() outside a Suspense boundary. Caught by the first real
production build (Turbopack/standalone output).
Split ChangePasswordPage into an outer Suspense shell (default export)
and an inner ChangePasswordForm that owns the useSearchParams() call.
Fallback is null — the shell renders for ~1ms before the client hydrates
the form, invisible.
No behavior change. No other auth pages use useSearchParams.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The card-grid view that came over from HP was never going to feel like a
Planner/Excel replacement. This rewrites /projects as a dense sortable
table that mirrors the Dow Studio Tracker XLSX column-for-column.
Columns (every one sortable, click header to toggle asc/desc):
Owner · Risk · OMG # · Team · Status · Category · Project Name
· Brief Accepted · Deadline · # Deliverables
Data maps straight off the upsertProjectFromDow() fields — what the XLSX
importer and the OMG webhook both write — so what you see in the grid
matches what the Dow team sees in the spreadsheet.
Filters (top bar):
- Search box — matches name, project code, OMG #, owner, category, notes
- Team dropdown — sourced from /api/client-teams (Brand / Events / B2B /
Content / Briefing Team / Performance + any custom-added teams)
- Status dropdown — PIPELINE / ACTIVE / ON_HOLD / COMPLETED / CANCELED /
ARCHIVED (the Dow enum)
- Live row count ("26 of 42 projects")
Affordances:
- Overdue deadlines render bold red with a warning icon (dueDate < now
and status isn't terminal)
- Row hover reveals open-project / delete icons to keep the grid dense
when idle
- Export CSV button — one-click dump of the filtered rows in XLSX
column order, so the team still has the spreadsheet escape hatch
while they migrate off Excel
- Client-side sort + filter because the dataset is bounded (one tenant,
one row per project); server pagination is premature
Hides noise: Client Contact (PII, per the ingest policy) and Status
Details (long freeform text, better on detail page).
Verified: tsc --noEmit ✓; GET /projects renders 200 locally against the
real imported Dow tracker data (18 projects, 5 teams).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Actually ran this for the first time. Three real bugs + two polish items.
1. NextAuth catch-all was eating local-auth routes
src/app/api/auth/[...nextauth]/route.ts is a catch-all that claims
everything under /api/auth/*. When AUTH_SECRET is set (i.e. outside
of DEV_BYPASS_AUTH), NextAuth's handlers absorbed my static
/api/auth/login, /api/auth/change-password etc. routes and returned
404 for them.
→ Moved to /api/local-auth/*. Updated all four client pages to
match. Added /api/local-auth to the middleware's authn-bypass
allow-list alongside /api/auth.
2. XLSX header matcher too greedy on "team"
The HEADER_MATCHERS entry for clientTeamRaw was ["team"], and
findIndex used substring match. That matched "Creative Team Member
Deliverable is Assigned to" (assignee column) BEFORE the literal
"Team" column. Result: client-team values on imported projects were
the assignee names ("gabrielle", "matt", "sergio").
→ Two-pass buildColumnMap: exact equality first (claims the
literal "Team" cell for clientTeamRaw), substring fallback second
(handles the verbose "Creative Team Member…" header for assignee).
Already-claimed columns are excluded from subsequent passes.
3. exceljs hyperlink cells not unwrapped
Project Name cells in the Dow tracker are a mix of plain strings
(for rows Dow edited manually) and exceljs hyperlink objects
(rows auto-linked to the OMG brief — shape `{ text, hyperlink }`).
The old extractColumns only unwrapped richText and formula.result;
hyperlink objects fell through and Zod rejected them with
"projectName: Invalid input". 24 of 27 rows from the real XLSX
failed with this before; now 26/27 pass (the 1 remaining error is
a genuinely missing omgNumber, correctly flagged).
→ Extracted unwrapCell() that handles hyperlink, richText, formula,
error, and Date cells.
4. DEV_BYPASS_AUTH defaulted to "true" in .env.example
Anyone copying .env.example verbatim got a mock session pointing at
the HP-era "dev-user-001" which doesn't exist in the Dow DB,
causing mysterious P2025 errors on user.update. Also leaves the app
wide open — nobody's auth is actually checked.
→ Default to "false" in .env.example with a DANGEROUS warning.
5. layout.tsx metadata description still said "HP CG department"
→ Fixed to "the Dow Jones studio".
Verified end-to-end on a fresh local DB:
- Login as seeded admin ✓
- Forced password change on first login ✓
- XLSX import: 27 rows → 26 created, 1 error (missing omg number) ✓
- 267 deliverables across 5 client teams ✓
- Invited a CLIENT_VIEWER, assigned to Brand team only ✓
- Brand tester sees 1 project; admin sees 18 ✓
- Brand tester gets 403 on POST /api/projects ✓
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Logo:
- src/components/layout/sidebar.tsx — replaced text wordmark with the Dow
navbar-logo.png in both desktop (collapsed-aware) and mobile sidebar
variants. Logo links to /dashboard. Sized at h-7 (28px) — fits the
400×48 source aspect ratio comfortably.
Deploy docs:
- DEPLOY.md — focused deployment guide for optical-dev.oliver.solutions.
Highlights the CLAUDE.md shared-server safety rules (compose `name:`
field + `-p` flag), env var checklist, first-time setup, update flow,
the seven-step verification list, rollback, and common-issue triage.
This is the doc you hand a new ops person along with the deploy.sh.
- README.md — top intro rewritten for the actual Dow product (Excel/
Planner replacement, 11-stage pipeline, OMG + XLSX ingest, per-team
visibility) instead of the inherited HP CG copy. Points at DEPLOY.md.
Verified: tsc --noEmit ✓ zero errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Until now the only way to set up Dow's six client teams + production pods
was via the seed script. Now admins manage them from the Settings UI.
Validators (Zod):
- src/lib/validators/client-team.ts: create/update/membership schemas;
slug regex enforced (lowercase + dashes only — keeps it stable for the
XLSX/webhook ingest path which resolves teams by slug).
- src/lib/validators/pod.ts: create/update + setHomePod schemas.
Services:
- src/lib/services/client-team-service.ts: list/create/update/delete +
addMember/removeMember. delete blocks if the team still has projects
(forces an explicit move first). Auto-derives slug from name when not
provided.
- src/lib/services/pod-service.ts: list/create/update/delete + setUserHomePod.
delete is non-cascading on members — sets User.homePodId=null instead
of deleting people. Lead-user assignment is org-scope-validated.
API routes (gated by new permissions CLIENT_TEAM_MANAGE / POD_MANAGE
seeded for ADMIN in Phase 3):
- GET/POST /api/client-teams
- PATCH/DELETE /api/client-teams/[teamId]
- POST/DELETE /api/client-teams/[teamId]/members
- GET/POST /api/pods
- PATCH/DELETE /api/pods/[podId]
- POST/DELETE /api/pods/[podId]/members
GET endpoints are open to any signed-in user — they need the lists for
filter dropdowns and to know their own team. Project-row visibility is
still enforced via Phase 2's visibility helpers, untouched.
Hooks:
- src/hooks/use-client-teams.ts and src/hooks/use-pods.ts — TanStack
Query wrappers with cache invalidation on mutations.
Settings pages:
- src/app/(app)/settings/client-teams/page.tsx — create teams, manage
memberships, see project counts. Hides external (CLIENT_VIEWER) users
with a "client" badge so admins know who's who.
- src/app/(app)/settings/pods/page.tsx — create pods, set lead, add/remove
members. Filters out external users from the pod-eligible list.
- src/app/(app)/settings/page.tsx — added Client Teams + Pods cards to
the index, reordered to surface user-management first.
Verified: tsc --noEmit ✓ zero errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Deploy fixes (critical — Phase 0 string-rebrand didn't touch numeric ports):
- deploy.sh APP_PORT 3001 → 3002 (health check was hitting HP's app!)
- apache/dow-prod-tracker.conf — all proxy/websocket rules 3001 → 3002
(traffic to /dow-prod-tracker would have been served by HP's container)
- deploy.sh: added COMPOSE_PROJECT=dow-prod-tracker and `-p $COMPOSE_PROJECT`
on every `docker compose` invocation (down, up, exec, logs, ps). This is
the CLAUDE.md belt-and-braces rule — without it, a future move of the
deploy dir to `deploy/` would collapse the compose project name to
`deploy` and collide with any other app in a sibling `deploy/` dir on
the shared server. The `name:` field in compose covers us today, -p
covers us tomorrow.
- apache conf header comment rewritten to explain the port convention and
where to keep it in sync.
Admin add-user flow (answers the open question):
- createInvitation now creates/upserts the placeholder User row
(email + role + organizationId + isExternal + mustChangePassword=true)
in addition to the Invitation bookkeeping row. It stores a 24-byte
password-reset token on BOTH the User (passwordResetToken) and the
Invitation (token) — same token, so the existing /reset-password/[token]
page accepts the invite URL without a separate accept endpoint.
- Role enum now includes CLIENT_VIEWER. isExternal auto-derives from role
but can be overridden. When admin invites a CLIENT_VIEWER, the placeholder
user lands correctly pre-flagged for external handling.
- POST /api/org/invitations now returns {acceptUrl} — the full
/reset-password/<token> link admin can hand over out-of-band while SMTP
is unwired.
- revokeInvitation also clears the reset token on the placeholder user so
a leaked URL can't be used to claim the account after revocation.
- Deleted /api/invitations/accept (SSO-era — the accept IS the password
reset now) and removed acceptInvitationSchema from the validator.
Team settings UI (src/app/(app)/settings/team/page.tsx):
- Role dropdown now has "Client (read-only)" alongside Admin/Producer/Artist.
- After a successful invite, a banner shows the accept URL with a Copy
button so admin can paste it into Teams/email. Dismissible.
- Current-members list renders CLIENT_VIEWER with an amber badge.
Plumbing verified: tsc --noEmit ✓ zero errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MVP auth is email + password (Entra SSO stays coded but env-gated via
NEXT_PUBLIC_AUTH_ENTRA_ENABLED for when the redirect URI is ready).
Uses a custom DB-session endpoint to mirror the existing MSAL pattern
at /api/auth/sso — no NextAuth strategy refactor needed.
API routes:
- POST /api/auth/login — email+password, bcrypt.compare, creates
Auth.js-compatible DB session + Secure cookie. Constant-time
behaviour (dummy-hash compare on missing user) to not leak account
existence. Returns { ok, mustChangePassword } so the client can
route first-login users to /change-password.
- POST /api/auth/forgot-password — issues a 1-hour single-use reset
token. Never leaks enumerability (always 200). In dev, returns the
reset URL in the response so admins can hand it over before SMTP
is wired up. In prod, the token is only logged server-side.
- POST /api/auth/reset-password — validates token, bcrypt-hashes new
password, clears token, flips mustChangePassword=false, and
revokes all existing sessions so a stolen cookie can't linger.
- POST /api/auth/change-password — authenticated user changes their
own password. Skips the current-password check for users without a
passwordHash (covers first-time setup for SSO-seeded accounts).
Clears mustChangePassword.
UI pages:
- (auth)/login — rewrote for email+password form. Entra SSO button
only renders when NEXT_PUBLIC_AUTH_ENTRA_ENABLED=true. Dow brand
block on the left ("Dow Jones Studio Tracker").
- (auth)/login/CredentialsLogin — client form, routes first-login
users to /change-password?first=1.
- (auth)/change-password — forced password change after first login;
also usable as a plain change-password screen.
- (auth)/forgot-password — email form → reset link. Shows dev link
in-page when available.
- (auth)/reset-password/[token] — set new password from email link.
Middleware: /forgot-password and /reset-password added to the
authn-bypass allow-list alongside /login.
Minimum password length enforced at 10 chars. All API endpoints
return generic messaging to avoid information disclosure.
Verified: tsc --noEmit ✓ zero errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Shared canonical path: both ingest channels transform inputs into a single
DowRow shape (src/lib/validators/dow-import.ts) and write via the single
upsertProjectFromDow() function (src/lib/services/dow-excel-service.ts),
so the XLSX importer and the webhook cannot drift. Upserts on
Project.omgJobNumber (unique) — idempotent under replay.
XLSX ingest (Phase 4):
- New src/lib/validators/dow-import.ts — Zod schema with STATUS_MAP,
RISK_MAP, header normalizer, team-slug normalizer, preview + commit
result types.
- New src/lib/services/dow-excel-service.ts:
- parseDowTracker(buffer): locates "Job Tracker " (or "Job Tracker"),
scans first 5 rows to find the header row with 4+ matched columns,
skips the example/instructions row at header+1, substring-matches
headers (handles "Creative Team Member Deliverable is Assigned to"
→ assignee), collects row-level errors without aborting the batch.
- upsertProjectFromDow(row, organizationId): auto-creates
ClientTeam if missing (seed covers the 6 canonical teams, but stay
forgiving); on create, generates N deliverables from outputCount +
pipeline stages from the default Dow pipeline template with
BLOCKED/NOT_STARTED status derived from stage dependencies; on
update, only overwrites fields that are set so producer-edited data
isn't clobbered by blanks.
- previewDowImport() and commitDowImport() wrap the flow for the API.
- Rewrote src/app/api/projects/bulk-import/route.ts for the Dow schema.
POST ?commit=true|false, multipart file=<xlsx>. commit=false returns
{preview, totalRows, validRows, errors[], rows[]} (first 25 samples);
commit=true returns {imported, created, updated, deliverablesCreated,
errors[]}. Batch never aborts on a single bad row.
OMG webhook (Phase 5):
- New src/app/api/webhooks/omg/route.ts — POST-only. HMAC-SHA256
signature verification via X-OMG-Signature: sha256=<hex> against
OMG_WEBHOOK_SECRET, timing-safe compare. OMG_WEBHOOK_ALLOW_INSECURE
escape hatch for stub testing. Looks up the Dow org by canonical
domain dowjones.com. Transforms the (speculative, documented)
OMG payload into DowRow then calls upsertProjectFromDow. Unknown
fields from payload.raw land on Project.customFields JSON so OMG
can add fields without us losing data. Logs every event (never
the raw payload — PII).
- middleware.ts: /api/webhooks/ added to the unauthenticated-allowed
path list (alongside /api/auth and /api/health) — HMAC auth happens
inside the handler.
Verified: tsc --noEmit ✓ zero errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>