Commit graph

155 commits

Author SHA1 Message Date
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
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
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
DJP
d065f4b56f Projects: add Board view with Status/Stage grouping
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.
2026-04-21 11:57:07 -04:00
DJP
12f8817108 Compact stacked stage list + fold bookings into workload
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.
2026-04-21 11:54:30 -04:00
DJP
405da7d2f8 Add cross-project Deliverables view + role/team filters on Resources
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
2026-04-21 11:13:40 -04:00
DJP
985f8effbc Typography: swap Inter → Public Sans globally
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
2026-04-21 10:52:19 -04:00
DJP
7ab7205fd5 Theme: swap Oliver green → Dow navy
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.
2026-04-21 10:50:21 -04:00
DJP
77151bb53e Skeletons pulse in Dow navy + fix stretched logo
- 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.
2026-04-21 10:43:41 -04:00
DJP
a0ebf4a1bb Project form actually Dow-shaped, Pipeline Stage column, bigger job picker
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>
2026-04-21 09:03:51 -04:00
DJP
e439ea09db Fix missed .mjs db:seed script + Dow navy bg behind logo
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>
2026-04-21 08:56:53 -04:00
DJP
d5467348a7 Precompile seed to CJS + plain <img> for the logo
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>
2026-04-20 22:46:15 -04:00
DJP
18ae429924 Dow-ify project form + seed placeholder roster + Resource Manager + docs
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>
2026-04-20 22:31:19 -04:00
DJP
2b18c99296 Fix stuck deploy: seed deps missing from prod image + health check too strict
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>
2026-04-20 20:45:44 -04:00
DJP
be46089569 Fix Next.js 16 build: wrap /change-password useSearchParams in Suspense
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>
2026-04-20 20:25:04 -04:00
DJP
90a5031de2 Excel-grid /projects page — Dow-shaped table view
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>
2026-04-20 19:50:58 -04:00
DJP
fe3f91c7ef Smoke-test fixes: routing collision + XLSX parser + defaults
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>
2026-04-20 19:47:22 -04:00
DJP
a4107ae23d Phase 7+8: logo swap + deploy README
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>
2026-04-20 19:27:31 -04:00
DJP
4361d4cd2a Phase 6e: ClientTeam + Pod CRUD — settings pages and APIs
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>
2026-04-20 19:25:29 -04:00
DJP
69f293682a Fix deploy port clash + wire admin invite → add-user flow
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>
2026-04-20 19:06:47 -04:00
DJP
eede696eee Phase 6a: local auth — credentials login + password reset flow
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>
2026-04-20 19:00:15 -04:00
DJP
7598f4285e Phase 4+5: Dow XLSX ingest API + OMG webhook receiver
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>
2026-04-20 18:57:06 -04:00