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>
This commit is contained in:
DJP 2026-05-12 17:51:53 -04:00
parent ae6ebc6da2
commit 1b73d6b8db
84 changed files with 8185 additions and 261 deletions

View file

@ -67,3 +67,21 @@ BRIEF_WEBHOOK_ALLOW_INSECURE="false"
# MVP: false. Flip to "true" post-MVP once Entra redirect URI is live in Oliver's tenant.
# When false, login page shows only the local email+password form.
NEXT_PUBLIC_AUTH_ENTRA_ENABLED="false"
# ─── Box integration (Phase 4) ──────────────────────────
# Bidirectional Box transport: outbound on APPROVED, inbound via Box
# webhook. Auth is JWT app-auth (server-to-server). The JWT app generates
# a JSON config file in the Box developer console — mount it as a docker
# secret and point BOX_CONFIG_FILE at the path inside the container.
#
# When BOX_CONFIG_FILE is unset or the file doesn't exist, all Box paths
# fail closed with a clear error and the "Send to client" button surfaces
# "Box not configured" in the UI.
BOX_CONFIG_FILE="/run/secrets/box-config.json"
BOX_OUT_FOLDER_ID=""
BOX_WATCH_FOLDER_ID=""
BOX_WEBHOOK_PRIMARY_KEY=""
BOX_WEBHOOK_SECONDARY_KEY=""
# Local dev only — short-circuits Box webhook signature verification.
BOX_WEBHOOK_ALLOW_INSECURE="false"

6
.gitignore vendored
View file

@ -41,7 +41,11 @@ docker-compose.override.yml
# Rendered Apache conf — deploy.sh writes this from apache/*.conf.tmpl with
# the chosen APP_HOST_PORT substituted in. Never commit (port may vary per
# server).
apache/dow-prod-tracker.conf
apache/loreal-prod-tracker.conf
# Box JWT app config — never commit. deploy.sh stubs it on first deploy;
# operator drops the real config from the Box developer console here.
/secrets/box-config.json
# vercel
.vercel

22
API.md
View file

@ -1,7 +1,7 @@
# Dow Jones Studio Tracker — API Reference
All endpoints live under the app's base path **`/dow-prod-tracker`**. Examples
in this doc use `https://optical-dev.oliver.solutions/dow-prod-tracker` as the
All endpoints live under the app's base path **`/loreal-prod-tracker`**. Examples
in this doc use `https://optical-dev.oliver.solutions/loreal-prod-tracker` as the
origin — substitute your own when self-hosting.
**The canonical key for jobs is `omgJobNumber`.** XLSX uploads and the OMG
@ -183,7 +183,7 @@ one project per row (keyed on `omgJobNumber`).
curl -X POST \
-H "X-API-Key: $API_KEY" \
-F "file=@Dow Jones_Studio Tracker_Example.xlsx" \
"https://optical-dev.oliver.solutions/dow-prod-tracker/api/projects/bulk-import?commit=false"
"https://optical-dev.oliver.solutions/loreal-prod-tracker/api/projects/bulk-import?commit=false"
```
Response:
@ -218,7 +218,7 @@ preview UI. Errors are row-scoped and don't abort the batch.
curl -X POST \
-H "X-API-Key: $API_KEY" \
-F "file=@Dow Jones_Studio Tracker_Example.xlsx" \
"https://optical-dev.oliver.solutions/dow-prod-tracker/api/projects/bulk-import?commit=true"
"https://optical-dev.oliver.solutions/loreal-prod-tracker/api/projects/bulk-import?commit=true"
```
Response:
@ -343,7 +343,7 @@ curl -X POST \
-H "Content-Type: application/json" \
-H "X-OMG-Signature: sha256=$SIG" \
-d "$BODY" \
https://optical-dev.oliver.solutions/dow-prod-tracker/api/webhooks/omg
https://optical-dev.oliver.solutions/loreal-prod-tracker/api/webhooks/omg
```
---
@ -439,14 +439,14 @@ All visibility-scoped — non-admins see only their client-team projects.
```bash
# 1. Health
curl https://optical-dev.oliver.solutions/dow-prod-tracker/api/health
curl https://optical-dev.oliver.solutions/loreal-prod-tracker/api/health
# 2. Invite the first producer (needs X-API-Key — you're the admin who ran seed)
curl -X POST \
-H "Content-Type: application/json" \
-H "X-API-Key: $API_KEY" \
-d '{"email":"producer@oliver.agency","role":"PRODUCER"}' \
https://optical-dev.oliver.solutions/dow-prod-tracker/api/org/invitations
https://optical-dev.oliver.solutions/loreal-prod-tracker/api/org/invitations
# response: {"id":"...","email":"...","acceptUrl":".../reset-password/<token>"}
# hand that URL over for the producer to set a password.
@ -455,7 +455,7 @@ curl -X POST \
curl -X POST \
-H "X-API-Key: $API_KEY" \
-F "file=@Dow Jones_Studio Tracker_2026_04_20.xlsx" \
"https://optical-dev.oliver.solutions/dow-prod-tracker/api/projects/bulk-import?commit=true"
"https://optical-dev.oliver.solutions/loreal-prod-tracker/api/projects/bulk-import?commit=true"
```
### OMG publishes a status change
@ -479,7 +479,7 @@ body = json.dumps({
sig = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
requests.post(
"https://optical-dev.oliver.solutions/dow-prod-tracker/api/webhooks/omg",
"https://optical-dev.oliver.solutions/loreal-prod-tracker/api/webhooks/omg",
data=body,
headers={
"Content-Type": "application/json",
@ -496,14 +496,14 @@ if the `number` already exists, created otherwise.
```bash
JOB="2337959"
PROJECT_ID=$(curl -s -H "X-API-Key: $API_KEY" \
"https://optical-dev.oliver.solutions/dow-prod-tracker/api/projects" \
"https://optical-dev.oliver.solutions/loreal-prod-tracker/api/projects" \
| jq -r ".[] | select(.omgJobNumber == \"$JOB\") | .id")
curl -X PATCH \
-H "Content-Type: application/json" \
-H "X-API-Key: $API_KEY" \
-d '{"status":"COMPLETED","description":"Delivered 2026-04-21"}' \
"https://optical-dev.oliver.solutions/dow-prod-tracker/api/projects/$PROJECT_ID"
"https://optical-dev.oliver.solutions/loreal-prod-tracker/api/projects/$PROJECT_ID"
```
---

View file

@ -1,12 +1,12 @@
# Deploying dow-prod-tracker
# Deploying loreal-prod-tracker
Target: **`https://optical-dev.oliver.solutions/dow-prod-tracker`**, hosted on the
Target: **`https://optical-dev.oliver.solutions/loreal-prod-tracker`**, hosted on the
shared Oliver Agency dev box alongside `hp-prod-tracker`.
Run the deploy script from the repo root on the server:
```bash
cd /opt/dow-prod-tracker # or wherever you cloned it
cd /opt/loreal-prod-tracker # or wherever you cloned it
./deploy.sh
```
@ -23,14 +23,14 @@ things must hold or those apps will silently corrupt each other** (this has
bitten us before — see CLAUDE.md):
1. **`docker-compose.yml` MUST pin a top-level `name:` field.**
We pin `name: dow-prod-tracker`. Without this, Compose defaults the project
We pin `name: loreal-prod-tracker`. Without this, Compose defaults the project
name to the parent directory name. If two apps both live under `deploy/`,
they collapse onto the same project and fight over containers
(`deploy-db-1`) and volumes (`deploy_pgdata`). Deploying one would silently
evict the other and destroy its data.
2. **Every `docker compose` invocation in `deploy.sh` passes `-p
dow-prod-tracker` as belt-and-braces.**
loreal-prod-tracker` as belt-and-braces.**
This is redundant with `name:` today, but if anyone moves the `name:` line
out of the compose file, or runs commands by hand from a different cwd,
the `-p` flag is the safety net.
@ -39,12 +39,12 @@ The deploy script also enforces:
| Concern | Value | Where |
|---|---|---|
| Compose project name | `dow-prod-tracker` | `name:` in `docker-compose.yml` + `-p` in `deploy.sh` |
| App port (host) | `3002` preferred, auto-bumped if busy | `deploy.sh` probes, exports `APP_HOST_PORT`, renders `apache/dow-prod-tracker.conf` from the `.tmpl` |
| Compose project name | `loreal-prod-tracker` | `name:` in `docker-compose.yml` + `-p` in `deploy.sh` |
| App port (host) | `3002` preferred, auto-bumped if busy | `deploy.sh` probes, exports `APP_HOST_PORT`, renders `apache/loreal-prod-tracker.conf` from the `.tmpl` |
| DB port (host) | `5492` preferred, auto-bumped if busy | `deploy.sh` probes, exports `DB_HOST_PORT` into compose |
| DB name | `dow_prod_tracker` | `docker-compose.yml` env + `.env` `DATABASE_URL` |
| App URL path | `/dow-prod-tracker` | `next.config.ts` `basePath` |
| Apache reverse proxy | `→ 127.0.0.1:${APP_HOST_PORT}/dow-prod-tracker` | rendered on each deploy from `apache/dow-prod-tracker.conf.tmpl` |
| DB name | `loreal_prod_tracker` | `docker-compose.yml` env + `.env` `DATABASE_URL` |
| App URL path | `/loreal-prod-tracker` | `next.config.ts` `basePath` |
| Apache reverse proxy | `→ 127.0.0.1:${APP_HOST_PORT}/loreal-prod-tracker` | rendered on each deploy from `apache/loreal-prod-tracker.conf.tmpl` |
**Auto port selection.** Everything runs in Docker — the container-internal
ports (app 3000, db 5432) never change. The *host* ports are only used by
@ -70,7 +70,7 @@ APP_HOST_PORT=3005 DB_HOST_PORT=5495 ./deploy.sh
Required env vars on the server:
```
DATABASE_URL=postgresql://postgres:<DB_PASSWORD>@db:5432/dow_prod_tracker?schema=public
DATABASE_URL=postgresql://postgres:<DB_PASSWORD>@db:5432/loreal_prod_tracker?schema=public
DB_PASSWORD=<long random>
AUTH_SECRET=<openssl rand -base64 32>
@ -80,7 +80,7 @@ NEXT_PUBLIC_AUTH_ENTRA_ENABLED=false
# Entra (only required when NEXT_PUBLIC_AUTH_ENTRA_ENABLED=true)
AZURE_CLIENT_ID=
AZURE_TENANT_ID=
AZURE_REDIRECT_URI=https://optical-dev.oliver.solutions/dow-prod-tracker/login
AZURE_REDIRECT_URI=https://optical-dev.oliver.solutions/loreal-prod-tracker/login
# OMG webhook receiver
OMG_WEBHOOK_SECRET=<openssl rand -hex 32> # share with Shashank
@ -109,8 +109,8 @@ DOW_ADMIN_PASSWORD= # leave blank → seed prints a r
# 1. Clone — /opt is standard for third-party apps on Debian/Ubuntu
sudo mkdir -p /opt && sudo chown $USER:$USER /opt
cd /opt
git clone git@bitbucket.org:zlalani/dow-prod-tracker.git
cd dow-prod-tracker
git clone git@bitbucket.org:zlalani/loreal-prod-tracker.git
cd loreal-prod-tracker
# 2. Configure
cp .env.example .env
@ -120,7 +120,7 @@ $EDITOR .env # fill in real secrets
./deploy.sh
# 4. Seed the Dow tenant + initial admin (one-time)
docker compose -p dow-prod-tracker exec app npm run db:seed
docker compose -p loreal-prod-tracker exec app npm run db:seed
# → save the printed admin email + temp password
```
@ -129,7 +129,7 @@ docker compose -p dow-prod-tracker exec app npm run db:seed
## Updating an existing deployment
```bash
cd /opt/dow-prod-tracker
cd /opt/loreal-prod-tracker
./deploy.sh # pulls, rebuilds, restarts
```
@ -143,16 +143,16 @@ automatically.
1. **Volume isolation** — both apps' volumes must be distinct:
```bash
docker volume ls | grep -E "hp-prod-tracker|dow-prod-tracker"
docker volume ls | grep -E "hp-prod-tracker|loreal-prod-tracker"
```
Expect to see `hp-prod-tracker_pgdata`, `hp-prod-tracker_uploads_data`,
`dow-prod-tracker_pgdata`, `dow-prod-tracker_uploads_data` — four distinct
`loreal-prod-tracker_pgdata`, `loreal-prod-tracker_uploads_data` — four distinct
volumes.
2. **HP unaffected** — hit HP's URL in a browser and confirm it loads. If it
fails, the new deploy collided with it (don't continue — fix first).
3. **Health check**`curl https://optical-dev.oliver.solutions/dow-prod-tracker/api/health`
3. **Health check**`curl https://optical-dev.oliver.solutions/loreal-prod-tracker/api/health`
should return 200.
4. **Login** — open the URL in a browser, sign in with the seed admin
@ -164,14 +164,14 @@ automatically.
Expect a preview JSON with normalized rows + any row errors. Then commit
with `?commit=true` and spot-check a project in psql:
```bash
docker compose -p dow-prod-tracker exec db \
psql -U postgres -d dow_prod_tracker \
docker compose -p loreal-prod-tracker exec db \
psql -U postgres -d loreal_prod_tracker \
-c "select \"omgJobNumber\", name, status, \"clientTeamId\" from projects limit 5;"
```
6. **OMG webhook** — when Shashank confirms the payload shape, share
`OMG_WEBHOOK_SECRET` and have OMG POST to
`https://optical-dev.oliver.solutions/dow-prod-tracker/api/webhooks/omg`
`https://optical-dev.oliver.solutions/loreal-prod-tracker/api/webhooks/omg`
with header `X-OMG-Signature: sha256=<hex hmac of body>`.
7. **Per-team visibility** — invite a test user as `CLIENT_VIEWER`, assign
@ -185,7 +185,7 @@ automatically.
If a deploy goes wrong:
```bash
cd /opt/dow-prod-tracker
cd /opt/loreal-prod-tracker
git log --oneline -5 # find previous good commit
git checkout <previous-commit>
./deploy.sh --skip-pull # rebuild from that commit
@ -202,11 +202,11 @@ forward-only), restore from the latest postgres dump in `/srv/backups/`
**"port is already allocated"** — very unlikely now; `deploy.sh` auto-picks
a free port before `docker compose up`. If it still happens, something
grabbed the port between the probe and the bind. Just rerun `./deploy.sh`.
If it's a stale dow-prod-tracker container from a botched deploy,
`docker compose -p dow-prod-tracker down` will release it first.
If it's a stale loreal-prod-tracker container from a botched deploy,
`docker compose -p loreal-prod-tracker down` will release it first.
**Apache returns 502** — the app container isn't running or its health check
is failing. `docker compose -p dow-prod-tracker logs app --tail 50`.
is failing. `docker compose -p loreal-prod-tracker logs app --tail 50`.
**"Project with that OMG number already exists in a different organization"**
— a previous import landed projects under a different org (e.g. dev seed).
@ -223,7 +223,7 @@ share the exact `OMG_WEBHOOK_SECRET` and that OMG sends the header as
## Backups
The database is business-critical. A nightly `pg_dump` runs on the host via
cron; the dumps land in `/srv/backups/dow-prod-tracker/` and auto-prune after
cron; the dumps land in `/srv/backups/loreal-prod-tracker/` and auto-prune after
30 days. **On top of that**, admins can grab a full XLSX snapshot from the
Dashboard's "Export Full XLSX" button — same shape as the Dow upstream XLSX
so a worst-case recovery can re-ingest through the `bulk-import` endpoint.
@ -232,18 +232,18 @@ so a worst-case recovery can re-ingest through the `bulk-import` endpoint.
```bash
# 1. Make sure the backup dir exists + is writable
sudo mkdir -p /srv/backups/dow-prod-tracker
sudo chown "$USER" /srv/backups/dow-prod-tracker
sudo mkdir -p /srv/backups/loreal-prod-tracker
sudo chown "$USER" /srv/backups/loreal-prod-tracker
# 2. Test the script by hand
cd /opt/dow-prod-tracker
cd /opt/loreal-prod-tracker
./scripts/backup-db.sh
ls -lh /srv/backups/dow-prod-tracker/ # should show dow-prod-tracker_YYYY-MM-DD_HHMMSS.sql.gz
ls -lh /srv/backups/loreal-prod-tracker/ # should show loreal-prod-tracker_YYYY-MM-DD_HHMMSS.sql.gz
# 3. Wire it to cron — midnight every night
crontab -e
# add this line:
0 0 * * * /opt/dow-prod-tracker/scripts/backup-db.sh >> /var/log/dow-prod-tracker-backup.log 2>&1
0 0 * * * /opt/loreal-prod-tracker/scripts/backup-db.sh >> /var/log/loreal-prod-tracker-backup.log 2>&1
```
Adjust `BACKUP_DIR` / `RETAIN_DAYS` / `COMPOSE_DIR` via env vars on the cron
@ -252,24 +252,24 @@ line if your paths differ from the defaults.
### Restoring from a dump
```bash
cd /opt/dow-prod-tracker
BACKUP=/srv/backups/dow-prod-tracker/dow-prod-tracker_2026-04-22_000001.sql.gz
cd /opt/loreal-prod-tracker
BACKUP=/srv/backups/loreal-prod-tracker/loreal-prod-tracker_2026-04-22_000001.sql.gz
# 1. Stop the app so nothing writes during restore
docker compose -p dow-prod-tracker stop app
docker compose -p loreal-prod-tracker stop app
# 2. Pipe the dump through psql. --clean --if-exists on the dump drops
# existing tables first, so this is destructive — confirm you have
# the right file.
gunzip -c "$BACKUP" | docker compose -p dow-prod-tracker exec -T db \
psql -U postgres -d dow_prod_tracker
gunzip -c "$BACKUP" | docker compose -p loreal-prod-tracker exec -T db \
psql -U postgres -d loreal_prod_tracker
# 3. Bring the app back up
docker compose -p dow-prod-tracker start app
docker compose -p loreal-prod-tracker start app
# 4. Sanity-check via the app or a quick psql count
docker compose -p dow-prod-tracker exec -T db \
psql -U postgres -d dow_prod_tracker -c "SELECT count(*) FROM projects;"
docker compose -p loreal-prod-tracker exec -T db \
psql -U postgres -d loreal_prod_tracker -c "SELECT count(*) FROM projects;"
```
### Off-site backup (optional, recommended)
@ -279,7 +279,7 @@ the whole server vanishing. Sync the backup dir to object storage:
```bash
# Add to crontab after the pg_dump line
15 0 * * * aws s3 sync /srv/backups/dow-prod-tracker/ s3://your-bucket/dow-prod-tracker/ --delete
15 0 * * * aws s3 sync /srv/backups/loreal-prod-tracker/ s3://your-bucket/loreal-prod-tracker/ --delete
```
Or rsync to a separate host. Either way, don't skip this if the app is

View file

@ -55,8 +55,8 @@ Prereqs: Docker Desktop, Node 20+, SSH key on Bitbucket.
```bash
# 1. Clone
cd ~/Desktop/CODING-2024
git clone git@bitbucket.org:zlalani/dow-prod-tracker.git DOW-PROD-TRACKER/dow-prod-tracker
cd DOW-PROD-TRACKER/dow-prod-tracker
git clone git@bitbucket.org:zlalani/loreal-prod-tracker.git DOW-PROD-TRACKER/loreal-prod-tracker
cd DOW-PROD-TRACKER/loreal-prod-tracker
npm install
# 2. Env
@ -66,7 +66,7 @@ sed -i '' "s|^AUTH_SECRET=.*|AUTH_SECRET=\"$(openssl rand -base64 32)\"|" .env
# Set NEXT_PUBLIC_AUTH_ENTRA_ENABLED=false (default) so local auth works.
# 3. Start the DB (compose handles port clashes via the ${DB_HOST_PORT:-5492} default)
docker compose -p dow-prod-tracker up -d db
docker compose -p loreal-prod-tracker up -d db
# If 5492 is busy on your Mac (e.g. another project's postgres), create a local
# override — gitignored:
@ -83,7 +83,7 @@ npm run db:seed # prints admin email + temp password — SAVE THEM
# 5. Dev server
npm run dev
# → http://localhost:3000/dow-prod-tracker
# → http://localhost:3000/loreal-prod-tracker
# (or :3001 if Docker Desktop occupies :3000 on your Mac)
```
@ -96,10 +96,10 @@ Sign in with the seed admin → forced password change → Dashboard.
See [DEPLOY.md](./DEPLOY.md). One-liner summary:
```bash
cd /opt/dow-prod-tracker
cd /opt/loreal-prod-tracker
git pull
./deploy.sh
docker compose -p dow-prod-tracker exec app npm run db:seed # first deploy only
docker compose -p loreal-prod-tracker exec app npm run db:seed # first deploy only
```
`deploy.sh` auto-picks free host ports (3002 / 5492 preferred), renders the
@ -150,7 +150,7 @@ curl -X POST \
-H "Content-Type: application/json" \
-H "X-API-Key: $API_KEY" \
-d '{"email":"newuser@oliver.agency","role":"PRODUCER"}' \
https://your-host/dow-prod-tracker/api/org/invitations
https://your-host/loreal-prod-tracker/api/org/invitations
# → response includes { "acceptUrl": ".../reset-password/<token>" }
```
@ -214,13 +214,13 @@ The fastest way to populate the tracker from Dow's existing spreadsheet.
curl -X POST \
-H "X-API-Key: $API_KEY" \
-F "file=@Dow_Studio_Tracker_2026_04_20.xlsx" \
"https://your-host/dow-prod-tracker/api/projects/bulk-import?commit=false"
"https://your-host/loreal-prod-tracker/api/projects/bulk-import?commit=false"
# commit
curl -X POST \
-H "X-API-Key: $API_KEY" \
-F "file=@Dow_Studio_Tracker_2026_04_20.xlsx" \
"https://your-host/dow-prod-tracker/api/projects/bulk-import?commit=true"
"https://your-host/loreal-prod-tracker/api/projects/bulk-import?commit=true"
```
### Idempotency
@ -252,7 +252,7 @@ openssl rand -hex 32
### 2. Set it on the server
```bash
# /opt/dow-prod-tracker/.env
# /opt/loreal-prod-tracker/.env
OMG_WEBHOOK_SECRET="<the hex string>"
OMG_WEBHOOK_ALLOW_INSECURE="false" # ensure this is false in prod
```
@ -260,12 +260,12 @@ OMG_WEBHOOK_ALLOW_INSECURE="false" # ensure this is false in prod
Restart the app container to pick up the env:
```bash
docker compose -p dow-prod-tracker up -d --force-recreate app
docker compose -p loreal-prod-tracker up -d --force-recreate app
```
### 3. Give the secret + endpoint to Shashank / OMG
- URL: `https://optical-dev.oliver.solutions/dow-prod-tracker/api/webhooks/omg`
- URL: `https://optical-dev.oliver.solutions/loreal-prod-tracker/api/webhooks/omg`
- Method: `POST`
- Content-Type: `application/json`
- Signature header: `X-OMG-Signature: sha256=<hex HMAC-SHA256 of the raw body
@ -275,7 +275,7 @@ docker compose -p dow-prod-tracker up -d --force-recreate app
### 4. Test with a stub payload
```bash
SECRET="$(grep OMG_WEBHOOK_SECRET /opt/dow-prod-tracker/.env | cut -d= -f2- | tr -d '"')"
SECRET="$(grep OMG_WEBHOOK_SECRET /opt/loreal-prod-tracker/.env | cut -d= -f2- | tr -d '"')"
BODY='{"event":"job.updated","timestamp":"2026-04-21T12:00:00Z","job":{"number":"TEST-001","name":"Stub job","team":"Brand","status":"In production"}}'
SIG=$(printf "%s" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
@ -283,7 +283,7 @@ curl -i -X POST \
-H "Content-Type: application/json" \
-H "X-OMG-Signature: sha256=$SIG" \
-d "$BODY" \
https://optical-dev.oliver.solutions/dow-prod-tracker/api/webhooks/omg
https://optical-dev.oliver.solutions/loreal-prod-tracker/api/webhooks/omg
```
Expected: `200 { "ok": true, "projectId": "..." }`. A TEST-001 project should
@ -383,7 +383,7 @@ projects, an Admin sees everything.
### "No organizations found" on `/api/health`
You haven't run the seed. `docker compose -p dow-prod-tracker exec app npm run db:seed`.
You haven't run the seed. `docker compose -p loreal-prod-tracker exec app npm run db:seed`.
### Seed says `tsx: not found`
@ -391,17 +391,17 @@ The runner image is missing the dev deps. Fixed in commit `df7ddbf` (installs
tsx globally). Rebuild with `--no-cache`:
```bash
docker compose -p dow-prod-tracker down
docker compose -p dow-prod-tracker build --no-cache app
docker compose -p dow-prod-tracker up -d
docker compose -p loreal-prod-tracker down
docker compose -p loreal-prod-tracker build --no-cache app
docker compose -p loreal-prod-tracker up -d
```
### Browser returns 404 at `/dow-prod-tracker/...`
### Browser returns 404 at `/loreal-prod-tracker/...`
Apache Include didn't land. Check:
```bash
grep dow-prod-tracker /etc/apache2/sites-enabled/optical-dev.oliver.solutions.conf
grep loreal-prod-tracker /etc/apache2/sites-enabled/optical-dev.oliver.solutions.conf
```
If absent, re-run deploy.sh (it auto-detects whether `sites-enabled` is a
@ -426,7 +426,7 @@ powers by default.
### Apache 502 after deploy
App container didn't start. `docker compose -p dow-prod-tracker logs app --tail 50`.
App container didn't start. `docker compose -p loreal-prod-tracker logs app --tail 50`.
### XLSX upload rejects most rows

View file

@ -12,7 +12,7 @@ example curl + signing snippet is included for each endpoint.
## Base URL
```
https://optical-dev.oliver.solutions/dow-prod-tracker
https://optical-dev.oliver.solutions/loreal-prod-tracker
```
Every URL in this doc is relative to that base.
@ -66,7 +66,7 @@ where `hex_digest` is the lowercase hex of
BODY='{"title":"New brief","priority":"HIGH"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$BRIEF_WEBHOOK_SECRET" | awk '{print $2}')
curl -X POST https://optical-dev.oliver.solutions/dow-prod-tracker/api/webhooks/briefs \
curl -X POST https://optical-dev.oliver.solutions/loreal-prod-tracker/api/webhooks/briefs \
-H "Content-Type: application/json" \
-H "X-Brief-Signature: sha256=$SIG" \
-d "$BODY"
@ -80,7 +80,7 @@ body = json.dumps({"title": "New brief", "priority": "HIGH"}, separators=(",", "
sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest()
requests.post(
"https://optical-dev.oliver.solutions/dow-prod-tracker/api/webhooks/briefs",
"https://optical-dev.oliver.solutions/loreal-prod-tracker/api/webhooks/briefs",
headers={"Content-Type": "application/json", "X-Brief-Signature": f"sha256={sig}"},
data=body, # pass the SAME string we signed; don't re-serialise
)
@ -93,7 +93,7 @@ import crypto from "node:crypto";
const body = JSON.stringify({ title: "New brief", priority: "HIGH" });
const sig = crypto.createHmac("sha256", secret).update(body).digest("hex");
await fetch("https://optical-dev.oliver.solutions/dow-prod-tracker/api/webhooks/briefs", {
await fetch("https://optical-dev.oliver.solutions/loreal-prod-tracker/api/webhooks/briefs", {
method: "POST",
headers: { "Content-Type": "application/json", "X-Brief-Signature": `sha256=${sig}` },
body,
@ -456,4 +456,4 @@ duplicates.
## Changelog
- **2026-04-22** — initial document. Three webhooks live on
`/dow-prod-tracker/api/webhooks/{omg,deliverables,briefs}`.
`/loreal-prod-tracker/api/webhooks/{omg,deliverables,briefs}`.

View file

@ -154,8 +154,8 @@ User clicks Confirm → mutation executes → cache invalidated
```bash
# Clone and configure
git clone <repo-url> dow-prod-tracker
cd dow-prod-tracker
git clone <repo-url> loreal-prod-tracker
cd loreal-prod-tracker
cp .env.example .env
# Edit .env with your values (see Environment Variables below)
@ -172,7 +172,7 @@ The Dockerfile is a multi-stage Node 22 Alpine build with FFmpeg for video proce
```env
# ─── Database ────────────────────────────────────────────
DATABASE_URL="postgresql://postgres:postgres@db:5432/dow_prod_tracker?schema=public"
DATABASE_URL="postgresql://postgres:postgres@db:5432/loreal_prod_tracker?schema=public"
DB_PASSWORD="your-password"
# ─── Auth (Microsoft Entra ID SSO) ──────────────────────
@ -204,14 +204,14 @@ DEV_USER_ID="dev-user-001"
### Apache Reverse Proxy
The app is served behind Apache at `/dow-prod-tracker`. The Next.js config sets `basePath: '/dow-prod-tracker'`. Key Apache configuration:
The app is served behind Apache at `/loreal-prod-tracker`. The Next.js config sets `basePath: '/loreal-prod-tracker'`. Key Apache configuration:
```apache
ProxyPass /dow-prod-tracker http://localhost:3001/dow-prod-tracker
ProxyPassReverse /dow-prod-tracker http://localhost:3001/dow-prod-tracker
ProxyPass /loreal-prod-tracker http://localhost:3001/loreal-prod-tracker
ProxyPassReverse /loreal-prod-tracker http://localhost:3001/loreal-prod-tracker
# SSE (chat) needs a longer timeout — Ollama can take 60-180s
ProxyPass /dow-prod-tracker/api/chat http://localhost:3001/dow-prod-tracker/api/chat timeout=300
ProxyPass /loreal-prod-tracker/api/chat http://localhost:3001/loreal-prod-tracker/api/chat timeout=300
```
---

View file

@ -40,8 +40,8 @@ nvm use 22
## 1. Clone the Repository
```bash
git clone <repo-url> dow_prod_tracker
cd dow_prod_tracker
git clone <repo-url> loreal_prod_tracker
cd loreal_prod_tracker
```
---
@ -69,7 +69,7 @@ npx prisma generate
```bash
brew install postgresql@17
brew services start postgresql@17
createdb dow_prod_tracker
createdb loreal_prod_tracker
```
The default Homebrew PostgreSQL setup uses your system username with no password.
@ -83,20 +83,20 @@ The default Homebrew PostgreSQL setup uses your system username with no password
2. During installation, note the password you set for the `postgres` user.
3. Open **pgAdmin** or **psql** and create the database:
```sql
CREATE DATABASE dow_prod_tracker;
CREATE DATABASE loreal_prod_tracker;
```
4. Optionally create a dedicated user:
```sql
CREATE USER leivur WITH PASSWORD 'your_password';
GRANT ALL PRIVILEGES ON DATABASE dow_prod_tracker TO leivur;
GRANT ALL PRIVILEGES ON DATABASE loreal_prod_tracker TO leivur;
```
### Verify Connection
```bash
psql -h localhost -U leivur -d dow_prod_tracker
psql -h localhost -U leivur -d loreal_prod_tracker
# or on Windows if using postgres user:
psql -h localhost -U postgres -d dow_prod_tracker
psql -h localhost -U postgres -d loreal_prod_tracker
```
---
@ -108,10 +108,10 @@ Create a `.env` file in the project root (this file is gitignored):
```env
# ─── Database ────────────────────────────────────────────
# macOS (Homebrew, no password):
DATABASE_URL="postgresql://leivur@localhost:5432/dow_prod_tracker?schema=public"
DATABASE_URL="postgresql://leivur@localhost:5432/loreal_prod_tracker?schema=public"
# Windows (with password):
# DATABASE_URL="postgresql://leivur:your_password@localhost:5432/dow_prod_tracker?schema=public"
# DATABASE_URL="postgresql://leivur:your_password@localhost:5432/loreal_prod_tracker?schema=public"
# ─── Auth (Dev Bypass) ──────────────────────────────────
# Skips SSO and uses a seeded dev user. Set to "false" or remove for production SSO.

View file

@ -1,27 +0,0 @@
# ── Dow Prod Tracker — Next.js standalone ────────────────────────────────
# This is a TEMPLATE — deploy.sh renders ${APP_HOST_PORT} into the sibling
# apache/dow-prod-tracker.conf (gitignored) before telling Apache to Include
# that rendered file. Edit this .tmpl, then rerun deploy.sh.
#
# APP_HOST_PORT is auto-picked by deploy.sh from 3002 upward if that port is
# already in use on the host. The rendered .conf always points at whatever
# port Docker actually bound — no drift between the reverse proxy and the
# container.
# Large uploads: video files up to 500 MB (overrides the global 100 MB limit)
<Location /dow-prod-tracker>
LimitRequestBody 524288000
</Location>
# WebSocket passthrough (Next.js real-time features)
RewriteCond %{HTTP:Upgrade} websocket [NC]
RewriteCond %{HTTP:Connection} upgrade [NC]
RewriteRule ^/dow-prod-tracker/(.*) ws://127.0.0.1:${APP_HOST_PORT}/dow-prod-tracker/$1 [P,L]
# Chat + AI endpoints: long timeout for streaming responses
ProxyPass /dow-prod-tracker/api/chat http://127.0.0.1:${APP_HOST_PORT}/dow-prod-tracker/api/chat timeout=300
ProxyPassReverse /dow-prod-tracker/api/chat http://127.0.0.1:${APP_HOST_PORT}/dow-prod-tracker/api/chat
# All other routes (must come after more-specific paths above)
ProxyPass /dow-prod-tracker http://127.0.0.1:${APP_HOST_PORT}/dow-prod-tracker
ProxyPassReverse /dow-prod-tracker http://127.0.0.1:${APP_HOST_PORT}/dow-prod-tracker

View file

@ -0,0 +1,27 @@
# ── L'Oréal Prod Tracker — Next.js standalone ───────────────────────────
# This is a TEMPLATE — deploy.sh renders ${APP_HOST_PORT} into the sibling
# apache/loreal-prod-tracker.conf (gitignored) before telling Apache to
# Include that rendered file. Edit this .tmpl, then rerun deploy.sh.
#
# APP_HOST_PORT is auto-picked by deploy.sh from 3002 upward if that port is
# already in use on the host. The rendered .conf always points at whatever
# port Docker actually bound — no drift between the reverse proxy and the
# container.
# Large uploads: video files up to 500 MB (overrides the global 100 MB limit)
<Location /loreal-prod-tracker>
LimitRequestBody 524288000
</Location>
# WebSocket passthrough (Next.js real-time features)
RewriteCond %{HTTP:Upgrade} websocket [NC]
RewriteCond %{HTTP:Connection} upgrade [NC]
RewriteRule ^/loreal-prod-tracker/(.*) ws://127.0.0.1:${APP_HOST_PORT}/loreal-prod-tracker/$1 [P,L]
# Chat + AI endpoints: long timeout for streaming responses
ProxyPass /loreal-prod-tracker/api/chat http://127.0.0.1:${APP_HOST_PORT}/loreal-prod-tracker/api/chat timeout=300
ProxyPassReverse /loreal-prod-tracker/api/chat http://127.0.0.1:${APP_HOST_PORT}/loreal-prod-tracker/api/chat
# All other routes (must come after more-specific paths above)
ProxyPass /loreal-prod-tracker http://127.0.0.1:${APP_HOST_PORT}/loreal-prod-tracker
ProxyPassReverse /loreal-prod-tracker http://127.0.0.1:${APP_HOST_PORT}/loreal-prod-tracker

View file

@ -1,5 +1,5 @@
#!/usr/bin/env bash
# deploy.sh — idempotent deploy script for dow-prod-tracker
# deploy.sh — idempotent deploy script for loreal-prod-tracker
# Idempotent — safe to run multiple times (initial deploy or update)
# Run as normal user; uses sudo internally for apt/apache/ufw
set -euo pipefail
@ -28,8 +28,8 @@ PREFERRED_DB_PORT=5492
# docker-compose.yml. CLAUDE.md rule: the shared optical-dev server runs
# multiple apps from deploy dirs and Compose defaults the project name to
# the parent directory, so without this they collide on containers
# (dow-prod-tracker-db-1 vs hp-prod-tracker-db-1) and volumes.
COMPOSE_PROJECT=dow-prod-tracker
# (loreal-prod-tracker-db-1 vs hp-prod-tracker-db-1) and volumes.
COMPOSE_PROJECT=loreal-prod-tracker
# ── Port helpers ────────────────────────────────────────────────────────────
# is_port_free uses bash's /dev/tcp — no external tool needed, works on
@ -155,6 +155,17 @@ done
info " .env OK"
# Box JWT app config — docker-compose declares ./secrets/box-config.json as
# a docker secret. Compose refuses to start when the file is missing, so
# we stub an empty {} if it doesn't exist. Box features stay disabled
# (isBoxConfigured() returns false) until the user drops the real config
# from the Box developer console at this path.
mkdir -p "$SCRIPT_DIR/secrets"
if [[ ! -f "$SCRIPT_DIR/secrets/box-config.json" ]]; then
echo '{}' > "$SCRIPT_DIR/secrets/box-config.json"
info " Created empty secrets/box-config.json — Box features disabled until configured"
fi
# ─────────────────────────────────────────────────────────────────────────────
# STEP 4: Build and start Docker containers
# ─────────────────────────────────────────────────────────────────────────────
@ -232,7 +243,7 @@ done
# ─────────────────────────────────────────────────────────────────────────────
info "Step 6: Waiting for app to be healthy (Prisma migrations run on startup)..."
HEALTH_URL="http://localhost:${APP_HOST_PORT}/dow-prod-tracker/api/health"
HEALTH_URL="http://localhost:${APP_HOST_PORT}/loreal-prod-tracker/api/health"
for i in $(seq 1 40); do
if curl -sf "$HEALTH_URL" &>/dev/null; then
info " App healthy (${i}s)"
@ -260,7 +271,7 @@ info "Step 7: Checking if database needs seeding..."
# haven't actually run (shouldn't happen — Step 6 waits for healthy, which
# happens after migrate deploy — but belt-and-braces).
ORG_COUNT=$(docker compose -p "$COMPOSE_PROJECT" exec -T db \
psql -U postgres -d dow_prod_tracker -tA \
psql -U postgres -d loreal_prod_tracker -tA \
-c "SELECT COUNT(*) FROM organizations;" 2>/dev/null \
| tr -d '[:space:]' || echo "0")
ORG_COUNT="${ORG_COUNT:-0}"
@ -296,8 +307,8 @@ else
APACHE_CONF="$APACHE_SITES_AVAILABLE"
fi
APACHE_TMPL="$SCRIPT_DIR/apache/dow-prod-tracker.conf.tmpl"
APACHE_SNIPPET="$SCRIPT_DIR/apache/dow-prod-tracker.conf"
APACHE_TMPL="$SCRIPT_DIR/apache/loreal-prod-tracker.conf.tmpl"
APACHE_SNIPPET="$SCRIPT_DIR/apache/loreal-prod-tracker.conf"
INCLUDE_LINE=" Include $APACHE_SNIPPET"
# Render the template with the chosen APP_HOST_PORT — the committed .tmpl
@ -317,8 +328,8 @@ elif grep -qF "$APACHE_SNIPPET" "$APACHE_CONF"; then
else
# Remove the old manually-added inline block before inserting the canonical Include
sudo sed -i '/# .*HP-PROD-TRACKER\|HP-PROD-TRACKER.*3001/d' "$APACHE_CONF"
sudo sed -i '/ProxyPass[[:space:]].*dow-prod-tracker/d' "$APACHE_CONF"
sudo sed -i '/ProxyPassReverse[[:space:]].*dow-prod-tracker/d' "$APACHE_CONF"
sudo sed -i '/ProxyPass[[:space:]].*loreal-prod-tracker/d' "$APACHE_CONF"
sudo sed -i '/ProxyPassReverse[[:space:]].*loreal-prod-tracker/d' "$APACHE_CONF"
# Insert Include before </VirtualHost>
sudo sed -i "s|</VirtualHost>|$INCLUDE_LINE\n</VirtualHost>|" "$APACHE_CONF"
@ -350,6 +361,6 @@ docker compose -p "$COMPOSE_PROJECT" ps
echo ""
info "Deploy complete!"
info " Commit : $(git rev-parse --short HEAD)$(git log -1 --pretty=%s)"
info " App : https://optical-dev.oliver.solutions/dow-prod-tracker"
info " App : https://optical-dev.oliver.solutions/loreal-prod-tracker"
info " Ports : app=$APP_HOST_PORT db=$DB_HOST_PORT (internal container ports unchanged)"
info " Logs : docker compose -p $COMPOSE_PROJECT logs -f app"

View file

@ -1,4 +1,4 @@
name: dow-prod-tracker
name: loreal-prod-tracker
services:
# ─── PostgreSQL with pgvector ───────────────────────────
@ -8,7 +8,7 @@ services:
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
POSTGRES_DB: dow_prod_tracker
POSTGRES_DB: loreal_prod_tracker
# Host port is overridable via DB_HOST_PORT env var — deploy.sh auto-picks
# a free one if 5492 is taken on the host. The container-internal port
# (5432) never changes — the app connects to db:5432 over the Docker
@ -32,7 +32,7 @@ services:
restart: unless-stopped
# Host port is overridable via APP_HOST_PORT env var — deploy.sh auto-picks
# a free one if 3002 is taken, and writes the chosen port into the Apache
# reverse-proxy config (apache/dow-prod-tracker.conf) at the same time.
# reverse-proxy config (apache/loreal-prod-tracker.conf) at the same time.
ports:
- "${APP_HOST_PORT:-3002}:3000"
environment:
@ -46,7 +46,7 @@ services:
# and stays explicit.
# Postgres side: default max_connections is 100, so 20 × a few
# app replicas is well below the ceiling.
DATABASE_URL: postgresql://postgres:${DB_PASSWORD:-postgres}@db:5432/dow_prod_tracker?schema=public&connection_limit=20&pool_timeout=10
DATABASE_URL: postgresql://postgres:${DB_PASSWORD:-postgres}@db:5432/loreal_prod_tracker?schema=public&connection_limit=20&pool_timeout=10
# Ollama — points to internal GPU server for embeddings + chat fallback
OLLAMA_HOST: ${OLLAMA_HOST:-http://10.24.42.219:11434}
OLLAMA_CHAT_HOST: ${OLLAMA_CHAT_HOST:-http://10.24.42.219:11434}
@ -70,8 +70,18 @@ services:
OMG_WEBHOOK_ALLOW_INSECURE: ${OMG_WEBHOOK_ALLOW_INSECURE:-false}
# Auth: Entra SSO stays coded but gated. Flip to "true" post-MVP once redirect URI is live.
NEXT_PUBLIC_AUTH_ENTRA_ENABLED: ${NEXT_PUBLIC_AUTH_ENTRA_ENABLED:-false}
# Box integration (Phase 4). BOX_CONFIG_FILE points at the mounted
# JSON secret below. The other vars come from .env on the host.
BOX_CONFIG_FILE: ${BOX_CONFIG_FILE:-/run/secrets/box-config.json}
BOX_OUT_FOLDER_ID: ${BOX_OUT_FOLDER_ID:-}
BOX_WATCH_FOLDER_ID: ${BOX_WATCH_FOLDER_ID:-}
BOX_WEBHOOK_PRIMARY_KEY: ${BOX_WEBHOOK_PRIMARY_KEY:-}
BOX_WEBHOOK_SECONDARY_KEY: ${BOX_WEBHOOK_SECONDARY_KEY:-}
BOX_WEBHOOK_ALLOW_INSECURE: ${BOX_WEBHOOK_ALLOW_INSECURE:-false}
volumes:
- uploads_data:/data/uploads
secrets:
- box-config
depends_on:
db:
condition: service_healthy
@ -85,3 +95,11 @@ services:
volumes:
pgdata:
uploads_data:
# Box JWT app config. Drop the JSON downloaded from the Box developer
# console at ./secrets/box-config.json on the host before `docker compose
# up`. Until that file exists, the app starts but Box features are
# disabled (isBoxConfigured() returns false; UI hides "Send to client").
secrets:
box-config:
file: ./secrets/box-config.json

145
docs/EXTERNAL_API.md Normal file
View file

@ -0,0 +1,145 @@
# L'Oréal Studio Tracker — External API (v1)
Server-to-server API for creating projects and deliverables from external
systems (e.g. OMG, brief intake forms, integration scripts).
Base URL: `https://optical-dev.oliver.solutions/loreal-prod-tracker/api/v1`
## Auth
Send the shared API key on every request:
```
x-api-key: <API_KEY>
```
The key matches `process.env.API_KEY` on the server. Requests with a
missing or wrong key get `401 Unauthorized`.
Optionally specify which organization the request applies to:
```
x-org-id: <organization-id>
```
If omitted, requests apply to the first organization in the database
(useful in single-org deployments).
## Idempotency
POST endpoints honour `Idempotency-Key`. A retried request with the same
key + same payload returns the cached response without re-executing:
```
idempotency-key: 7f3c8e2a-...-deterministic-uuid
```
The same key with a *different* payload returns `409 Conflict`. Records
expire after 24 hours.
## Endpoints
### `GET /api/v1/projects`
List all projects visible to the caller. Returns the same shape as the
in-app `/api/projects`.
### `POST /api/v1/projects`
Create a project. If `pipelineTemplateId` is omitted, the org's default
pipeline template is auto-applied. Body matches `createProjectSchema`
(see `src/lib/validators/project.ts`).
Required fields:
- `projectCode` (string, unique per org)
- `name` (string)
Recommended fields:
- `omgJobNumber` (string) — enables Box-folder matching downstream
- `clientTeamId` (string) — drives team visibility
- `requestorUserId` (string) — project owner
- `priority` (`LOW` | `MEDIUM` | `HIGH` | `URGENT`)
- `status` (`PIPELINE` | `ACTIVE` | …)
- `startDate`, `dueDate` (ISO 8601)
Returns `201` + the created project.
```bash
curl -X POST \
-H "x-api-key: $API_KEY" \
-H "idempotency-key: $(uuidgen)" \
-H "content-type: application/json" \
-d '{
"projectCode": "API-001",
"name": "Test integration project",
"omgJobNumber": "12345",
"priority": "MEDIUM"
}' \
https://optical-dev.oliver.solutions/loreal-prod-tracker/api/v1/projects
```
### `GET /api/v1/projects/{projectId}`
Read-back. Same shape as in-app `/api/projects/{id}` — includes
deliverables, stages, attachments, requestor user, client team.
### `POST /api/v1/projects/{projectId}/deliverables`
Create a deliverable on the given project. Pipeline stages auto-applied
from the project's template (`Deliverable.pipelineTemplate` cascades to
`DeliverableStage` rows). Body matches `createDeliverableSchema`.
Required fields:
- `name` (string)
Common fields:
- `priority`, `dueDate`, `notes`, `externalId` (idempotency for upstream
webhook callers — distinct from the `Idempotency-Key` header)
Returns `201` + the created deliverable with its stage chain populated.
```bash
curl -X POST \
-H "x-api-key: $API_KEY" \
-H "idempotency-key: $(uuidgen)" \
-H "content-type: application/json" \
-d '{
"name": "Hero banner",
"priority": "HIGH",
"dueDate": "2026-06-30T12:00:00Z"
}' \
https://optical-dev.oliver.solutions/loreal-prod-tracker/api/v1/projects/$PROJECT_ID/deliverables
```
## Error shapes
```json
{ "error": "Project code \"X\" already exists" }
```
All errors return JSON with an `error` string. Status codes:
- `400` — validation error (zod issues joined into one message)
- `401` — missing/wrong API key
- `404` — project not visible (RBAC) or not found
- `409` — Idempotency-Key reused with a different payload
- `500` — server error (check server logs)
## What's NOT here
- Update/delete endpoints — out of scope for v1. External callers create;
in-app users update.
- Deliverable read-back — `GET /api/v1/projects/{id}` includes
deliverables in its response. A dedicated deliverable read endpoint can
be added if external callers need it.
- Bulk endpoints — call POST per project/deliverable.
## Internal notes
- Auth path: `src/middleware.ts` short-circuits API-key requests before
the session check. `src/lib/api-utils.ts` `getAuthSession()` resolves
the key into a synthetic ADMIN session for the chosen org.
- Idempotency: `src/lib/api/idempotency.ts` + `IdempotencyRecord` prisma
model. 24-hour TTL — sweep via cron (see `scripts/`).
- Default pipeline fallback: `src/lib/services/project-service.ts`
`createProject()` — looks up the org's `PipelineTemplate.isDefault` if
no `pipelineTemplateId` is supplied.

158
docs/RENAME_RUNBOOK.md Normal file
View file

@ -0,0 +1,158 @@
# Rename runbook — dow-prod-tracker → loreal-prod-tracker
This is the one-time server-side migration to swing the URL slug, docker
compose project, database name, and deploy path over to the new L'Oréal
naming. Run it on `optical-dev.oliver.solutions` after the renamed code
has been merged to `main`.
The bitbucket repo name (`zlalani/dow-prod-tracker`) stays for now — that
rename is a separate decision.
## Prerequisites
- All Phase 2 code changes merged to `main` and pushed.
- A shell on `optical-dev.oliver.solutions` with sudo + docker access.
- A maintenance window: ~5 minutes of downtime, expect 1015 for backup +
restore on a non-trivial database.
## Step-by-step
### 1. Backup
```bash
mkdir -p /opt/backups
docker compose -p dow-prod-tracker exec -T db \
pg_dump -U postgres -d dow_prod_tracker \
| gzip > /opt/backups/pre-rename-$(date +%Y%m%d-%H%M).sql.gz
```
Verify the dump is non-empty (`ls -lah` should show a file > 1 MB for any
real data).
### 2. Stop the old stack (preserve volumes)
```bash
cd /opt/dow-prod-tracker
docker compose -p dow-prod-tracker down
# DO NOT pass -v — we need the old volumes intact for rollback.
```
### 3. Rename the deploy directory
```bash
sudo mv /opt/dow-prod-tracker /opt/loreal-prod-tracker
cd /opt/loreal-prod-tracker
```
### 4. Pull the renamed code
```bash
git pull origin main
```
(The repo URL still ends in `dow-prod-tracker.git` — that's fine; the
bitbucket rename is deferred. Only the working copy on disk moves.)
### 5. Bring up the new DB only, with an empty volume
```bash
docker compose -p loreal-prod-tracker up -d db
```
Wait ~5 s for `pg_isready` to pass:
```bash
until docker compose -p loreal-prod-tracker exec -T db \
pg_isready -U postgres -d loreal_prod_tracker; do
sleep 1
done
```
### 6. Restore the dump into the new DB
```bash
gunzip -c /opt/backups/pre-rename-*.sql.gz \
| docker compose -p loreal-prod-tracker exec -T db \
psql -U postgres -d loreal_prod_tracker
```
The dump was taken from the old `dow_prod_tracker` DB but pg_dump emits
explicit `\connect` and `SET search_path` lines that piping into the
new DB above handles cleanly. If you see "ERROR: relation already
exists" lines, the new DB volume wasn't empty — `docker volume rm` the
fresh `loreal-prod-tracker_pgdata` and retry from step 5.
### 7. Start the rest of the stack
```bash
docker compose -p loreal-prod-tracker up -d
```
### 8. Smoke check
Open `https://optical-dev.oliver.solutions/loreal-prod-tracker` in a
browser:
- Login renders with L'Oréal branding (black sidebar, "L'Oréal Studio
Tracker" wordmark).
- A known project loads — its stages, attachments, and notes are intact.
- A recent deliverable shows correct stage state.
- Image attachments serve from `/api/uploads/...`.
If the page doesn't load at all, check `docker compose -p
loreal-prod-tracker logs -f app` for startup errors.
### 9. Add an Apache 301 from the old URL
Append to the same vhost the new tracker lives in:
```apache
RedirectMatch 301 ^/dow-prod-tracker/?(.*)$ /loreal-prod-tracker/$1
```
Reload Apache: `sudo systemctl reload apache2`.
Test: `curl -I https://optical-dev.oliver.solutions/dow-prod-tracker/dashboard`
should return `301` with a `Location:` pointing at
`/loreal-prod-tracker/dashboard`.
### 10. Update cron entries
Anywhere we have cron pointing at the old path (`/opt/dow-prod-tracker`)
needs to swap to `/opt/loreal-prod-tracker`. Common ones:
```bash
sudo crontab -l | sed 's|/opt/dow-prod-tracker|/opt/loreal-prod-tracker|g' \
| sudo crontab -
```
Verify with `sudo crontab -l`. Notable jobs:
- Nightly DB backup (`scripts/backup-db.sh`).
- Any cron jobs hitting the in-app deadline notifier endpoint.
### 11. Cleanup (after a 1-week soak)
Once nothing's complained for a week, drop the old volumes:
```bash
docker volume rm dow-prod-tracker_pgdata dow-prod-tracker_uploads_data
```
You can also drop `/opt/backups/pre-rename-*.sql.gz` if disk pressure
matters — though keeping it indefinitely is cheap insurance.
## Rollback
If anything goes wrong in steps 58, the old volumes are still present
under the `dow-prod-tracker` project name. To roll back:
```bash
docker compose -p loreal-prod-tracker down
sudo mv /opt/loreal-prod-tracker /opt/dow-prod-tracker
cd /opt/dow-prod-tracker
git checkout <previous-sha> # the SHA before the Phase 2 merge
docker compose -p dow-prod-tracker up -d
```
The old data is unchanged because step 1 only read from the DB and
step 2 didn't pass `-v`.

View file

@ -1,6 +1,6 @@
import type { NextConfig } from "next";
const basePath = "/dow-prod-tracker";
const basePath = "/loreal-prod-tracker";
const nextConfig: NextConfig = {
output: "standalone",

View file

@ -1,5 +1,5 @@
{
"name": "dow-prod-tracker",
"name": "loreal-prod-tracker",
"version": "0.1.0",
"private": true,
"scripts": {

View file

@ -0,0 +1,134 @@
-- Restore formal review workflow (FeedbackItem + ReviewSession + ReviewSessionItem)
-- and add ApprovalType column on pipeline stage definitions and templates.
-- CreateEnum
CREATE TYPE "ApprovalType" AS ENUM ('NONE', 'SIMPLE', 'FORMAL');
-- CreateEnum
CREATE TYPE "FeedbackStatus" AS ENUM ('OPEN', 'IN_PROGRESS', 'RESOLVED', 'VERIFIED', 'REOPENED');
-- CreateEnum
CREATE TYPE "ReviewSessionStatus" AS ENUM ('DRAFT', 'IN_PROGRESS', 'COMPLETED');
-- AlterTable
ALTER TABLE "pipeline_stage_templates" ADD COLUMN "approvalType" "ApprovalType" NOT NULL DEFAULT 'NONE';
-- AlterTable
ALTER TABLE "pipeline_stage_definitions" ADD COLUMN "approvalType" "ApprovalType" NOT NULL DEFAULT 'NONE';
-- CreateTable
CREATE TABLE "feedback_items" (
"id" TEXT NOT NULL,
"deliverableStageId" TEXT NOT NULL,
"revisionId" TEXT NOT NULL,
"annotationId" TEXT,
"commentId" TEXT,
"summary" TEXT NOT NULL,
"isActionItem" BOOLEAN NOT NULL DEFAULT true,
"status" "FeedbackStatus" NOT NULL DEFAULT 'OPEN',
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"assignedToId" TEXT,
"createdById" TEXT NOT NULL,
"resolvedById" TEXT,
"resolvedAt" TIMESTAMP(3),
"resolutionNote" TEXT,
"verifiedById" TEXT,
"verifiedAt" TIMESTAMP(3),
"carriedFromId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "feedback_items_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "review_sessions" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"status" "ReviewSessionStatus" NOT NULL DEFAULT 'DRAFT',
"createdById" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "review_sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "review_session_items" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"deliverableStageId" TEXT NOT NULL,
"revisionId" TEXT,
"sortOrder" INTEGER NOT NULL,
"decision" TEXT,
"decisionNote" TEXT,
"decidedById" TEXT,
"decidedAt" TIMESTAMP(3),
CONSTRAINT "review_session_items_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "feedback_items_deliverableStageId_idx" ON "feedback_items"("deliverableStageId");
-- CreateIndex
CREATE INDEX "feedback_items_revisionId_idx" ON "feedback_items"("revisionId");
-- CreateIndex
CREATE INDEX "feedback_items_assignedToId_idx" ON "feedback_items"("assignedToId");
-- CreateIndex
CREATE INDEX "feedback_items_status_idx" ON "feedback_items"("status");
-- CreateIndex
CREATE INDEX "review_sessions_organizationId_idx" ON "review_sessions"("organizationId");
-- CreateIndex
CREATE INDEX "review_sessions_status_idx" ON "review_sessions"("status");
-- CreateIndex
CREATE INDEX "review_session_items_sessionId_idx" ON "review_session_items"("sessionId");
-- AddForeignKey
ALTER TABLE "feedback_items" ADD CONSTRAINT "feedback_items_deliverableStageId_fkey" FOREIGN KEY ("deliverableStageId") REFERENCES "deliverable_stages"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "feedback_items" ADD CONSTRAINT "feedback_items_revisionId_fkey" FOREIGN KEY ("revisionId") REFERENCES "revisions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "feedback_items" ADD CONSTRAINT "feedback_items_annotationId_fkey" FOREIGN KEY ("annotationId") REFERENCES "annotations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "feedback_items" ADD CONSTRAINT "feedback_items_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "comments"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "feedback_items" ADD CONSTRAINT "feedback_items_assignedToId_fkey" FOREIGN KEY ("assignedToId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "feedback_items" ADD CONSTRAINT "feedback_items_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "feedback_items" ADD CONSTRAINT "feedback_items_resolvedById_fkey" FOREIGN KEY ("resolvedById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "feedback_items" ADD CONSTRAINT "feedback_items_verifiedById_fkey" FOREIGN KEY ("verifiedById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "feedback_items" ADD CONSTRAINT "feedback_items_carriedFromId_fkey" FOREIGN KEY ("carriedFromId") REFERENCES "feedback_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "review_sessions" ADD CONSTRAINT "review_sessions_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "review_session_items" ADD CONSTRAINT "review_session_items_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "review_sessions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "review_session_items" ADD CONSTRAINT "review_session_items_deliverableStageId_fkey" FOREIGN KEY ("deliverableStageId") REFERENCES "deliverable_stages"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "review_session_items" ADD CONSTRAINT "review_session_items_revisionId_fkey" FOREIGN KEY ("revisionId") REFERENCES "revisions"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "review_session_items" ADD CONSTRAINT "review_session_items_decidedById_fkey" FOREIGN KEY ("decidedById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -0,0 +1,17 @@
-- External API idempotency record store.
-- Composite primary key (key, route) lets the same Idempotency-Key be reused
-- across different routes without conflict. createdAt is indexed so the
-- 24-hour TTL sweep can range-scan and DELETE in O(log n).
CREATE TABLE "idempotency_records" (
"key" TEXT NOT NULL,
"route" TEXT NOT NULL,
"requestHash" TEXT NOT NULL,
"responseBody" JSONB NOT NULL,
"statusCode" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "idempotency_records_pkey" PRIMARY KEY ("key", "route")
);
CREATE INDEX "idempotency_records_createdAt_idx" ON "idempotency_records"("createdAt");

View file

@ -0,0 +1,65 @@
-- Box bidirectional integration: schema additions for outbound push logs,
-- inbound webhook logs, optional matcher override on Deliverable, and the
-- Revision.boxFolderId traceability column. Also extends NotificationType.
-- AlterEnum
ALTER TYPE "NotificationType" ADD VALUE 'BOX_UNMATCHED_FILE';
ALTER TYPE "NotificationType" ADD VALUE 'NEW_FILE_AWAITING_REVIEWER';
-- CreateEnum
CREATE TYPE "BoxPushStatus" AS ENUM ('PENDING', 'SUCCESS', 'FAILED');
-- CreateEnum
CREATE TYPE "BoxInboundStatus" AS ENUM ('MATCHED', 'UNMATCHED', 'ERROR');
-- AlterTable
ALTER TABLE "revisions" ADD COLUMN "boxFolderId" TEXT;
-- AlterTable
ALTER TABLE "deliverables" ADD COLUMN "boxAliasSlug" TEXT;
-- CreateTable
CREATE TABLE "box_push_logs" (
"id" TEXT NOT NULL,
"deliverableId" TEXT NOT NULL,
"revisionId" TEXT,
"boxFolderId" TEXT,
"status" "BoxPushStatus" NOT NULL DEFAULT 'PENDING',
"attempt" INTEGER NOT NULL DEFAULT 1,
"error" TEXT,
"sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "box_push_logs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "box_inbound_logs" (
"id" TEXT NOT NULL,
"boxFileId" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"matchedDeliverableId" TEXT,
"matchedProjectId" TEXT,
"status" "BoxInboundStatus" NOT NULL,
"error" TEXT,
"receivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "box_inbound_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "box_push_logs_deliverableId_idx" ON "box_push_logs"("deliverableId");
-- CreateIndex
CREATE INDEX "box_push_logs_status_idx" ON "box_push_logs"("status");
-- CreateIndex
CREATE INDEX "box_inbound_logs_status_idx" ON "box_inbound_logs"("status");
-- CreateIndex
CREATE INDEX "box_inbound_logs_receivedAt_idx" ON "box_inbound_logs"("receivedAt");
-- AddForeignKey
ALTER TABLE "box_push_logs" ADD CONSTRAINT "box_push_logs_deliverableId_fkey" FOREIGN KEY ("deliverableId") REFERENCES "deliverables"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "box_push_logs" ADD CONSTRAINT "box_push_logs_revisionId_fkey" FOREIGN KEY ("revisionId") REFERENCES "revisions"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -26,11 +26,11 @@ enum ProjectStatus {
}
enum BriefStatus {
PENDING // just arrived
REVIEWING // producer is triaging
ACCEPTED // approved but not yet a project
REJECTED // declined
CONVERTED // promoted to a Project (see convertedProjectId)
PENDING // just arrived
REVIEWING // producer is triaging
ACCEPTED // approved but not yet a project
REJECTED // declined
CONVERTED // promoted to a Project (see convertedProjectId)
ARCHIVED
}
@ -76,6 +76,20 @@ enum NotificationType {
DEADLINE_APPROACHING
DEADLINE_OVERDUE
STAGE_UNBLOCKED
BOX_UNMATCHED_FILE
NEW_FILE_AWAITING_REVIEWER
}
enum BoxPushStatus {
PENDING
SUCCESS
FAILED
}
enum BoxInboundStatus {
MATCHED
UNMATCHED
ERROR
}
enum AssignmentRole {
@ -160,23 +174,30 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
sessions Session[]
assignments StageAssignment[]
comments Comment[]
notifications Notification[]
skills UserSkill[]
searchLogs SearchLog[]
automationRules AutomationRule[] @relation("AutomationCreator")
chatMessages ChatMessage[]
invitationsSent Invitation[] @relation("InvitedBy")
annotations Annotation[]
clientTeams ClientTeamMembership[]
podsLed Pod[] @relation("PodLead")
bookings ResourceBooking[] @relation("BookingResource")
bookingsCreated ResourceBooking[] @relation("BookingCreator")
accounts Account[]
sessions Session[]
assignments StageAssignment[]
comments Comment[]
notifications Notification[]
skills UserSkill[]
searchLogs SearchLog[]
automationRules AutomationRule[] @relation("AutomationCreator")
chatMessages ChatMessage[]
invitationsSent Invitation[] @relation("InvitedBy")
annotations Annotation[]
clientTeams ClientTeamMembership[]
podsLed Pod[] @relation("PodLead")
bookings ResourceBooking[] @relation("BookingResource")
bookingsCreated ResourceBooking[] @relation("BookingCreator")
attachmentsCreated DeliverableAttachment[]
projectsOwned Project[] @relation("ProjectOwner")
projectsOwned Project[] @relation("ProjectOwner")
feedbackCreated FeedbackItem[] @relation("FeedbackCreator")
feedbackAssigned FeedbackItem[] @relation("FeedbackAssignee")
feedbackResolved FeedbackItem[] @relation("FeedbackResolver")
feedbackVerified FeedbackItem[] @relation("FeedbackVerifier")
reviewSessionsCreated ReviewSession[] @relation("ReviewSessionCreator")
reviewSessionDecisions ReviewSessionItem[] @relation("ReviewSessionDecider")
@@index([homePodId])
@@index([isExternal])
@ -226,14 +247,15 @@ model VerificationToken {
// ─── Pipeline Templates (seed data) ────────────────────
model PipelineStageTemplate {
id String @id @default(cuid())
name String @unique
slug String @unique
order Int @unique
isCriticalGate Boolean @default(false)
isOptional Boolean @default(false)
id String @id @default(cuid())
name String @unique
slug String @unique
order Int @unique
isCriticalGate Boolean @default(false)
isOptional Boolean @default(false)
description String?
estimatedDays Float?
approvalType ApprovalType @default(NONE)
dependsOn PipelineStageDependency[] @relation("DependsOnStage")
dependedBy PipelineStageDependency[] @relation("PrerequisiteStage")
@ -291,6 +313,7 @@ model PipelineStageDefinition {
estimatedDays Float?
color String?
customStatuses Json?
approvalType ApprovalType @default(NONE)
dependsOn PipelineStageDependencyV2[] @relation("DependsOnStageV2")
dependedBy PipelineStageDependencyV2[] @relation("PrerequisiteStageV2")
@ -343,31 +366,31 @@ model PipelineStageRework {
// ─── Project ────────────────────────────────────────────
model Project {
id String @id @default(cuid())
projectCode String @unique
name String
description String?
status ProjectStatus @default(ACTIVE)
priority Priority @default(MEDIUM)
startDate DateTime?
dueDate DateTime?
businessUnit String?
formFactor String?
codeName String?
npiOrRefresh String?
quarter String?
id String @id @default(cuid())
projectCode String @unique
name String
description String?
status ProjectStatus @default(ACTIVE)
priority Priority @default(MEDIUM)
startDate DateTime?
dueDate DateTime?
businessUnit String?
formFactor String?
codeName String?
npiOrRefresh String?
quarter String?
// Freeform owner name from XLSX/webhook intake. Used as a fallback
// label when no matching user exists on the system (e.g. the upstream
// Owner column contains a client-side name). Once we can link it to
// a real user, populate requestorUserId and display that instead.
requestor String?
requestor String?
requestorUserId String?
workfrontId String?
omgCode String?
bmtId String?
estimatedCost Float?
actualCost Float?
agency String?
workfrontId String?
omgCode String?
bmtId String?
estimatedCost Float?
actualCost Float?
agency String?
// Dow-specific: upstream OMG job number (canonical key for XLSX + webhook ingest)
omgJobNumber String? @unique
@ -390,8 +413,8 @@ model Project {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deliverables Deliverable[]
convertedFrom Brief[] @relation("BriefConvertedProject")
deliverables Deliverable[]
convertedFrom Brief[] @relation("BriefConvertedProject")
@@index([organizationId])
@@index([clientTeamId])
@ -430,6 +453,12 @@ model Deliverable {
stages DeliverableStage[]
attachments DeliverableAttachment[]
boxPushLogs BoxPushLog[]
// Optional override for the Box matcher slug. Producers can pin an alias
// if the auto-slugified deliverable name doesn't match what arrives in
// the Box watch folder. Default null = use slugify(name).
boxAliasSlug String?
@@index([projectId])
@@index([organizationId])
@ -450,20 +479,20 @@ model DeliverableAttachment {
deliverableId String
deliverable Deliverable @relation(fields: [deliverableId], references: [id], onDelete: Cascade)
kind String // "file" | "link"
title String
url String // local /api/uploads/... or external https://...
kind String // "file" | "link"
title String
url String // local /api/uploads/... or external https://...
mimeType String?
fileSize Int?
thumbnailUrl String?
mimeType String?
fileSize Int?
thumbnailUrl String?
stageDefinitionId String?
stageDefinition PipelineStageDefinition? @relation(fields: [stageDefinitionId], references: [id])
createdById String
createdBy User @relation(fields: [createdById], references: [id])
createdAt DateTime @default(now())
createdById String
createdBy User @relation(fields: [createdById], references: [id])
createdAt DateTime @default(now())
@@index([deliverableId])
@@index([stageDefinitionId])
@ -500,9 +529,11 @@ model DeliverableStage {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
assignments StageAssignment[]
revisions Revision[]
comments Comment[]
assignments StageAssignment[]
revisions Revision[]
comments Comment[]
feedbackItems FeedbackItem[]
reviewSessionItems ReviewSessionItem[]
@@unique([deliverableId, stageDefinitionId])
@@index([deliverableId])
@ -547,7 +578,14 @@ model Revision {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
annotations Annotation[]
annotations Annotation[]
feedbackItems FeedbackItem[]
reviewSessionItems ReviewSessionItem[]
boxPushLogs BoxPushLog[]
// Set when this revision's assets have been pushed to Box (outbound).
// Format: the Box folder id under which the assets were uploaded.
boxFolderId String?
@@index([deliverableStageId])
@@map("revisions")
@ -569,7 +607,8 @@ model Comment {
parent Comment? @relation("CommentThread", fields: [parentId], references: [id])
replies Comment[] @relation("CommentThread")
annotations Annotation[]
annotations Annotation[]
feedbackItems FeedbackItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -835,12 +874,179 @@ model Annotation {
createdBy User @relation(fields: [createdById], references: [id])
createdAt DateTime @default(now())
feedbackItems FeedbackItem[]
@@index([commentId])
@@index([revisionId])
@@index([revisionId, timestampSeconds])
@@map("annotations")
}
// ─── Approval Type (per pipeline stage) ─────────────────
enum ApprovalType {
NONE
SIMPLE
FORMAL
}
// ─── Feedback Items ────────────────────────────────────
enum FeedbackStatus {
OPEN
IN_PROGRESS
RESOLVED
VERIFIED
REOPENED
}
model FeedbackItem {
id String @id @default(cuid())
deliverableStageId String
deliverableStage DeliverableStage @relation(fields: [deliverableStageId], references: [id], onDelete: Cascade)
revisionId String
revision Revision @relation(fields: [revisionId], references: [id], onDelete: Cascade)
annotationId String?
annotation Annotation? @relation(fields: [annotationId], references: [id], onDelete: SetNull)
commentId String?
comment Comment? @relation(fields: [commentId], references: [id], onDelete: SetNull)
summary String
isActionItem Boolean @default(true)
status FeedbackStatus @default(OPEN)
sortOrder Int @default(0)
assignedToId String?
assignedTo User? @relation("FeedbackAssignee", fields: [assignedToId], references: [id], onDelete: SetNull)
createdById String
createdBy User @relation("FeedbackCreator", fields: [createdById], references: [id])
resolvedById String?
resolvedBy User? @relation("FeedbackResolver", fields: [resolvedById], references: [id], onDelete: SetNull)
resolvedAt DateTime?
resolutionNote String?
verifiedById String?
verifiedBy User? @relation("FeedbackVerifier", fields: [verifiedById], references: [id], onDelete: SetNull)
verifiedAt DateTime?
carriedFromId String?
carriedFrom FeedbackItem? @relation("FeedbackCarry", fields: [carriedFromId], references: [id], onDelete: SetNull)
carriedTo FeedbackItem[] @relation("FeedbackCarry")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([deliverableStageId])
@@index([revisionId])
@@index([assignedToId])
@@index([status])
@@map("feedback_items")
}
// ─── Review Sessions (batch approval) ───────────────────
enum ReviewSessionStatus {
DRAFT
IN_PROGRESS
COMPLETED
}
model ReviewSession {
id String @id @default(cuid())
name String
description String?
status ReviewSessionStatus @default(DRAFT)
createdById String
createdBy User @relation("ReviewSessionCreator", fields: [createdById], references: [id])
organizationId String
items ReviewSessionItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId])
@@index([status])
@@map("review_sessions")
}
model ReviewSessionItem {
id String @id @default(cuid())
sessionId String
session ReviewSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
deliverableStageId String
deliverableStage DeliverableStage @relation(fields: [deliverableStageId], references: [id])
revisionId String?
revision Revision? @relation(fields: [revisionId], references: [id])
sortOrder Int
decision String?
decisionNote String?
decidedById String?
decidedBy User? @relation("ReviewSessionDecider", fields: [decidedById], references: [id])
decidedAt DateTime?
@@index([sessionId])
@@map("review_session_items")
}
// ─── External API idempotency ──────────────────────────
//
// Stores the response body of POSTs that carried an `Idempotency-Key`
// header so a retried request returns the same response without
// re-executing. Indexed for fast lookup; createdAt drives the 24-hour
// TTL sweep.
model IdempotencyRecord {
key String
route String
requestHash String
responseBody Json
statusCode Int
createdAt DateTime @default(now())
@@id([key, route])
@@index([createdAt])
@@map("idempotency_records")
}
// ─── Box integration (bidirectional asset transport) ────
//
// Outbound: when a deliverable hits APPROVED, push its latest revision's
// assets into a Box "In/" sub-folder using a strict naming convention
// (omgJobNumber_deliverableSlug_v{round}). Inbound: Box notifies us when
// a new file lands in the watch folder; we match by OMG # + slug and
// attach as a new revision.
//
// Both directions log every attempt. See [[project-box-integration]].
model BoxPushLog {
id String @id @default(cuid())
deliverableId String
deliverable Deliverable @relation(fields: [deliverableId], references: [id], onDelete: Cascade)
revisionId String?
revision Revision? @relation(fields: [revisionId], references: [id], onDelete: SetNull)
boxFolderId String?
status BoxPushStatus @default(PENDING)
attempt Int @default(1)
error String?
sentAt DateTime @default(now())
@@index([deliverableId])
@@index([status])
@@map("box_push_logs")
}
model BoxInboundLog {
id String @id @default(cuid())
boxFileId String
fileName String
matchedDeliverableId String?
matchedProjectId String?
status BoxInboundStatus
error String?
receivedAt DateTime @default(now())
@@index([status])
@@index([receivedAt])
@@map("box_inbound_logs")
}
// ─── Dow: Client Teams (visibility grouping) ───────────
model ClientTeam {
@ -891,40 +1097,40 @@ model Brief {
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
title String
description String?
title String
description String?
// Source tracking — "manual" | "api" | "webhook:<system>" so we can
// report on where briefs come from without needing a separate enum.
source String @default("manual")
source String @default("manual")
// External idempotency key (from webhook/API). Unique per org so the
// same upstream system can't create duplicates on replay.
externalId String?
externalId String?
// Requestor info — client-side contact. Freeform because intake
// systems can't be relied on to have user rows.
requestorName String?
requestorEmail String?
status BriefStatus @default(PENDING)
priority Priority @default(MEDIUM)
status BriefStatus @default(PENDING)
priority Priority @default(MEDIUM)
requestedDueDate DateTime?
clientTeamId String?
clientTeam ClientTeam? @relation(fields: [clientTeamId], references: [id])
clientTeamId String?
clientTeam ClientTeam? @relation(fields: [clientTeamId], references: [id])
// When promoted to a Project, fill in convertedProjectId (CONVERTED status).
convertedProjectId String?
convertedProject Project? @relation("BriefConvertedProject", fields: [convertedProjectId], references: [id])
// Pass-through field for anything in the incoming payload we didn't map.
rawPayload Json?
rawPayload Json?
receivedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
receivedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([organizationId, externalId])
@@index([organizationId])

View file

@ -1,5 +1,5 @@
#!/usr/bin/env bash
# scripts/backup-db.sh — nightly Postgres dump for dow-prod-tracker
# scripts/backup-db.sh — nightly Postgres dump for loreal-prod-tracker
#
# Runs on the HOST (not inside the app container), driving pg_dump
# through the db service's compose project. Writes a gzipped SQL
@ -9,26 +9,26 @@
# crontab entry. Safe to run manually at any time.
#
# Env vars (all optional, sensible defaults for optical-dev):
# BACKUP_DIR where to write dumps (default /srv/backups/dow-prod-tracker)
# BACKUP_DIR where to write dumps (default /srv/backups/loreal-prod-tracker)
# RETAIN_DAYS days to keep dumps (default 30)
# COMPOSE_PROJECT compose project name (default dow-prod-tracker)
# COMPOSE_DIR dir containing docker-compose.yml (default /opt/dow-prod-tracker)
# PGDATABASE database name (default dow_prod_tracker)
# COMPOSE_PROJECT compose project name (default loreal-prod-tracker)
# COMPOSE_DIR dir containing docker-compose.yml (default /opt/loreal-prod-tracker)
# PGDATABASE database name (default loreal_prod_tracker)
# PGUSER postgres user (default postgres)
#
# Exit codes: 0 ok, non-zero = failure (cron will mail root on non-zero).
set -euo pipefail
BACKUP_DIR="${BACKUP_DIR:-/srv/backups/dow-prod-tracker}"
BACKUP_DIR="${BACKUP_DIR:-/srv/backups/loreal-prod-tracker}"
RETAIN_DAYS="${RETAIN_DAYS:-30}"
COMPOSE_PROJECT="${COMPOSE_PROJECT:-dow-prod-tracker}"
COMPOSE_DIR="${COMPOSE_DIR:-/opt/dow-prod-tracker}"
PGDATABASE="${PGDATABASE:-dow_prod_tracker}"
COMPOSE_PROJECT="${COMPOSE_PROJECT:-loreal-prod-tracker}"
COMPOSE_DIR="${COMPOSE_DIR:-/opt/loreal-prod-tracker}"
PGDATABASE="${PGDATABASE:-loreal_prod_tracker}"
PGUSER="${PGUSER:-postgres}"
TIMESTAMP=$(date +%Y-%m-%d_%H%M%S)
OUT_FILE="${BACKUP_DIR}/dow-prod-tracker_${TIMESTAMP}.sql.gz"
OUT_FILE="${BACKUP_DIR}/loreal-prod-tracker_${TIMESTAMP}.sql.gz"
mkdir -p "$BACKUP_DIR"
@ -50,7 +50,7 @@ echo "[backup] wrote $OUT_FILE ($SIZE)"
# Prune anything older than RETAIN_DAYS. `-mtime +N` is "modified more
# than N days ago" — older dumps get deleted. Fails gracefully if the
# dir is empty.
find "$BACKUP_DIR" -maxdepth 1 -type f -name 'dow-prod-tracker_*.sql.gz' \
find "$BACKUP_DIR" -maxdepth 1 -type f -name 'loreal-prod-tracker_*.sql.gz' \
-mtime +"$RETAIN_DAYS" -print -delete || true
echo "[backup] $(date -Iseconds) done"

View file

@ -2,7 +2,7 @@ import { CalendarView } from "@/components/calendar/calendar-view";
import { Suspense } from "react";
export const metadata = {
title: "Due Date Calendar | Dow Jones Studio Tracker",
title: "Due Date Calendar | L'Oréal Studio Tracker",
};
export default function CalendarPage() {

View file

@ -30,6 +30,7 @@ import { DeliverableFormDialog } from "@/components/deliverables/deliverable-for
import { StageDatePopover } from "@/components/stages/stage-date-popover";
import { StageNotes } from "@/components/stages/stage-notes";
import { StageAttachmentIndicator } from "@/components/stages/stage-attachment-indicator";
import { StageReviewPanel } from "@/components/stages/stage-review-panel";
import { PipelineProgress } from "@/components/deliverables/pipeline-progress";
import { AttachmentsPanel } from "@/components/deliverables/attachments-panel";
import { StageStatusBadge } from "@/components/stages/stage-status-badge";
@ -478,6 +479,23 @@ export default function DeliverableDetailPage() {
"needs legal review"). Always editable; empty state
is a compact "+ Add note" affordance. */}
<StageNotes stageId={stage.id} notes={stage.notes ?? null} />
{/* Per-stage review surface gated by approvalType on the
pipeline stage definition. NONE renders nothing; SIMPLE
adds approve/changes + new-version; FORMAL adds the
feedback checklist + progress bar + link to review page. */}
<StageReviewPanel
stageId={stage.id}
approvalType={
(stage.stageDefinition?.approvalType ??
stage.template?.approvalType ??
"NONE") as "NONE" | "SIMPLE" | "FORMAL"
}
stageStatus={stage.status}
projectId={projectId}
deliverableId={deliverableId}
canEdit={canEditAttachments}
/>
</div>
);
})}

View file

@ -0,0 +1,841 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams, useSearchParams } from "next/navigation";
import Link from "next/link";
import {
ArrowLeft,
ChevronLeft,
ChevronRight,
ChevronDown,
ChevronUp,
Upload,
Columns2,
Loader2,
Images,
Film,
ImageIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { StageStatusBadge } from "@/components/stages/stage-status-badge";
import { ImageViewer, type ImageViewerState } from "@/components/review/image-viewer";
import { ComparisonViewer } from "@/components/review/comparison-viewer";
import {
ComparisonToolbar,
type ComparisonMode,
} from "@/components/review/comparison-toolbar";
import { ImageUploadZone } from "@/components/review/image-upload-zone";
import { VideoUploadZone } from "@/components/review/video-upload-zone";
import { ImageGallery } from "@/components/review/image-gallery";
import { AnnotationLayer } from "@/components/review/annotation-layer";
import { VideoPlayer } from "@/components/review/video-player";
import { VideoAnnotationLayer } from "@/components/review/video-annotation-layer";
import { useVideoTimelineMarkers } from "@/components/review/video-timeline-markers";
import { ReviewSidebar } from "@/components/review/review-sidebar";
import { useDeliverable } from "@/hooks/use-deliverables";
import { useRevisions, useCreateRevision } from "@/hooks/use-revisions";
import { useAnnotations, useDeleteAnnotation } from "@/hooks/use-annotations";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
interface AttachedImage {
url: string;
filename: string;
size: number;
width: number;
height: number;
uploadedAt: string;
originalUrl?: string;
}
interface AttachedVideo {
url: string;
hlsUrl: string | null;
status: "processing" | "ready" | "failed";
thumbnailUrl: string | null;
filename: string;
size: number;
duration: number;
fps: number;
width: number;
height: number;
}
interface RevisionAttachments {
referenceImage?: AttachedImage;
currentImage?: AttachedImage;
video?: AttachedVideo;
referenceVideo?: AttachedVideo;
}
interface RevisionImage {
revisionId: string;
roundNumber: number;
type: "reference" | "current";
url: string;
thumbnailUrl: string;
filename: string;
}
export default function ReviewPage() {
const { projectId, deliverableId } = useParams<{
projectId: string;
deliverableId: string;
}>();
const searchParams = useSearchParams();
const initialStageId = searchParams.get("stage");
const queryClient = useQueryClient();
const { data: deliverableData, isLoading: delLoading } = useDeliverable(
projectId,
deliverableId
);
const deliverable = deliverableData as any;
// Stage selection — default to first non-blocked stage
const [selectedStageId, setSelectedStageId] = useState<string | null>(null);
const [uploadPanelOpen, setUploadPanelOpen] = useState(false);
const [activeImageUrl, setActiveImageUrl] = useState<string | null>(null);
// ── Viewer mode: image or video ─────────────────────────────────────
const [viewerMode, setViewerMode] = useState<"image" | "video">("image");
// ── Gallery strip state ──────────────────────────────────────────────
const [galleryOpen, setGalleryOpen] = useState(true);
// ── Annotation hover highlight (from feedback sidebar) ──────────────
const [hoveredAnnotationId, setHoveredAnnotationId] = useState<string | null>(null);
// ── Video player refs (for sidebar → player communication) ──────────
const videoSeekRef = useRef<((time: number) => void) | null>(null);
const videoDurationRef = useRef(0);
const [videoDuration, setVideoDuration] = useState(0);
// Sync video duration from render-time ref to state (avoids setState-during-render)
useEffect(() => {
const interval = setInterval(() => {
if (videoDurationRef.current > 0 && videoDurationRef.current !== videoDuration) {
setVideoDuration(videoDurationRef.current);
}
}, 500);
return () => clearInterval(interval);
}, [videoDuration]);
const handleAnnotationClick = useCallback(
(annotation: { id: string; imageX: number; imageY: number; timestampSeconds?: number | null }) => {
if (annotation.timestampSeconds != null && videoSeekRef.current) {
// Switch to video mode if not already
setViewerMode("video");
// Seek the video to the annotation's timestamp
videoSeekRef.current(annotation.timestampSeconds);
}
},
[]
);
// ── Comparison mode state ────────────────────────────────────────────
const [comparisonActive, setComparisonActive] = useState(false);
const [comparisonMode, setComparisonMode] =
useState<ComparisonMode>("side-by-side");
const [leftRevisionKey, setLeftRevisionKey] = useState<string>("");
const [rightRevisionKey, setRightRevisionKey] = useState<string>("");
const [flipA, setFlipA] = useState(false);
const [flipB, setFlipB] = useState(false);
const stages = useMemo(() => {
if (!deliverable?.stages) return [];
return [...deliverable.stages].sort(
(a: any, b: any) => (a.stageDefinition?.order ?? a.template.order) - (b.stageDefinition?.order ?? b.template.order)
);
}, [deliverable]);
// Auto-select stage from URL param, or fall back to first non-blocked stage
useEffect(() => {
if (stages.length > 0 && !selectedStageId) {
const fromParam = initialStageId
? stages.find((s: any) => s.id === initialStageId)
: null;
const first = fromParam ??
stages.find((s: any) => s.status !== "BLOCKED") ?? stages[0];
setSelectedStageId(first.id);
}
}, [stages, selectedStageId, initialStageId]);
const selectedStage = stages.find((s: any) => s.id === selectedStageId);
const stageIdx = stages.findIndex((s: any) => s.id === selectedStageId);
// Load revisions for selected stage
const { data: revisionsData } = useRevisions(selectedStageId ?? "");
const revisions = (revisionsData as any[]) ?? [];
// Poll revisions when any video is still "processing"
const hasProcessingVideo = useMemo(() => {
return revisions.some((r: any) => {
const att = r.attachments as RevisionAttachments | null;
return att?.video?.status === "processing" || att?.referenceVideo?.status === "processing";
});
}, [revisions]);
useEffect(() => {
if (!hasProcessingVideo || !selectedStageId) return;
const interval = setInterval(() => {
queryClient.invalidateQueries({ queryKey: ["revisions", selectedStageId] });
}, 3000);
return () => clearInterval(interval);
}, [hasProcessingVideo, selectedStageId, queryClient]);
// Create revision mutation
const createRevision = useCreateRevision(selectedStageId ?? "");
const handleCreateRevision = useCallback(async () => {
try {
await createRevision.mutateAsync({});
toast.success("Round created — you can now upload images");
setUploadPanelOpen(true);
} catch {
toast.error("Failed to create revision");
}
}, [createRevision]);
// Build gallery from all revisions
const galleryImages: RevisionImage[] = useMemo(() => {
const images: RevisionImage[] = [];
for (const rev of revisions) {
const attachments = rev.attachments as RevisionAttachments | null;
if (!attachments) continue;
if (attachments.referenceImage) {
const thumbUrl = attachments.referenceImage.url.replace(
/\.(png|jpg|jpeg|webp)$/i,
"_thumb.jpg"
);
images.push({
revisionId: rev.id,
roundNumber: rev.roundNumber,
type: "reference",
url: attachments.referenceImage.url,
thumbnailUrl: thumbUrl,
filename: attachments.referenceImage.filename,
});
}
if (attachments.currentImage) {
const thumbUrl = attachments.currentImage.url.replace(
/\.(png|jpg|jpeg|webp)$/i,
"_thumb.jpg"
);
images.push({
revisionId: rev.id,
roundNumber: rev.roundNumber,
type: "current",
url: attachments.currentImage.url,
thumbnailUrl: thumbUrl,
filename: attachments.currentImage.filename,
});
}
}
return images;
}, [revisions]);
// Build revision options for comparison dropdowns
const revisionOptions = useMemo(() => {
return galleryImages.map((img) => ({
revisionId: img.revisionId,
roundNumber: img.roundNumber,
type: img.type,
label: `R${img.roundNumber}${img.type === "reference" ? "Reference" : "Render"}`,
url: img.url,
}));
}, [galleryImages]);
// Auto-select comparison revisions: previous round vs current
useEffect(() => {
if (!comparisonActive || revisionOptions.length < 2) return;
if (leftRevisionKey && rightRevisionKey) return;
// Default: second-to-last as A, latest as B
const latest = revisionOptions[revisionOptions.length - 1];
const previous =
revisionOptions.length >= 2
? revisionOptions[revisionOptions.length - 2]
: latest;
setLeftRevisionKey(`${previous.revisionId}-${previous.type}`);
setRightRevisionKey(`${latest.revisionId}-${latest.type}`);
}, [comparisonActive, revisionOptions, leftRevisionKey, rightRevisionKey]);
// Resolve selected revision keys to URLs
const leftSrc = useMemo(() => {
const opt = revisionOptions.find(
(o) => `${o.revisionId}-${o.type}` === leftRevisionKey
);
return opt?.url ?? null;
}, [revisionOptions, leftRevisionKey]);
const rightSrc = useMemo(() => {
const opt = revisionOptions.find(
(o) => `${o.revisionId}-${o.type}` === rightRevisionKey
);
return opt?.url ?? null;
}, [revisionOptions, rightRevisionKey]);
// Auto-select the latest current image
useEffect(() => {
if (!activeImageUrl && galleryImages.length > 0) {
const latestCurrent = galleryImages.find((i) => i.type === "current");
setActiveImageUrl(latestCurrent?.url ?? galleryImages[0].url);
}
}, [galleryImages, activeImageUrl]);
// Find the revision ID for the currently active content (image or video)
const activeRevisionId = useMemo(() => {
// First try matching from image gallery
const match = galleryImages.find((img) => img.url === activeImageUrl);
if (match) return match.revisionId;
// Fallback: if no image match (video-only revision), use the latest revision with a video
if (revisions.length > 0) {
const withVideo = revisions.find((r: any) => {
const att = r.attachments as RevisionAttachments | null;
return !!att?.video;
});
if (withVideo) return withVideo.id;
}
return null;
}, [galleryImages, activeImageUrl, revisions]);
// ── Active video attachment (for video mode) ──────────────────────
const activeVideo = useMemo(() => {
if (!activeRevisionId) return null;
const rev = revisions.find((r: any) => r.id === activeRevisionId);
if (!rev) return null;
const att = rev.attachments as RevisionAttachments | null;
return att?.video ?? null;
}, [activeRevisionId, revisions]);
// Auto-switch to video mode when a video exists but no image
useEffect(() => {
if (!activeRevisionId) return;
const rev = revisions.find((r: any) => r.id === activeRevisionId);
const att = rev?.attachments as RevisionAttachments | null;
const hasImage = !!(att?.currentImage || att?.referenceImage);
const hasVideo = !!att?.video;
if (hasVideo && !hasImage) {
setViewerMode("video");
} else if (hasImage && !hasVideo) {
setViewerMode("image");
}
// When both exist: keep the user's current selection
}, [activeRevisionId, revisions]);
const hasImageAttachment = useMemo(() => {
if (!activeRevisionId) return false;
const rev = revisions.find((r: any) => r.id === activeRevisionId);
const att = rev?.attachments as RevisionAttachments | null;
return !!(att?.currentImage || att?.referenceImage);
}, [activeRevisionId, revisions]);
const hasVideoAttachment = !!activeVideo;
// ── Annotations for video timeline markers ──────────────────────────
const { data: annotationsRaw } = useAnnotations(activeRevisionId);
const annotationsForMarkers = (annotationsRaw as any[]) ?? [];
// Use browser-reported duration (from loadedmetadata) when DB duration is 0 (no FFmpeg)
const markerDuration = activeVideo?.duration || videoDuration;
const videoTimelineMarkers = useVideoTimelineMarkers(
annotationsForMarkers,
markerDuration
);
// ── Delete annotation (from feedback sidebar) ─────────────────────
const deleteAnnotationMutation = useDeleteAnnotation(activeRevisionId);
const handleDeleteAnnotation = useCallback(
(annotationId: string) => {
deleteAnnotationMutation.mutate(annotationId);
},
[deleteAnnotationMutation]
);
// Latest revision for upload panel
const latestRevision = revisions[0] as any | undefined;
const latestAttachments =
(latestRevision?.attachments as RevisionAttachments) ?? {};
const handleUploadComplete = useCallback(() => {
if (selectedStageId) {
queryClient.invalidateQueries({
queryKey: ["revisions", selectedStageId],
});
// Re-fetch after a short delay to pick up async video processing status
// (HLS transcoding or FFmpeg fallback updates status from "processing" → "ready")
setTimeout(() => {
queryClient.invalidateQueries({
queryKey: ["revisions", selectedStageId],
});
}, 2000);
}
// Reset active image to pick up new upload
setActiveImageUrl(null);
}, [selectedStageId, queryClient]);
const navigateStage = useCallback(
(direction: -1 | 1) => {
const newIdx = stageIdx + direction;
if (newIdx >= 0 && newIdx < stages.length) {
setSelectedStageId(stages[newIdx].id);
setActiveImageUrl(null);
// Reset comparison state when changing stages
setLeftRevisionKey("");
setRightRevisionKey("");
}
},
[stageIdx, stages]
);
const handleEnterComparison = useCallback(() => {
setComparisonActive(true);
// Reset revision keys so auto-select picks up
setLeftRevisionKey("");
setRightRevisionKey("");
}, []);
const handleExitComparison = useCallback(() => {
setComparisonActive(false);
}, []);
// ── Revision timeline handlers ──────────────────────────────────────
const handleTimelineSelectRevision = useCallback(
(revisionId: string, imageUrl: string | null) => {
if (imageUrl) {
setActiveImageUrl(imageUrl);
}
},
[]
);
const handleTimelineCompareRevisions = useCallback(
(leftRevId: string, rightRevId: string) => {
// Find gallery images for these revisions (prefer "current" type)
const leftImg =
galleryImages.find(
(img) => img.revisionId === leftRevId && img.type === "current"
) ?? galleryImages.find((img) => img.revisionId === leftRevId);
const rightImg =
galleryImages.find(
(img) => img.revisionId === rightRevId && img.type === "current"
) ?? galleryImages.find((img) => img.revisionId === rightRevId);
if (leftImg && rightImg) {
setComparisonActive(true);
setLeftRevisionKey(`${leftImg.revisionId}-${leftImg.type}`);
setRightRevisionKey(`${rightImg.revisionId}-${rightImg.type}`);
}
},
[galleryImages]
);
// ── Keyboard shortcuts for comparison modes ────────────────────────────
useEffect(() => {
if (!comparisonActive) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement
)
return;
switch (e.key) {
case "1":
e.preventDefault();
setComparisonMode("side-by-side");
break;
case "2":
e.preventDefault();
setComparisonMode("wipe");
break;
case "3":
e.preventDefault();
setComparisonMode("overlay");
break;
case "4":
e.preventDefault();
setComparisonMode("toggle");
break;
case "g":
case "G":
e.preventDefault();
setFlipA((f) => !f);
break;
case "h":
case "H":
e.preventDefault();
setFlipB((f) => !f);
break;
case "Escape":
e.preventDefault();
handleExitComparison();
break;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [comparisonActive, handleExitComparison]);
if (delLoading) {
return (
<div className="flex h-full flex-col">
<Skeleton className="h-12 w-full" />
<Skeleton className="mt-2 flex-1" />
</div>
);
}
if (!deliverable) {
return (
<div className="flex h-full items-center justify-center text-[var(--muted-foreground)]">
Deliverable not found.
</div>
);
}
return (
<div className="flex h-full flex-col -m-4 md:-m-6">
{/* ── Top bar ──────────────────────────────────────────────── */}
<div className="grid grid-cols-[1fr_auto_1fr] items-center border-b bg-[var(--card)] px-4 py-2">
{/* Left: back + deliverable name */}
<div className="flex items-center gap-3">
<Link
href={`/projects/${projectId}/deliverables/${deliverableId}`}
className="flex items-center gap-1 text-xs text-[var(--muted-foreground)] transition-colors hover:text-[var(--foreground)]"
>
<ArrowLeft className="h-3 w-3" />
Back
</Link>
<Separator orientation="vertical" className="h-4" />
<h1 className="max-w-[300px] truncate font-heading text-sm font-semibold">
{deliverable.name}
</h1>
</div>
{/* Center: stage navigator */}
{selectedStage && (
<div className="flex items-center gap-1.5">
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
disabled={stageIdx <= 0}
onClick={() => navigateStage(-1)}
>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<div className="flex w-72 items-center justify-center gap-1.5 rounded-md border bg-[var(--background)] px-2.5 py-1">
<span className="font-mono text-[10px] text-[var(--muted-foreground)]">
{selectedStage.stageDefinition?.order ?? selectedStage.template.order}
</span>
<span className="truncate text-xs font-medium">
{selectedStage.stageDefinition?.name ?? selectedStage.template.name}
</span>
<StageStatusBadge status={selectedStage.status} className="shrink-0" />
</div>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
disabled={stageIdx >= stages.length - 1}
onClick={() => navigateStage(1)}
>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
)}
{/* Right: actions */}
<div className="flex items-center justify-end gap-1.5">
{/* Image/Video toggle */}
{hasImageAttachment && hasVideoAttachment && (
<div className="flex items-center rounded-md border bg-[var(--background)]">
<Button
size="sm"
variant={viewerMode === "image" ? "default" : "ghost"}
className="h-7 rounded-r-none px-2 text-xs"
onClick={() => setViewerMode("image")}
>
<ImageIcon className="mr-1 h-3 w-3" />
Image
</Button>
<Button
size="sm"
variant={viewerMode === "video" ? "default" : "ghost"}
className="h-7 rounded-l-none px-2 text-xs"
onClick={() => setViewerMode("video")}
>
<Film className="mr-1 h-3 w-3" />
Video
</Button>
</div>
)}
{/* Compare toggle */}
{!comparisonActive && galleryImages.length >= 2 && viewerMode === "image" && (
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
onClick={handleEnterComparison}
>
<Columns2 className="mr-1 h-3 w-3" />
Compare
</Button>
)}
{/* Upload */}
<Sheet open={uploadPanelOpen} onOpenChange={setUploadPanelOpen}>
<SheetTrigger asChild>
<Button size="sm" variant="outline" className="h-7 text-xs">
<Upload className="mr-1 h-3 w-3" />
Upload
</Button>
</SheetTrigger>
<SheetContent className="w-full overflow-y-auto sm:max-w-sm">
<SheetHeader>
<SheetTitle className="text-sm">Upload Media</SheetTitle>
<SheetDescription className="sr-only">
Upload images and video for review
</SheetDescription>
</SheetHeader>
<Separator className="my-3" />
{latestRevision ? (
<div className="space-y-4">
<p className="text-xs text-[var(--muted-foreground)]">
Uploading to{" "}
<strong>Round {latestRevision.roundNumber}</strong>
</p>
<div className="space-y-2">
<p className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Reference Image
</p>
<ImageUploadZone
stageId={selectedStageId!}
revisionId={latestRevision.id}
imageType="reference"
existingImage={latestAttachments.referenceImage}
onUploadComplete={handleUploadComplete}
/>
</div>
<div className="space-y-2">
<p className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Current Render
</p>
<ImageUploadZone
stageId={selectedStageId!}
revisionId={latestRevision.id}
imageType="current"
existingImage={latestAttachments.currentImage}
onUploadComplete={handleUploadComplete}
/>
</div>
<Separator className="my-2" />
<div className="space-y-2">
<p className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Video
</p>
<VideoUploadZone
stageId={selectedStageId!}
revisionId={latestRevision.id}
videoType="video"
existingVideo={latestAttachments.video}
onUploadComplete={handleUploadComplete}
/>
</div>
<div className="space-y-2">
<p className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Reference Video
</p>
<VideoUploadZone
stageId={selectedStageId!}
revisionId={latestRevision.id}
videoType="referenceVideo"
existingVideo={latestAttachments.referenceVideo}
onUploadComplete={handleUploadComplete}
/>
</div>
</div>
) : (
<div className="flex flex-col items-center gap-3 py-8 text-center">
<p className="text-sm text-[var(--muted-foreground)]">
No rounds yet for this stage.
</p>
<Button
size="sm"
onClick={handleCreateRevision}
disabled={createRevision.isPending}
>
{createRevision.isPending ? (
<>
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
Creating
</>
) : (
<>
<Upload className="mr-1.5 h-3.5 w-3.5" />
New Round
</>
)}
</Button>
<p className="text-[10px] text-[var(--muted-foreground)]/60">
Creates Round 1 so you can start uploading images
</p>
</div>
)}
</SheetContent>
</Sheet>
</div>
</div>
{/* ── Comparison toolbar (when active) ──────────────────────── */}
{comparisonActive && (
<ComparisonToolbar
mode={comparisonMode}
onModeChange={setComparisonMode}
revisionOptions={revisionOptions}
leftRevisionKey={leftRevisionKey}
rightRevisionKey={rightRevisionKey}
onLeftChange={setLeftRevisionKey}
onRightChange={setRightRevisionKey}
flipA={flipA}
flipB={flipB}
onFlipA={() => setFlipA((f) => !f)}
onFlipB={() => setFlipB((f) => !f)}
onExit={handleExitComparison}
/>
)}
{/* ── Main content: viewer + sidebar ────────────────────────── */}
<div className="flex flex-1 overflow-hidden">
{/* ── Viewer column ──────────────────────────────────────── */}
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{comparisonActive ? (
<ComparisonViewer
leftSrc={leftSrc}
rightSrc={rightSrc}
mode={comparisonMode}
flipA={flipA}
flipB={flipB}
className="min-h-0 flex-1"
/>
) : viewerMode === "video" && activeVideo ? (
<VideoPlayer
hlsUrl={activeVideo.hlsUrl}
mp4Url={activeVideo.url}
posterUrl={activeVideo.thumbnailUrl}
fps={activeVideo.fps || 24}
status={activeVideo.status}
className="min-h-0 flex-1"
timelineMarkers={videoTimelineMarkers}
renderOverlay={(vs) => {
// Expose seek to sidebar for click-to-seek on feedback items
videoSeekRef.current = vs.seek;
// Track browser-reported duration via ref (scheduled to state in effect below)
if (vs.duration > 0) {
videoDurationRef.current = vs.duration;
}
return (
<VideoAnnotationLayer
revisionId={activeRevisionId}
stageId={selectedStageId}
currentTime={vs.currentTime}
isPlaying={vs.isPlaying}
fps={vs.fps}
videoWidth={vs.videoWidth}
videoHeight={vs.videoHeight}
nativeWidth={activeVideo.width || vs.videoWidth}
nativeHeight={activeVideo.height || vs.videoHeight}
onPause={vs.pause}
onSeek={vs.seek}
hoveredAnnotationId={hoveredAnnotationId}
/>
);
}}
/>
) : (
<ImageViewer
src={activeImageUrl}
className="min-h-0 flex-1"
renderOverlay={(vs: ImageViewerState) => (
<AnnotationLayer
revisionId={activeRevisionId}
stageId={selectedStageId}
zoom={vs.zoom}
panX={vs.panX}
panY={vs.panY}
containerWidth={vs.containerWidth}
containerHeight={vs.containerHeight}
imageDimensions={vs.imageDimensions}
hoveredAnnotationId={hoveredAnnotationId}
/>
)}
/>
)}
{/* Gallery strip — collapsible */}
{!comparisonActive && galleryImages.length > 0 && (
<div className="shrink-0 border-t bg-[var(--card)]">
<button
onClick={() => setGalleryOpen((p) => !p)}
className="flex w-full items-center gap-1.5 px-3 py-1 text-left transition-colors hover:bg-[var(--muted)]/50"
>
<Images className="h-3 w-3 text-[var(--muted-foreground)]" />
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Gallery
</span>
<span className="rounded-full bg-[var(--foreground)]/5 px-1.5 text-[9px] font-medium tabular-nums text-[var(--muted-foreground)]">
{galleryImages.length}
</span>
{galleryOpen ? (
<ChevronDown className="ml-auto h-3 w-3 text-[var(--muted-foreground)]" />
) : (
<ChevronUp className="ml-auto h-3 w-3 text-[var(--muted-foreground)]" />
)}
</button>
{galleryOpen && (
<div className="px-3 pb-1.5">
<ImageGallery
images={galleryImages}
activeUrl={activeImageUrl}
onSelect={(img) => setActiveImageUrl(img.url)}
/>
</div>
)}
</div>
)}
</div>
{/* ── Unified sidebar: revisions + feedback tabs ─────────── */}
<ReviewSidebar
stageId={selectedStageId}
revisions={revisions}
activeRevisionId={activeRevisionId}
onSelectRevision={handleTimelineSelectRevision}
onCompareRevisions={handleTimelineCompareRevisions}
onCreateRevision={handleCreateRevision}
isCreatingRevision={createRevision.isPending}
onAnnotationClick={handleAnnotationClick}
onAnnotationHover={setHoveredAnnotationId}
onDeleteAnnotation={handleDeleteAnnotation}
/>
</div>
</div>
);
}

View file

@ -253,7 +253,7 @@ export default function ProjectsPage() {
<div>
<h1 className="font-heading text-2xl font-bold">Projects</h1>
<p className="mt-1 text-sm text-[var(--muted-foreground)]">
Studio tracker all Dow Jones projects in the pipeline
Studio tracker all L'Oréal projects in the pipeline
</p>
</div>
<div className="flex items-center gap-2">

View file

@ -93,7 +93,7 @@ export default function WeeklyReportPage() {
<footer className="border-t pt-4 print:mt-8">
<div className="flex items-center justify-between">
<p className="label-upper text-[var(--primary)]">
Dow Jones Studio Tracker
L'Oréal Studio Tracker
</p>
<p className="text-[12px] text-[var(--muted-foreground)]">
Confidential Oliver Agency

View file

@ -0,0 +1,311 @@
"use client";
import { useCallback, useState } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { format } from "date-fns";
import {
ArrowLeft,
Play,
Pencil,
Grid3x3,
List,
CheckCircle2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import {
useReviewSession,
useUpdateReviewSession,
} from "@/hooks/use-review-sessions";
import { SessionBuilder } from "@/components/review/session-builder";
import { SessionPresenter } from "@/components/review/session-presenter";
import { SessionSummary } from "@/components/review/session-summary";
const STATUS_STYLES: Record<string, { label: string; className: string }> = {
DRAFT: {
label: "Draft",
className: "bg-[var(--muted)]/50 text-[var(--muted-foreground)]",
},
IN_PROGRESS: {
label: "In Progress",
className: "bg-[var(--status-in-review)]/10 text-[var(--status-in-review)]",
},
COMPLETED: {
label: "Completed",
className: "bg-[var(--status-approved)]/10 text-[var(--status-approved)]",
},
};
export default function ReviewSessionPage() {
const { sessionId } = useParams<{ sessionId: string }>();
const router = useRouter();
const searchParams = useSearchParams();
const initialMode = searchParams.get("mode");
const [view, setView] = useState<"builder" | "summary" | "presenter">(
initialMode === "present" ? "presenter" : "builder"
);
const [isEditingName, setIsEditingName] = useState(false);
const [editName, setEditName] = useState("");
const { data: session, isLoading } = useReviewSession(sessionId);
const updateMutation = useUpdateReviewSession(sessionId);
const items = (session?.items as any[]) ?? [];
const statusConfig = STATUS_STYLES[session?.status] ?? STATUS_STYLES.DRAFT;
// ── Name editing ────────────────────────────────────────────────────────
const handleStartEdit = useCallback(() => {
setEditName(session?.name ?? "");
setIsEditingName(true);
}, [session?.name]);
const handleSaveName = useCallback(() => {
if (!editName.trim()) return;
updateMutation.mutate(
{ name: editName.trim() },
{
onSuccess: () => {
setIsEditingName(false);
toast.success("Name updated");
},
}
);
}, [editName, updateMutation]);
// ── Status transitions ──────────────────────────────────────────────────
const handleStartSession = useCallback(() => {
updateMutation.mutate(
{ status: "IN_PROGRESS" },
{
onSuccess: () => {
toast.success("Session started");
setView("presenter");
},
}
);
}, [updateMutation]);
const handleCompleteSession = useCallback(() => {
updateMutation.mutate(
{ status: "COMPLETED" },
{
onSuccess: () => {
toast.success("Session completed");
setView("summary");
},
}
);
}, [updateMutation]);
const handleReopenSession = useCallback(() => {
updateMutation.mutate(
{ status: "IN_PROGRESS" },
{
onSuccess: () => toast.success("Session reopened"),
}
);
}, [updateMutation]);
// ── Presenter exit ──────────────────────────────────────────────────────
const handleExitPresenter = useCallback(() => {
setView("builder");
// Remove ?mode=present from URL
router.replace(`/reviews/${sessionId}`, { scroll: false });
}, [router, sessionId]);
// ── Loading ─────────────────────────────────────────────────────────────
if (isLoading) {
return (
<div className="flex h-[calc(100vh-3.5rem)] flex-col">
<Skeleton className="h-12 w-full" />
<Skeleton className="mt-2 flex-1" />
</div>
);
}
if (!session) {
return (
<div className="flex h-full items-center justify-center text-[var(--muted-foreground)]">
Session not found.
</div>
);
}
// ── Presenter mode (full height) ───────────────────────────────────────
if (view === "presenter") {
return (
<div className="h-[calc(100vh-3.5rem)]">
<SessionPresenter
sessionId={sessionId}
items={items}
sessionName={session.name}
onExit={handleExitPresenter}
/>
</div>
);
}
// ── Builder / Summary view ──────────────────────────────────────────────
return (
<div className="flex h-[calc(100vh-3.5rem)] flex-col">
{/* ── Top bar ──────────────────────────────────────────── */}
<div className="flex items-center justify-between border-b px-4 py-2">
{/* Left: back + name */}
<div className="flex items-center gap-3">
<Link
href="/reviews"
className="flex items-center gap-1 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<ArrowLeft className="h-3.5 w-3.5" />
Sessions
</Link>
<Separator orientation="vertical" className="h-5" />
{isEditingName ? (
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
onBlur={handleSaveName}
onKeyDown={(e) => {
if (e.key === "Enter") handleSaveName();
if (e.key === "Escape") setIsEditingName(false);
}}
className="h-7 w-60 text-sm font-semibold"
autoFocus
/>
) : (
<button
onClick={handleStartEdit}
className="flex items-center gap-1.5 font-heading text-sm font-semibold hover:text-[var(--primary)]"
>
{session.name}
<Pencil className="h-2.5 w-2.5 text-[var(--muted-foreground)]" />
</button>
)}
<Badge
variant="secondary"
className={cn("text-[10px] uppercase", statusConfig.className)}
>
{statusConfig.label}
</Badge>
{session.createdBy && (
<span className="text-[10px] text-[var(--muted-foreground)]">
by {session.createdBy.name} &middot;{" "}
{format(new Date(session.createdAt), "MMM d")}
</span>
)}
</div>
{/* Right: view toggle + actions */}
<div className="flex items-center gap-2">
{/* View toggle */}
<div className="flex rounded-md border">
<Button
size="sm"
variant={view === "builder" ? "default" : "ghost"}
className="h-7 rounded-r-none text-xs"
onClick={() => setView("builder")}
>
<List className="mr-1 h-3 w-3" />
List
</Button>
<Button
size="sm"
variant={view === "summary" ? "default" : "ghost"}
className="h-7 rounded-l-none text-xs"
onClick={() => setView("summary")}
>
<Grid3x3 className="mr-1 h-3 w-3" />
Grid
</Button>
</div>
<Separator orientation="vertical" className="h-5" />
{/* Status actions */}
{session.status === "DRAFT" && items.length > 0 && (
<Button
size="sm"
className="h-7 text-xs"
onClick={handleStartSession}
>
<Play className="mr-1 h-3 w-3" />
Start Review
</Button>
)}
{session.status === "IN_PROGRESS" && (
<>
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
onClick={() => setView("presenter")}
>
<Play className="mr-1 h-3 w-3" />
Present
</Button>
<Button
size="sm"
className="h-7 text-xs"
onClick={handleCompleteSession}
>
<CheckCircle2 className="mr-1 h-3 w-3" />
Complete
</Button>
</>
)}
{session.status === "COMPLETED" && (
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
onClick={handleReopenSession}
>
Reopen
</Button>
)}
</div>
</div>
{/* ── Content ──────────────────────────────────────────── */}
<div className="flex-1 overflow-y-auto">
{view === "builder" && (
<SessionBuilder
sessionId={sessionId}
items={items}
isLoading={isLoading}
/>
)}
{view === "summary" && (
<div className="p-4">
<SessionSummary
items={items}
onItemClick={(idx) => {
setView("presenter");
// The presenter will handle its own index state
}}
/>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,275 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { format } from "date-fns";
import {
Plus,
Play,
FileCheck,
Pencil,
Trash2,
Presentation,
Inbox,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import {
useReviewSessions,
useDeleteReviewSession,
} from "@/hooks/use-review-sessions";
import { CreateSessionDialog } from "@/components/review/create-session-dialog";
const STATUS_STYLES: Record<string, { label: string; className: string }> = {
DRAFT: {
label: "Draft",
className: "bg-[var(--muted)]/50 text-[var(--muted-foreground)]",
},
IN_PROGRESS: {
label: "In Progress",
className: "bg-[var(--status-in-review)]/10 text-[var(--status-in-review)]",
},
COMPLETED: {
label: "Completed",
className: "bg-[var(--status-approved)]/10 text-[var(--status-approved)]",
},
};
export default function ReviewSessionsPage() {
const [statusFilter, setStatusFilter] = useState<string | undefined>();
const [createOpen, setCreateOpen] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(null);
const { data: sessions, isLoading } = useReviewSessions(statusFilter);
const deleteMutation = useDeleteReviewSession();
const handleDelete = () => {
if (!deleteId) return;
deleteMutation.mutate(deleteId, {
onSuccess: () => {
toast.success("Session deleted");
setDeleteId(null);
},
onError: (err) => toast.error("Delete failed", { description: err.message }),
});
};
return (
<div className="mx-auto max-w-5xl px-6 py-6">
{/* ── Header ──────────────────────────────────────────── */}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="font-heading text-lg font-bold tracking-tight">
Review Sessions
</h1>
<p className="mt-0.5 text-xs text-[var(--muted-foreground)]">
Batch review deliverables in a structured walkthrough
</p>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
New Session
</Button>
</div>
{/* ── Status filter tabs ──────────────────────────────── */}
<div className="mb-4 flex gap-1">
{[
{ value: undefined, label: "All" },
{ value: "DRAFT", label: "Draft" },
{ value: "IN_PROGRESS", label: "In Progress" },
{ value: "COMPLETED", label: "Completed" },
].map((tab) => (
<Button
key={tab.label}
size="sm"
variant={statusFilter === tab.value ? "default" : "ghost"}
className="h-7 text-xs"
onClick={() => setStatusFilter(tab.value)}
>
{tab.label}
</Button>
))}
</div>
{/* ── Session list ────────────────────────────────────── */}
{isLoading && (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-20 w-full rounded-lg" />
))}
</div>
)}
{!isLoading && (!sessions || sessions.length === 0) && (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed py-16">
<Inbox className="h-10 w-10 text-[var(--muted-foreground)]/30" />
<p className="text-sm text-[var(--muted-foreground)]">
No review sessions yet
</p>
<Button
size="sm"
variant="outline"
onClick={() => setCreateOpen(true)}
>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Create your first session
</Button>
</div>
)}
{sessions && sessions.length > 0 && (
<div className="space-y-2">
{sessions.map((session: any) => {
const statusConfig = STATUS_STYLES[session.status] ?? STATUS_STYLES.DRAFT;
const itemCount = session._count?.items ?? 0;
const decidedCount = session.items?.filter(
(i: any) => i.decision != null
).length ?? 0;
return (
<Link
key={session.id}
href={`/reviews/${session.id}`}
className="group flex items-center gap-4 rounded-lg border bg-[var(--card)] px-4 py-3 transition-colors hover:bg-[var(--background)]"
>
{/* Icon */}
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--primary)]/10">
<Presentation className="h-4 w-4 text-[var(--primary)]" />
</div>
{/* Info */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold">
{session.name}
</span>
<Badge
variant="secondary"
className={cn("text-[10px] uppercase", statusConfig.className)}
>
{statusConfig.label}
</Badge>
</div>
<div className="mt-0.5 flex items-center gap-3 text-[10px] text-[var(--muted-foreground)]">
<span>{itemCount} items</span>
{itemCount > 0 && (
<span>
{decidedCount}/{itemCount} decided
</span>
)}
<span>
by {session.createdBy?.name ?? "Unknown"}
</span>
<span>
{format(new Date(session.updatedAt), "MMM d, yyyy")}
</span>
</div>
</div>
{/* Actions */}
<div
className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100"
onClick={(e) => e.preventDefault()}
>
{session.status === "DRAFT" && (
<Tooltip>
<TooltipTrigger asChild>
<Link href={`/reviews/${session.id}`}>
<Button size="icon" variant="ghost" className="h-7 w-7">
<Pencil className="h-3 w-3" />
</Button>
</Link>
</TooltipTrigger>
<TooltipContent className="text-xs">Edit</TooltipContent>
</Tooltip>
)}
{(session.status === "DRAFT" || session.status === "IN_PROGRESS") && (
<Tooltip>
<TooltipTrigger asChild>
<Link href={`/reviews/${session.id}?mode=present`}>
<Button size="icon" variant="ghost" className="h-7 w-7">
<Play className="h-3 w-3" />
</Button>
</Link>
</TooltipTrigger>
<TooltipContent className="text-xs">Present</TooltipContent>
</Tooltip>
)}
{session.status === "COMPLETED" && (
<Tooltip>
<TooltipTrigger asChild>
<Link href={`/reviews/${session.id}`}>
<Button size="icon" variant="ghost" className="h-7 w-7">
<FileCheck className="h-3 w-3" />
</Button>
</Link>
</TooltipTrigger>
<TooltipContent className="text-xs">View Summary</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-red-500 hover:text-red-600"
onClick={() => setDeleteId(session.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent className="text-xs">Delete</TooltipContent>
</Tooltip>
</div>
</Link>
);
})}
</div>
)}
{/* ── Create dialog ───────────────────────────────────── */}
<CreateSessionDialog open={createOpen} onOpenChange={setCreateOpen} />
{/* ── Delete confirmation ─────────────────────────────── */}
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete review session?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete the session and all its items. This
action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700"
onClick={handleDelete}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View file

@ -0,0 +1,213 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { CheckCircle2, AlertCircle, RefreshCw } from "lucide-react";
import { apiUrl } from "@/lib/api-client";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
interface BoxStatus {
configPath: string | null;
configPresent: boolean;
outFolderId: string | null;
watchFolderId: string | null;
webhookKeysSet: boolean;
inboundLogs: Array<{
id: string;
boxFileId: string;
fileName: string;
matchedDeliverableId: string | null;
matchedProjectId: string | null;
status: "MATCHED" | "UNMATCHED" | "ERROR";
error: string | null;
receivedAt: string;
}>;
pushLogs: Array<{
id: string;
deliverableId: string;
deliverable: { id: string; name: string };
boxFolderId: string | null;
status: "PENDING" | "SUCCESS" | "FAILED";
attempt: number;
error: string | null;
sentAt: string;
}>;
}
async function fetchStatus(): Promise<BoxStatus> {
const res = await fetch(apiUrl("/api/settings/box"));
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return res.json();
}
function StatusRow({ label, ok, value }: { label: string; ok: boolean; value?: string | null }) {
return (
<div className="flex items-start justify-between border-b border-[var(--border)] py-2 last:border-0">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">{label}</span>
{value && <span className="font-mono text-[12px] text-[var(--muted-foreground)]">{value}</span>}
</div>
{ok ? (
<Badge variant="outline" className="border-emerald-500/60 text-emerald-700 dark:text-emerald-400">
<CheckCircle2 className="mr-1 h-3 w-3" /> Configured
</Badge>
) : (
<Badge variant="outline" className="border-amber-500/60 text-amber-700 dark:text-amber-400">
<AlertCircle className="mr-1 h-3 w-3" /> Missing
</Badge>
)}
</div>
);
}
export default function BoxSettingsPage() {
const q = useQuery({ queryKey: ["settings-box"], queryFn: fetchStatus });
if (q.isLoading) {
return (
<div className="flex flex-col gap-3 p-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
</div>
);
}
if (q.error) {
return (
<div className="p-6">
<p className="text-sm text-destructive">
Failed to load Box status: {(q.error as Error).message}
</p>
</div>
);
}
const data = q.data!;
return (
<div className="flex flex-col gap-6 p-6">
<div className="flex items-start justify-between">
<div>
<h1 className="font-heading text-2xl font-bold">Box Integration</h1>
<p className="mt-1 text-sm text-[var(--muted-foreground)]">
Outbound on approval, inbound webhook ingestion. Configuration
comes from env vars and a mounted JWT config secret.
</p>
</div>
<button
type="button"
onClick={() => q.refetch()}
className="flex items-center gap-1 text-[12px] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<RefreshCw className="h-3 w-3" /> Refresh
</button>
</div>
<section className="rounded-md border bg-[var(--card)] p-4">
<h2 className="mb-3 text-sm font-semibold">Config</h2>
<StatusRow
label="JWT app config"
ok={data.configPresent}
value={data.configPath ?? "BOX_CONFIG_FILE unset"}
/>
<StatusRow
label="Outbound folder"
ok={!!data.outFolderId}
value={data.outFolderId ?? "BOX_OUT_FOLDER_ID unset"}
/>
<StatusRow
label="Inbound watch folder"
ok={!!data.watchFolderId}
value={data.watchFolderId ?? "BOX_WATCH_FOLDER_ID unset"}
/>
<StatusRow
label="Webhook signing keys"
ok={data.webhookKeysSet}
value={
data.webhookKeysSet
? "primary + secondary set"
: "BOX_WEBHOOK_PRIMARY_KEY / SECONDARY_KEY unset"
}
/>
</section>
<section className="rounded-md border bg-[var(--card)] p-4">
<h2 className="mb-3 text-sm font-semibold">Recent outbound pushes</h2>
{data.pushLogs.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">No pushes yet.</p>
) : (
<div className="flex flex-col gap-1">
{data.pushLogs.map((log) => (
<div key={log.id} className="flex items-center gap-3 border-b border-[var(--border)] py-1.5 text-[13px] last:border-0">
<Badge
variant="outline"
className={
log.status === "SUCCESS"
? "border-emerald-500/60 text-emerald-700 dark:text-emerald-400"
: log.status === "FAILED"
? "border-destructive text-destructive"
: ""
}
>
{log.status}
</Badge>
<span className="flex-1 truncate">{log.deliverable.name}</span>
{log.attempt > 1 && (
<span className="text-[12px] text-[var(--muted-foreground)]">
{log.attempt} attempts
</span>
)}
<span className="font-mono text-[11px] text-[var(--muted-foreground)]">
{new Date(log.sentAt).toLocaleString()}
</span>
{log.error && (
<span className="max-w-[300px] truncate text-[12px] text-destructive">
{log.error}
</span>
)}
</div>
))}
</div>
)}
</section>
<section className="rounded-md border bg-[var(--card)] p-4">
<h2 className="mb-3 text-sm font-semibold">Recent inbound events</h2>
{data.inboundLogs.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">
No inbound files yet. Subscribe a Box webhook to the watch folder.
</p>
) : (
<div className="flex flex-col gap-1">
{data.inboundLogs.map((log) => (
<div key={log.id} className="flex items-center gap-3 border-b border-[var(--border)] py-1.5 text-[13px] last:border-0">
<Badge
variant="outline"
className={
log.status === "MATCHED"
? "border-emerald-500/60 text-emerald-700 dark:text-emerald-400"
: log.status === "UNMATCHED"
? "border-amber-500/60 text-amber-700 dark:text-amber-400"
: "border-destructive text-destructive"
}
>
{log.status}
</Badge>
<span className="flex-1 truncate font-mono text-[12px]">{log.fileName}</span>
<span className="font-mono text-[11px] text-[var(--muted-foreground)]">
{new Date(log.receivedAt).toLocaleString()}
</span>
{log.error && (
<span className="max-w-[300px] truncate text-[12px] text-destructive">
{log.error}
</span>
)}
</div>
))}
</div>
)}
</section>
</div>
);
}

View file

@ -1,5 +1,5 @@
import Link from "next/link";
import { Settings, Wrench, Shield, GitBranch, Users, Users2, Layers, Columns, Bell, Zap } from "lucide-react";
import { Settings, Wrench, Shield, GitBranch, Users, Users2, Layers, Columns, Bell, Zap, Box } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
const settingsPages = [
@ -57,6 +57,12 @@ const settingsPages = [
description: "Define team skills and assign them to users for smart assignment suggestions",
icon: Wrench,
},
{
href: "/settings/box",
label: "Box Integration",
description: "Status of the Box JWT app config, outbound push folder, and inbound webhook subscription. Recent push + ingestion activity.",
icon: Box,
},
];
export default function SettingsPage() {

View file

@ -33,10 +33,10 @@ export default async function LoginPage() {
</div>
<div>
<h1 className="font-heading text-4xl font-black leading-[1.05] tracking-[-0.03em] text-[var(--primary-foreground)]">
Dow Jones<br />Studio<br />Tracker
L&apos;Or&eacute;al<br />Studio<br />Tracker
</h1>
<p className="mt-4 text-[13px] font-medium tracking-[0.06em] uppercase text-[var(--primary-foreground)]/60">
Production pipeline for Dow Jones studio
Production pipeline for L&apos;Or&eacute;al studio
</p>
</div>
<div>
@ -52,7 +52,7 @@ export default async function LoginPage() {
{/* Mobile wordmark */}
<div className="mb-10 md:hidden">
<h1 className="font-heading text-2xl font-black tracking-[-0.02em]">
Dow Jones Studio Tracker
L&apos;Or&eacute;al Studio Tracker
</h1>
<p className="mt-1 text-[12px] font-semibold tracking-[0.1em] uppercase text-[var(--muted-foreground)]">
Oliver Agency

View file

@ -50,7 +50,7 @@ export default async function PendingPage() {
{/* Mobile wordmark */}
<div className="mb-10 md:hidden">
<h1 className="font-heading text-2xl font-black tracking-[-0.02em]">
Dow Jones Studio Tracker
L&apos;Or&eacute;al Studio Tracker
</h1>
<p className="mt-1 text-[12px] font-semibold tracking-[0.1em] uppercase text-[var(--muted-foreground)]">
Oliver Agency
@ -84,7 +84,7 @@ export default async function PendingPage() {
<form
action={async () => {
"use server";
await signOut({ redirectTo: "/dow-prod-tracker/login" });
await signOut({ redirectTo: "/loreal-prod-tracker/login" });
}}
className="mt-6"
>

View file

@ -0,0 +1,55 @@
import { NextResponse } from "next/server";
import { notFound, serverError } from "@/lib/api-utils";
import { requireAuth } from "@/lib/rbac/require-auth";
import { assertOrgAccess } from "@/lib/rbac/org-scope";
import { pushDeliverableToBox } from "@/lib/services/box/box-outbound-service";
type Params = { params: Promise<{ deliverableId: string }> };
// POST /api/deliverables/:deliverableId/box-push
// Manual "Send to client (Box)" trigger. Admin / producer only.
export async function POST(_request: Request, { params }: Params) {
const { session, error } = await requireAuth("DELIVERABLE_UPDATE");
if (error) return error;
try {
const { deliverableId } = await params;
try {
await assertOrgAccess("deliverable", deliverableId, session.user.organizationId);
} catch {
return notFound("Deliverable not found");
}
const result = await pushDeliverableToBox(deliverableId);
return NextResponse.json(result, { status: result.ok ? 200 : 502 });
} catch (e) {
return serverError(e);
}
}
// GET /api/deliverables/:deliverableId/box-push — list push history
export async function GET(_request: Request, { params }: Params) {
const { session, error } = await requireAuth("DELIVERABLE_VIEW");
if (error) return error;
try {
const { deliverableId } = await params;
try {
await assertOrgAccess("deliverable", deliverableId, session.user.organizationId);
} catch {
return notFound("Deliverable not found");
}
const { prisma } = await import("@/lib/prisma");
const logs = await prisma.boxPushLog.findMany({
where: { deliverableId },
orderBy: { sentAt: "desc" },
take: 20,
});
return NextResponse.json(logs);
} catch (e) {
return serverError(e);
}
}

View file

@ -0,0 +1,93 @@
import { NextResponse } from "next/server";
import { badRequest, notFound, serverError } from "@/lib/api-utils";
import { requireAuth } from "@/lib/rbac/require-auth";
import { assertOrgAccess } from "@/lib/rbac/org-scope";
import {
updateFeedbackSchema,
resolveFeedbackSchema,
} from "@/lib/validators/feedback";
import {
getFeedbackItem,
updateFeedbackItem,
resolveFeedbackItem,
verifyFeedbackItem,
reopenFeedbackItem,
deleteFeedbackItem,
} from "@/lib/services/feedback-service";
type Params = { params: Promise<{ itemId: string }> };
// PATCH /api/feedback/:itemId — action=update|resolve|verify|reopen
export async function PATCH(request: Request, { params }: Params) {
const { session, error } = await requireAuth("REVISION_UPDATE");
if (error) return error;
try {
const { itemId } = await params;
try {
await assertOrgAccess("feedbackItem", itemId, session!.user.organizationId);
} catch {
return notFound("Resource not found");
}
const body = await request.json();
const action = body.action ?? "update";
const existing = await getFeedbackItem(itemId);
if (!existing) return notFound("Feedback item not found");
let result;
switch (action) {
case "resolve": {
const parsed = resolveFeedbackSchema.safeParse(body);
if (!parsed.success) {
return badRequest(parsed.error.issues.map((i) => i.message).join(", "));
}
result = await resolveFeedbackItem(itemId, session!.user.id, parsed.data);
break;
}
case "verify":
result = await verifyFeedbackItem(itemId, session!.user.id);
break;
case "reopen":
result = await reopenFeedbackItem(itemId);
break;
default: {
const parsed = updateFeedbackSchema.safeParse(body);
if (!parsed.success) {
return badRequest(parsed.error.issues.map((i) => i.message).join(", "));
}
result = await updateFeedbackItem(itemId, parsed.data);
}
}
return NextResponse.json(result);
} catch (e) {
return serverError(e);
}
}
// DELETE /api/feedback/:itemId
export async function DELETE(_request: Request, { params }: Params) {
const { session, error } = await requireAuth("REVISION_UPDATE");
if (error) return error;
try {
const { itemId } = await params;
try {
await assertOrgAccess("feedbackItem", itemId, session!.user.organizationId);
} catch {
return notFound("Resource not found");
}
const existing = await getFeedbackItem(itemId);
if (!existing) return notFound("Feedback item not found");
await deleteFeedbackItem(itemId);
return NextResponse.json({ ok: true });
} catch (e) {
return serverError(e);
}
}

View file

@ -0,0 +1,136 @@
import { NextResponse } from "next/server";
import { badRequest, notFound, serverError } from "@/lib/api-utils";
import { requireAuth } from "@/lib/rbac/require-auth";
import { assertOrgAccess } from "@/lib/rbac/org-scope";
import {
updateReviewSessionSchema,
addSessionItemsSchema,
reorderSessionItemsSchema,
recordDecisionSchema,
generateSessionItemsSchema,
} from "@/lib/validators/review-session";
import {
getReviewSession,
updateReviewSession,
deleteReviewSession,
addSessionItems,
removeSessionItem,
reorderSessionItems,
recordDecision,
clearDecision,
generateSessionItems,
} from "@/lib/services/review-session-service";
type Params = { params: Promise<{ sessionId: string }> };
// GET /api/reviews/:sessionId
export async function GET(_request: Request, { params }: Params) {
const { session: authSession, error } = await requireAuth("PROJECT_VIEW");
if (error) return error;
try {
const { sessionId } = await params;
try {
await assertOrgAccess("reviewSession", sessionId, authSession!.user.organizationId);
} catch {
return notFound("Resource not found");
}
const reviewSession = await getReviewSession(sessionId);
if (!reviewSession) return notFound("Review session not found");
return NextResponse.json(reviewSession);
} catch (e) {
return serverError(e);
}
}
// PATCH /api/reviews/:sessionId
// Multiple actions via `action` field: add-items | remove-item | reorder | decide
// | clear-decision | generate. Default (no action) = update metadata.
export async function PATCH(request: Request, { params }: Params) {
const { session: authSession, error } = await requireAuth("PROJECT_UPDATE");
if (error) return error;
try {
const { sessionId } = await params;
try {
await assertOrgAccess("reviewSession", sessionId, authSession!.user.organizationId);
} catch {
return notFound("Resource not found");
}
const body = await request.json();
const action = body.action as string | undefined;
if (action === "add-items") {
const parsed = addSessionItemsSchema.safeParse(body);
if (!parsed.success) return badRequest(parsed.error.issues.map((i) => i.message).join(", "));
await addSessionItems(sessionId, parsed.data);
return NextResponse.json(await getReviewSession(sessionId));
}
if (action === "remove-item") {
const itemId = body.itemId as string;
if (!itemId) return badRequest("itemId is required");
await removeSessionItem(itemId);
return NextResponse.json(await getReviewSession(sessionId));
}
if (action === "reorder") {
const parsed = reorderSessionItemsSchema.safeParse(body);
if (!parsed.success) return badRequest(parsed.error.issues.map((i) => i.message).join(", "));
await reorderSessionItems(sessionId, parsed.data);
return NextResponse.json(await getReviewSession(sessionId));
}
if (action === "decide") {
const parsed = recordDecisionSchema.safeParse(body);
if (!parsed.success) return badRequest(parsed.error.issues.map((i) => i.message).join(", "));
await recordDecision(authSession!.user.id, parsed.data);
return NextResponse.json(await getReviewSession(sessionId));
}
if (action === "clear-decision") {
const itemId = body.itemId as string;
if (!itemId) return badRequest("itemId is required");
await clearDecision(itemId);
return NextResponse.json(await getReviewSession(sessionId));
}
if (action === "generate") {
const parsed = generateSessionItemsSchema.safeParse(body);
if (!parsed.success) return badRequest(parsed.error.issues.map((i) => i.message).join(", "));
const candidates = await generateSessionItems(parsed.data);
return NextResponse.json(candidates);
}
const parsed = updateReviewSessionSchema.safeParse(body);
if (!parsed.success) return badRequest(parsed.error.issues.map((i) => i.message).join(", "));
const updated = await updateReviewSession(sessionId, parsed.data);
return NextResponse.json(updated);
} catch (e) {
return serverError(e);
}
}
// DELETE /api/reviews/:sessionId
export async function DELETE(_request: Request, { params }: Params) {
const { session: authSession, error } = await requireAuth("PROJECT_DELETE");
if (error) return error;
try {
const { sessionId } = await params;
try {
await assertOrgAccess("reviewSession", sessionId, authSession!.user.organizationId);
} catch {
return notFound("Resource not found");
}
await deleteReviewSession(sessionId);
return NextResponse.json({ ok: true });
} catch (e) {
return serverError(e);
}
}

View file

@ -0,0 +1,51 @@
import { NextResponse } from "next/server";
import { badRequest, serverError } from "@/lib/api-utils";
import { requireAuth } from "@/lib/rbac/require-auth";
import { createReviewSessionSchema } from "@/lib/validators/review-session";
import {
listReviewSessions,
createReviewSession,
} from "@/lib/services/review-session-service";
// GET /api/reviews?status=DRAFT|IN_PROGRESS|COMPLETED
export async function GET(request: Request) {
const { session, error } = await requireAuth("PROJECT_VIEW");
if (error) return error;
try {
const url = new URL(request.url);
const status = url.searchParams.get("status") ?? undefined;
const sessions = await listReviewSessions(
session!.user.organizationId,
{ status }
);
return NextResponse.json(sessions);
} catch (e) {
return serverError(e);
}
}
// POST /api/reviews
export async function POST(request: Request) {
const { session, error } = await requireAuth("PROJECT_VIEW");
if (error) return error;
try {
const body = await request.json();
const parsed = createReviewSessionSchema.safeParse(body);
if (!parsed.success) {
return badRequest(parsed.error.issues.map((i) => i.message).join(", "));
}
const reviewSession = await createReviewSession(
session!.user.organizationId,
session!.user.id,
parsed.data
);
return NextResponse.json(reviewSession, { status: 201 });
} catch (e) {
return serverError(e);
}
}

View file

@ -0,0 +1,49 @@
import { NextResponse } from "next/server";
import fs from "fs";
import { serverError } from "@/lib/api-utils";
import { requireAuth } from "@/lib/rbac/require-auth";
import { prisma } from "@/lib/prisma";
// GET /api/settings/box — admin Box-integration status. Returns:
// - config presence (BOX_CONFIG_FILE exists)
// - out / watch folder ids
// - whether webhook keys are set
// - recent inbound + outbound log activity
export async function GET() {
const { session, error } = await requireAuth();
if (error) return error;
if (session.user.role !== "ADMIN") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const configPath = process.env.BOX_CONFIG_FILE ?? null;
const configPresent = !!configPath && fs.existsSync(configPath);
const inboundLogs = await prisma.boxInboundLog.findMany({
orderBy: { receivedAt: "desc" },
take: 20,
});
const pushLogs = await prisma.boxPushLog.findMany({
orderBy: { sentAt: "desc" },
take: 20,
include: {
deliverable: { select: { id: true, name: true } },
},
});
return NextResponse.json({
configPath,
configPresent,
outFolderId: process.env.BOX_OUT_FOLDER_ID ?? null,
watchFolderId: process.env.BOX_WATCH_FOLDER_ID ?? null,
webhookKeysSet:
!!process.env.BOX_WEBHOOK_PRIMARY_KEY ||
!!process.env.BOX_WEBHOOK_SECONDARY_KEY,
inboundLogs,
pushLogs,
});
} catch (e) {
return serverError(e);
}
}

View file

@ -0,0 +1,76 @@
import { NextResponse } from "next/server";
import { badRequest, notFound, serverError } from "@/lib/api-utils";
import { requireAuth } from "@/lib/rbac/require-auth";
import { assertOrgAccess } from "@/lib/rbac/org-scope";
import { createFeedbackSchema } from "@/lib/validators/feedback";
import {
listFeedbackItems,
createFeedbackItem,
getFeedbackSummary,
} from "@/lib/services/feedback-service";
type Params = { params: Promise<{ stageId: string }> };
// GET /api/stages/:stageId/feedback?revisionId=&status=&isActionItem=&summary=
export async function GET(request: Request, { params }: Params) {
const { session, error } = await requireAuth("STAGE_VIEW");
if (error) return error;
try {
const { stageId } = await params;
try {
await assertOrgAccess("deliverableStage", stageId, session!.user.organizationId);
} catch {
return notFound("Resource not found");
}
const url = new URL(request.url);
const revisionId = url.searchParams.get("revisionId") ?? undefined;
const status = url.searchParams.get("status") ?? undefined;
const isActionItemParam = url.searchParams.get("isActionItem");
const isActionItem =
isActionItemParam === "true"
? true
: isActionItemParam === "false"
? false
: undefined;
const summaryOnly = url.searchParams.get("summary") === "true";
if (summaryOnly) {
return NextResponse.json(await getFeedbackSummary(stageId));
}
const items = await listFeedbackItems(stageId, { revisionId, status, isActionItem });
return NextResponse.json(items);
} catch (e) {
return serverError(e);
}
}
// POST /api/stages/:stageId/feedback
export async function POST(request: Request, { params }: Params) {
const { session, error } = await requireAuth("REVISION_CREATE");
if (error) return error;
try {
const { stageId } = await params;
try {
await assertOrgAccess("deliverableStage", stageId, session!.user.organizationId);
} catch {
return notFound("Resource not found");
}
const body = await request.json();
const parsed = createFeedbackSchema.safeParse(body);
if (!parsed.success) {
return badRequest(parsed.error.issues.map((i) => i.message).join(", "));
}
const item = await createFeedbackItem(stageId, session!.user.id, parsed.data);
return NextResponse.json(item, { status: 201 });
} catch (e) {
return serverError(e);
}
}

View file

@ -0,0 +1,46 @@
import { NextResponse } from "next/server";
import { badRequest, notFound, serverError } from "@/lib/api-utils";
import { requireAuth, visibilityContextFromSession } from "@/lib/rbac/require-auth";
import { assertOrgAccess } from "@/lib/rbac/org-scope";
import { createDeliverableSchema } from "@/lib/validators/deliverable";
import { createDeliverable } from "@/lib/services/deliverable-service";
import { checkIdempotency, recordIdempotency } from "@/lib/api/idempotency";
type Params = { params: Promise<{ projectId: string }> };
// POST /api/v1/projects/:projectId/deliverables — external API path.
// The deliverable's stage chain is auto-applied from the project's
// pipeline template (see deliverable-service). Supports Idempotency-Key.
export async function POST(request: Request, { params }: Params) {
const { session, error } = await requireAuth("DELIVERABLE_CREATE");
if (error) return error;
try {
const { projectId } = await params;
try {
await assertOrgAccess("project", projectId, session.user.organizationId);
} catch {
return notFound("Project not found");
}
const route = `POST /api/v1/projects/${projectId}/deliverables`;
const rawBody = await request.text();
const cached = await checkIdempotency(request, route, rawBody);
if (cached) return cached;
const body = JSON.parse(rawBody || "{}");
const parsed = createDeliverableSchema.safeParse(body);
if (!parsed.success) {
return badRequest(parsed.error.issues.map((i) => i.message).join(", "));
}
const ctx = visibilityContextFromSession(session);
const deliverable = await createDeliverable(projectId, parsed.data, ctx);
await recordIdempotency(request, route, rawBody, deliverable, 201);
return NextResponse.json(deliverable, { status: 201 });
} catch (e) {
return serverError(e);
}
}

View file

@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { notFound, serverError } from "@/lib/api-utils";
import { requireAuth, visibilityContextFromSession } from "@/lib/rbac/require-auth";
import { assertOrgAccess } from "@/lib/rbac/org-scope";
import { getProject } from "@/lib/services/project-service";
type Params = { params: Promise<{ projectId: string }> };
// GET /api/v1/projects/:projectId — read-back for external callers
// to confirm the state after a create/update.
export async function GET(_request: Request, { params }: Params) {
const { session, error } = await requireAuth("PROJECT_VIEW");
if (error) return error;
try {
const { projectId } = await params;
try {
await assertOrgAccess("project", projectId, session.user.organizationId);
} catch {
return notFound("Project not found");
}
const ctx = visibilityContextFromSession(session);
const project = await getProject(projectId, session.user.organizationId, ctx);
if (!project) return notFound("Project not found");
return NextResponse.json(project);
} catch (e) {
return serverError(e);
}
}

View file

@ -0,0 +1,53 @@
import { NextResponse } from "next/server";
import { badRequest, serverError } from "@/lib/api-utils";
import { requireAuth, visibilityContextFromSession } from "@/lib/rbac/require-auth";
import { createProjectSchema } from "@/lib/validators/project";
import { listProjects, createProject } from "@/lib/services/project-service";
import { checkIdempotency, recordIdempotency } from "@/lib/api/idempotency";
const ROUTE = "POST /api/v1/projects";
// GET /api/v1/projects — list all projects for the caller's org.
// Auth is the same as the in-app endpoint; API-key callers get attributed
// to the org's first ADMIN user (see src/lib/api-utils.ts).
export async function GET() {
const { session, error } = await requireAuth("PROJECT_VIEW");
if (error) return error;
try {
const ctx = visibilityContextFromSession(session);
const projects = await listProjects(session.user.organizationId, ctx);
return NextResponse.json(projects);
} catch (e) {
return serverError(e);
}
}
// POST /api/v1/projects — create a project. Default pipeline applied
// automatically when `pipelineTemplateId` is omitted (see project-service).
// Supports `Idempotency-Key` header — retried requests with the same
// payload return the cached response.
export async function POST(request: Request) {
const { session, error } = await requireAuth("PROJECT_CREATE");
if (error) return error;
try {
const rawBody = await request.text();
const cached = await checkIdempotency(request, ROUTE, rawBody);
if (cached) return cached;
const body = JSON.parse(rawBody || "{}");
const parsed = createProjectSchema.safeParse(body);
if (!parsed.success) {
return badRequest(parsed.error.issues.map((i) => i.message).join(", "));
}
const project = await createProject(parsed.data, session.user.organizationId);
await recordIdempotency(request, ROUTE, rawBody, project, 201);
return NextResponse.json(project, { status: 201 });
} catch (e) {
return serverError(e);
}
}

View file

@ -0,0 +1,85 @@
import { NextResponse } from "next/server";
import crypto from "crypto";
import { ingestBoxUpload } from "@/lib/services/box/box-inbound-service";
import { rateLimitWebhook } from "@/lib/webhooks/rate-limit";
/**
* Box webhook receiver. Box pushes a FILE.UPLOADED event when a file
* lands in the configured watch folder. We verify the signature against
* the two rotation keys Box maintains, then dispatch to the inbound
* service for matching + ingestion.
*
* Signature scheme (per Box docs):
* - Headers: `box-delivery-timestamp`, `box-signature-primary`,
* `box-signature-secondary`, `box-signature-version` (v1).
* - String to sign = body + delivery_timestamp.
* - HMAC-SHA256 with the matching primary or secondary key, base64.
*/
export async function POST(request: Request) {
const rl = rateLimitWebhook(request, "box");
if (!rl.ok) {
return NextResponse.json(
{ error: "Rate limit exceeded" },
{ status: 429, headers: { "Retry-After": String(rl.retryAfterSeconds ?? 60) } }
);
}
const bodyText = await request.text();
const timestamp = request.headers.get("box-delivery-timestamp") ?? "";
const primarySig = request.headers.get("box-signature-primary");
const secondarySig = request.headers.get("box-signature-secondary");
const allowInsecure = process.env.BOX_WEBHOOK_ALLOW_INSECURE === "true";
if (!allowInsecure) {
const primaryKey = process.env.BOX_WEBHOOK_PRIMARY_KEY;
const secondaryKey = process.env.BOX_WEBHOOK_SECONDARY_KEY;
if (!primaryKey && !secondaryKey) {
return NextResponse.json(
{ error: "Box webhook keys not configured" },
{ status: 500 }
);
}
const stringToSign = bodyText + timestamp;
const ok =
(primaryKey && primarySig && verifySig(stringToSign, primaryKey, primarySig)) ||
(secondaryKey && secondarySig && verifySig(stringToSign, secondaryKey, secondarySig));
if (!ok) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
}
let payload: any;
try {
payload = JSON.parse(bodyText);
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
// Box payload shape (only what we need):
// { trigger: "FILE.UPLOADED", source: { id, type: "file", name } }
const trigger = payload?.trigger as string | undefined;
const fileId = payload?.source?.id as string | undefined;
const fileType = payload?.source?.type as string | undefined;
if (trigger !== "FILE.UPLOADED" || fileType !== "file" || !fileId) {
// Acknowledge other triggers (Box retries on non-2xx).
return NextResponse.json({ ok: true, ignored: true });
}
const result = await ingestBoxUpload(fileId);
return NextResponse.json(result);
}
function verifySig(stringToSign: string, key: string, expectedB64: string): boolean {
const computed = crypto
.createHmac("sha256", key)
.update(stringToSign)
.digest("base64");
try {
return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(expectedB64));
} catch {
return false;
}
}

View file

@ -38,7 +38,7 @@ import { rateLimitWebhook } from "@/lib/webhooks/rate-limit";
* raw?: Record<string, unknown>, // pass-through; landed on Project.customFields
* }
*
* When this endpoint lands on `optical-dev.oliver.solutions/dow-prod-tracker`,
* When this endpoint lands on `optical-dev.oliver.solutions/loreal-prod-tracker`,
* share the secret with Shashank and have OMG POST here on every job event.
* Use `curl -H 'X-OMG-Signature: sha256=<hmac>' -d @payload.json ...` to
* test.

View file

@ -25,8 +25,8 @@ const jetbrainsMono = JetBrains_Mono({
});
export const metadata: Metadata = {
title: "Dow Jones Studio Tracker",
description: "Production pipeline tracker for the Dow Jones studio",
title: "L'Oréal Studio Tracker",
description: "Production pipeline tracker for the L'Oréal studio",
};
export default function RootLayout({

View file

@ -58,7 +58,7 @@ const FIGMA_URL_RE =
// Rewrites a Figma file URL into the embed URL Figma itself exposes.
function figmaEmbedUrl(raw: string): string | null {
if (!FIGMA_URL_RE.test(raw)) return null;
return `https://www.figma.com/embed?embed_host=dow-prod-tracker&url=${encodeURIComponent(raw)}`;
return `https://www.figma.com/embed?embed_host=loreal-prod-tracker&url=${encodeURIComponent(raw)}`;
}
function isImage(mime: string | null): boolean {

View file

@ -6,6 +6,7 @@ import {
LayoutDashboard,
FolderKanban,
ClipboardList,
ClipboardCheck,
ListChecks,
Inbox,
Bell,
@ -33,6 +34,7 @@ const navItems = [
{ href: "/briefs", label: "Briefs", icon: Inbox },
{ href: "/projects", label: "Projects", icon: FolderKanban },
{ href: "/deliverables", label: "Deliverables", icon: ListChecks },
{ href: "/reviews", label: "Reviews", icon: ClipboardCheck },
{ href: "/my-work", label: "My Work", icon: ClipboardList },
{ href: "/workload", label: "Workload", icon: Users },
{ href: "/resources", label: "Resources", icon: Users2 },
@ -175,18 +177,18 @@ export function Sidebar() {
role="navigation"
aria-label="Sidebar"
>
{/* Logo / wordmark Dow Jones logo is white, needs a dark
background to read. #002B5C is Dow Jones's brand navy. */}
{/* Logo / wordmark L'Oréal logo is white, needs a dark background
to read. Solid black matches L'Oréal Paris branding. */}
<div className={cn(
"flex h-14 items-center border-b bg-[#002B5C]",
"flex h-14 items-center border-b bg-black",
isCollapsed ? "justify-center px-2" : "px-4"
)}>
{!isCollapsed && (
<Link href="/dashboard" className="flex items-center" aria-label="Dow Jones Studio Tracker">
<Link href="/dashboard" className="flex items-center" aria-label="L'Oréal Studio Tracker">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/navbar-logo.png`}
alt="Dow Jones Studio Tracker"
alt="L'Oréal Studio Tracker"
className="h-7 w-auto max-w-full object-contain"
/>
</Link>
@ -236,11 +238,11 @@ export function MobileSidebar() {
<Sheet open={isMobileOpen} onOpenChange={setMobileOpen}>
<SheetContent side="left" className="w-60 p-0">
<SheetTitle className="sr-only">Navigation</SheetTitle>
<div className="flex h-14 items-center border-b bg-[#002B5C] px-4">
<div className="flex h-14 items-center border-b bg-black px-4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/navbar-logo.png`}
alt="Dow Jones Studio Tracker"
alt="L'Oréal Studio Tracker"
className="h-7 w-auto"
/>
</div>

View file

@ -14,7 +14,16 @@ import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Separator } from "@/components/ui/separator";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Save, Trash2 } from "lucide-react";
type ApprovalType = "NONE" | "SIMPLE" | "FORMAL";
import { toast } from "sonner";
interface StageEditSheetProps {
@ -27,6 +36,7 @@ interface StageEditSheetProps {
isOptional: boolean;
estimatedDays: number | null;
color: string | null;
approvalType?: ApprovalType;
} | null;
open: boolean;
onOpenChange: (open: boolean) => void;
@ -38,6 +48,7 @@ interface StageEditSheetProps {
isOptional?: boolean;
estimatedDays?: number | null;
color?: string | null;
approvalType?: ApprovalType;
}) => void;
onDelete: () => void;
}
@ -63,6 +74,7 @@ export function StageEditSheet({
const [isOptional, setIsOptional] = useState(false);
const [estimatedDays, setEstimatedDays] = useState("");
const [color, setColor] = useState("");
const [approvalType, setApprovalType] = useState<ApprovalType>("NONE");
const [autoSlug, setAutoSlug] = useState(true);
// Sync form state when stage changes
@ -75,6 +87,7 @@ export function StageEditSheet({
setIsOptional(stage.isOptional);
setEstimatedDays(stage.estimatedDays != null ? String(stage.estimatedDays) : "");
setColor(stage.color ?? "");
setApprovalType(stage.approvalType ?? "NONE");
setAutoSlug(false);
}
}, [stage]);
@ -109,6 +122,7 @@ export function StageEditSheet({
isOptional,
estimatedDays: estimatedDays ? Number(estimatedDays) : null,
color: color.trim() || null,
approvalType,
});
}
@ -186,6 +200,30 @@ export function StageEditSheet({
/>
</div>
{/* Approval Type — controls how the stage row renders the review surface */}
<div className="flex flex-col gap-1.5">
<Label htmlFor="stage-approval-type" className="label-upper text-[12px]">
Approval Type
</Label>
<Select
value={approvalType}
onValueChange={(v) => setApprovalType(v as ApprovalType)}
>
<SelectTrigger id="stage-approval-type" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="NONE">None no review widgets</SelectItem>
<SelectItem value="SIMPLE">
Simple upload version + approve / request changes
</SelectItem>
<SelectItem value="FORMAL">
Formal annotations, feedback checklist, sessions
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Color */}
<div className="flex flex-col gap-1.5">
<Label htmlFor="stage-color" className="label-upper text-[12px]">

View file

@ -33,7 +33,7 @@ export function ReportHeader({
<div className="min-w-0">
<div className="flex items-center gap-3">
<span className="label-upper text-[var(--primary)]">
Dow Jones Studio
L&apos;Or&eacute;al Studio
</span>
<span className="label-upper opacity-40">/</span>
<span className="label-upper">Oliver Agency</span>

View file

@ -0,0 +1,434 @@
"use client";
import { useEffect, useMemo, useCallback } from "react";
import { Send, X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
AnnotationRenderer,
type AnnotationShape,
} from "@/components/review/annotation-renderer";
import { AnnotationTools } from "@/components/review/annotation-tools";
import { useAnnotationState } from "@/hooks/use-annotation-state";
// ── Types ──────────────────────────────────────────────
export interface AnnotationLayerProps {
revisionId: string | null;
stageId: string | null;
zoom: number;
panX: number;
panY: number;
containerWidth: number;
containerHeight: number;
imageDimensions: { width: number; height: number } | null;
readOnly?: boolean;
/** ID of annotation to highlight (when hovering feedback item) */
hoveredAnnotationId?: string | null;
}
// ── Component ──────────────────────────────────────────
export function AnnotationLayer({
revisionId,
stageId,
zoom,
panX,
panY,
containerWidth,
containerHeight,
imageDimensions,
readOnly = false,
hoveredAnnotationId,
}: AnnotationLayerProps) {
const ann = useAnnotationState(revisionId, stageId);
// Drag-to-move annotations — works in move mode
const handleAnnotationDragStart = useCallback(
(annotationId: string, e: React.MouseEvent) => {
if (readOnly) return;
if (ann.activeTool !== "move") return;
e.preventDefault();
e.stopPropagation();
ann.setSelectedId(annotationId);
let lastX = e.clientX;
let lastY = e.clientY;
let moved = false;
const handleMove = (me: MouseEvent) => {
const dx = (me.clientX - lastX) / zoom;
const dy = (me.clientY - lastY) / zoom;
lastX = me.clientX;
lastY = me.clientY;
if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) {
moved = true;
ann.handleAnnotationMove(annotationId, dx, dy);
}
};
const handleUp = () => {
window.removeEventListener("mousemove", handleMove);
window.removeEventListener("mouseup", handleUp);
if (moved) ann.commitDrag(annotationId);
};
window.addEventListener("mousemove", handleMove);
window.addEventListener("mouseup", handleUp);
},
[readOnly, ann, zoom]
);
// Clipboard paste handler for screenshots
useEffect(() => {
const handlePaste = async (e: ClipboardEvent) => {
if (readOnly) return;
if (!revisionId || !stageId) return;
const items = e.clipboardData?.items;
if (!items) return;
for (const item of Array.from(items)) {
if (item.type.startsWith("image/")) {
e.preventDefault();
const file = item.getAsFile();
if (!file) continue;
await ann.handleScreenshotPaste(
file,
containerWidth,
containerHeight,
panX,
panY,
zoom,
imageDimensions
);
break;
}
}
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [revisionId, stageId, panX, panY, zoom, containerWidth, containerHeight, imageDimensions, ann, readOnly]);
// Cursor style
const cursorStyle = useMemo(() => {
if (ann.activeTool === "move") return "default";
if (ann.activeTool === "text") return "text";
return "crosshair";
}, [ann.activeTool]);
// Don't render overlay when there's no revision
if (!revisionId || !imageDimensions) return null;
return (
<>
{/* ── Annotation toolbar (floating inside viewport, top-center) ── */}
{!readOnly && (
<div className="absolute left-1/2 top-2 z-30 -translate-x-1/2">
<div className="flex items-center rounded-md border bg-[var(--card)]/90 px-2 py-1 shadow-lg backdrop-blur-sm">
<AnnotationTools
activeTool={ann.activeTool}
onToolChange={ann.setActiveTool}
color={ann.color}
onColorChange={ann.setColor}
canUndo={ann.canUndo}
canRedo={ann.canRedo}
onUndo={ann.handleUndo}
onRedo={ann.handleRedo}
visible={ann.visible}
onToggleVisibility={() => ann.setVisible((v: boolean) => !v)}
hasSelection={!!ann.selectedId}
onDeleteSelection={ann.handleDeleteSelection}
/>
</div>
</div>
)}
{/* ── SVG overlay ────────────────────────────────── */}
<svg
ref={ann.svgRef}
className="absolute inset-0 z-20"
width={containerWidth}
height={containerHeight}
style={{
cursor: readOnly ? "default" : cursorStyle,
pointerEvents: readOnly
? "none"
: ann.activeTool === "move" ? "none" : "auto",
}}
onMouseDown={readOnly ? undefined : (e) => ann.handleMouseDown(e, panX, panY, zoom)}
onMouseMove={readOnly ? undefined : (e) => ann.handleMouseMove(e, panX, panY, zoom)}
onMouseUp={readOnly ? undefined : (e) => ann.handleMouseUp(e)}
>
{/* Glow filter for hover highlight */}
<defs>
<filter id="annotation-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="4" result="blur" />
<feFlood floodColor="#ffffff" floodOpacity="0.8" result="color" />
<feComposite in="color" in2="blur" operator="in" result="glow" />
<feMerge>
<feMergeNode in="glow" />
<feMergeNode in="glow" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{ann.visible && (
<g transform={`translate(${panX}, ${panY}) scale(${zoom})`}>
{/* Persisted annotations */}
{ann.annotationShapes
.filter((a: AnnotationShape) => a.type !== "SCREENSHOT")
.map((a: AnnotationShape) => (
<g
key={a.id}
pointerEvents="auto"
filter={hoveredAnnotationId === a.id ? "url(#annotation-glow)" : undefined}
style={{
transition: "opacity 0.2s",
opacity: hoveredAnnotationId && hoveredAnnotationId !== a.id ? 0.3 : 1,
}}
>
<AnnotationRenderer
annotation={a}
onDragStart={handleAnnotationDragStart}
moveActive={ann.activeTool === "move"}
/>
</g>
))}
{/* Drawing preview */}
{!readOnly && ann.drawingPreview && (
<g opacity={0.8}>
<AnnotationRenderer annotation={ann.drawingPreview} />
</g>
)}
</g>
)}
</svg>
{/* ── Screenshot callouts (HTML layer for independent pointer events) ── */}
{!readOnly && ann.visible && ann.screenshotAnnotations.length > 0 && (
<div
className="absolute inset-0 z-25"
style={{ pointerEvents: "none" }}
>
{ann.screenshotAnnotations.map((a: any) => {
const d = a.data ?? {};
const imgX = d.x ?? 0;
const imgY = d.y ?? 0;
const w = d.width ?? 200;
const h = d.height ?? 150;
const screenLeft = panX + imgX * zoom;
const screenTop = panY + imgY * zoom;
const screenW = w * zoom;
const screenH = h * zoom;
const isSelected = a.id === ann.selectedId;
return (
<div
key={a.id}
style={{
position: "absolute",
left: screenLeft,
top: screenTop,
width: screenW,
height: screenH,
pointerEvents: "auto",
cursor: "grab",
border: `2px solid ${isSelected ? "#fff" : "rgba(0,0,0,0.6)"}`,
boxShadow: isSelected
? "0 0 0 1px rgba(255,255,255,0.8), 0 4px 12px rgba(0,0,0,0.5)"
: "0 2px 8px rgba(0,0,0,0.4)",
borderRadius: 3,
overflow: "hidden",
userSelect: "none",
}}
onMouseDown={(e) => {
e.stopPropagation();
ann.setSelectedId(a.id);
const startX = e.clientX;
const startY = e.clientY;
const origX = imgX;
const origY = imgY;
const handleMove = (me: MouseEvent) => {
const dx = (me.clientX - startX) / zoom;
const dy = (me.clientY - startY) / zoom;
ann.handleScreenshotMove(a.id, origX + dx, origY + dy);
};
const handleUp = () => {
window.removeEventListener("mousemove", handleMove);
window.removeEventListener("mouseup", handleUp);
ann.commitDrag(a.id);
};
window.addEventListener("mousemove", handleMove);
window.addEventListener("mouseup", handleUp);
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={d.imageUrl ?? ""}
alt="Screenshot"
draggable={false}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
pointerEvents: "none",
}}
/>
{/* Resize handle */}
{isSelected && (
<div
style={{
position: "absolute",
right: -4,
bottom: -4,
width: 10,
height: 10,
background: "white",
border: "1px solid rgba(0,0,0,0.3)",
borderRadius: 2,
cursor: "nwse-resize",
pointerEvents: "auto",
}}
onMouseDown={(e) => {
e.stopPropagation();
const startX = e.clientX;
const startY = e.clientY;
const origW = w;
const origH = h;
const handleMove = (me: MouseEvent) => {
const dx = (me.clientX - startX) / zoom;
const dy = (me.clientY - startY) / zoom;
ann.handleScreenshotResize(
a.id,
Math.max(40, origW + dx),
Math.max(40, origH + dy)
);
};
const handleUp = () => {
window.removeEventListener("mousemove", handleMove);
window.removeEventListener("mouseup", handleUp);
ann.commitDrag(a.id);
};
window.addEventListener("mousemove", handleMove);
window.addEventListener("mouseup", handleUp);
}}
/>
)}
</div>
);
})}
</div>
)}
{/* ── Floating text input ────────────────────────── */}
{!readOnly && ann.textInput && (
<div
className="absolute z-50"
style={{
left: ann.textInput.x,
top: ann.textInput.y,
transform: "translate(-4px, -14px)",
}}
>
<Input
ref={ann.textInputRef}
value={ann.textValue}
onChange={(e) => ann.setTextValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
ann.commitTextAnnotation();
}
if (e.key === "Escape") {
ann.setTextInput(null);
ann.setTextValue("");
}
}}
onBlur={() => {
setTimeout(() => ann.commitTextAnnotationOnBlur(), 150);
}}
className="h-7 min-w-[180px] border-[var(--primary)] bg-[var(--card)] text-sm"
placeholder="Type label..."
autoFocus
/>
</div>
)}
{/* ── Annotation comment popover ────────────────── */}
{!readOnly && ann.pendingAnnotation && (
<div
className="absolute z-50"
style={{
left: Math.min(
ann.pendingAnnotation.screenX,
containerWidth - 280
),
top: Math.min(
ann.pendingAnnotation.screenY + 12,
containerHeight - 120
),
}}
>
<div className="w-[260px] rounded-lg border bg-[var(--card)] shadow-xl">
<div className="flex items-center gap-2 border-b px-3 py-2">
<div
className="h-2 w-2 rounded-full"
style={{ backgroundColor: ann.color }}
/>
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Add Comment
</span>
<button
onClick={ann.cancelPendingAnnotation}
className="ml-auto flex h-5 w-5 items-center justify-center rounded text-[var(--muted-foreground)] hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
>
<X className="h-3 w-3" />
</button>
</div>
<div className="p-2">
<textarea
ref={ann.commentInputRef}
value={ann.commentValue}
onChange={(e) => ann.setCommentValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
ann.commitPendingAnnotation(ann.commentValue);
}
if (e.key === "Escape") {
ann.cancelPendingAnnotation();
}
}}
className="w-full resize-none rounded-md border bg-[var(--background)] px-2.5 py-2 text-xs leading-relaxed text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:border-[var(--primary)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
rows={2}
placeholder="Describe the issue..."
autoFocus
/>
<div className="mt-2 flex items-center justify-between">
<span className="text-[10px] text-[var(--muted-foreground)]">
Enter to save
</span>
<Button
size="sm"
className="h-6 px-2 text-[10px]"
onClick={() =>
ann.commitPendingAnnotation(ann.commentValue)
}
>
<Send className="mr-1 h-2.5 w-2.5" />
Save
</Button>
</div>
</div>
</div>
</div>
)}
</>
);
}

View file

@ -0,0 +1,104 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner";
import { useCreateReviewSession } from "@/hooks/use-review-sessions";
interface CreateSessionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function CreateSessionDialog({
open,
onOpenChange,
}: CreateSessionDialogProps) {
const router = useRouter();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const createMutation = useCreateReviewSession();
const handleCreate = () => {
if (!name.trim()) {
toast.error("Session name is required");
return;
}
createMutation.mutate(
{ name: name.trim(), description: description.trim() || undefined },
{
onSuccess: (session: any) => {
toast.success("Session created");
onOpenChange(false);
setName("");
setDescription("");
router.push(`/reviews/${session.id}`);
},
onError: (err) =>
toast.error("Failed to create session", { description: err.message }),
}
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="font-heading text-sm font-semibold">
New Review Session
</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<div>
<label className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Session Name
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Q1 Catalog Images Review"
className="mt-1"
autoFocus
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
/>
</div>
<div>
<label className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Description
</label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional notes about this session..."
className="mt-1 resize-none"
rows={2}
/>
</div>
</div>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
size="sm"
onClick={handleCreate}
disabled={createMutation.isPending || !name.trim()}
>
{createMutation.isPending ? "Creating..." : "Create Session"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,335 @@
"use client";
import { useMemo, useState } from "react";
import {
ChevronDown,
ChevronUp,
ClipboardList,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { TooltipProvider } from "@/components/ui/tooltip";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import {
useFeedbackItems,
useUpdateFeedback,
useResolveFeedback,
useVerifyFeedback,
useReopenFeedback,
useDeleteFeedback,
} from "@/hooks/use-feedback";
import { FeedbackItemCard } from "./feedback-item-card";
import { FeedbackProgressBar } from "./feedback-progress-bar";
interface FeedbackChecklistProps {
stageId: string;
revisionId?: string;
className?: string;
onAnnotationClick?: (annotation: {
id: string;
imageX: number;
imageY: number;
timestampSeconds?: number | null;
}) => void;
onAnnotationHover?: (annotationId: string | null) => void;
onDeleteAnnotation?: (annotationId: string) => void;
}
type TypeFilter = "ALL" | "ACTION" | "INFO";
type StatusFilter = "ALL" | "OPEN" | "RESOLVED";
export function FeedbackChecklist({
stageId,
revisionId,
className,
onAnnotationClick,
onAnnotationHover,
onDeleteAnnotation,
}: FeedbackChecklistProps) {
const [collapsed, setCollapsed] = useState(false);
const [typeFilter, setTypeFilter] = useState<TypeFilter>("ALL");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("ALL");
const { data: items = [], isLoading } = useFeedbackItems(stageId, revisionId);
const updateMutation = useUpdateFeedback(stageId);
const resolveMutation = useResolveFeedback(stageId);
const verifyMutation = useVerifyFeedback(stageId);
const reopenMutation = useReopenFeedback(stageId);
const deleteMutation = useDeleteFeedback(stageId);
const isPending =
updateMutation.isPending ||
resolveMutation.isPending ||
verifyMutation.isPending ||
reopenMutation.isPending ||
deleteMutation.isPending;
// Filter items
const filteredItems = useMemo(() => {
let result = [...items];
if (typeFilter === "ACTION") {
result = result.filter((i: any) => i.isActionItem);
} else if (typeFilter === "INFO") {
result = result.filter((i: any) => !i.isActionItem);
}
if (statusFilter === "OPEN") {
result = result.filter(
(i: any) =>
i.status === "OPEN" ||
i.status === "IN_PROGRESS" ||
i.status === "REOPENED"
);
} else if (statusFilter === "RESOLVED") {
result = result.filter(
(i: any) => i.status === "RESOLVED" || i.status === "VERIFIED"
);
}
return result;
}, [items, typeFilter, statusFilter]);
// Separate action items and info callouts
const actionItems = useMemo(
() => filteredItems.filter((i: any) => i.isActionItem),
[filteredItems]
);
const infoItems = useMemo(
() => filteredItems.filter((i: any) => !i.isActionItem),
[filteredItems]
);
// Stats (only count action items for progress)
const allActionItems = items.filter((i: any) => i.isActionItem);
const totalCount = allActionItems.length;
const resolvedCount = allActionItems.filter(
(i: any) => i.status === "RESOLVED" || i.status === "VERIFIED"
).length;
const infoCount = items.filter((i: any) => !i.isActionItem).length;
const handleResolve = (itemId: string, resolutionNote?: string) => {
resolveMutation.mutate(
{ itemId, data: { resolutionNote } },
{
onError: (err) => toast.error("Failed to resolve", { description: err.message }),
}
);
};
const handleVerify = (itemId: string) => {
verifyMutation.mutate(itemId, {
onError: (err) => toast.error("Failed to verify", { description: err.message }),
});
};
const handleReopen = (itemId: string) => {
reopenMutation.mutate(itemId, {
onError: (err) => toast.error("Failed to reopen", { description: err.message }),
});
};
const handleDelete = (itemId: string, annotationId?: string | null) => {
deleteMutation.mutate(itemId, {
onSuccess: () => {
// Also delete the linked annotation from the canvas
if (annotationId && onDeleteAnnotation) {
onDeleteAnnotation(annotationId);
}
},
onError: (err) => toast.error("Failed to delete", { description: err.message }),
});
};
const handleToggleType = (itemId: string, isActionItem: boolean) => {
updateMutation.mutate(
{ itemId, data: { isActionItem } },
{
onError: (err) => toast.error("Failed to update", { description: err.message }),
}
);
};
const handleUpdateSummary = (itemId: string, summary: string) => {
updateMutation.mutate(
{ itemId, data: { summary } },
{
onError: (err) => toast.error("Failed to update", { description: err.message }),
}
);
};
return (
<TooltipProvider delayDuration={200}>
<div
className={cn(
"bg-[var(--card)] transition-all",
className
)}
>
{/* Header */}
<button
className="flex w-full items-center justify-between px-3 py-2 text-left hover:bg-[var(--muted)]/50"
onClick={() => setCollapsed(!collapsed)}
>
<div className="flex items-center gap-2">
<ClipboardList className="h-3.5 w-3.5 text-[var(--primary)]" />
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Feedback
</span>
{totalCount > 0 && (
<Badge
variant="secondary"
className={cn(
"h-4 px-1.5 text-[9px] font-bold tabular-nums",
resolvedCount === totalCount
? "bg-[var(--status-approved)]/10 text-[var(--status-approved)]"
: "bg-[var(--primary)]/10 text-[var(--primary)]"
)}
>
{resolvedCount}/{totalCount}
</Badge>
)}
{infoCount > 0 && (
<Badge
variant="outline"
className="h-4 px-1.5 text-[9px] tabular-nums text-[var(--muted-foreground)]"
>
{infoCount} info
</Badge>
)}
</div>
{collapsed ? (
<ChevronDown className="h-3.5 w-3.5 text-[var(--muted-foreground)]" />
) : (
<ChevronUp className="h-3.5 w-3.5 text-[var(--muted-foreground)]" />
)}
</button>
{!collapsed && (
<div className="px-3 pb-3">
{/* Progress bar (action items only) */}
{totalCount > 0 && (
<FeedbackProgressBar
resolved={resolvedCount}
total={totalCount}
className="mb-2"
/>
)}
{/* Filters */}
{items.length > 0 && (
<div className="mb-2 flex items-center gap-1.5">
{/* Status filter */}
<div className="flex rounded-md border">
{(["ALL", "OPEN", "RESOLVED"] as StatusFilter[]).map((s) => (
<button
key={s}
className={cn(
"px-2 py-0.5 text-[10px] font-medium transition-colors",
statusFilter === s
? "bg-[var(--primary)] text-[var(--primary-foreground)]"
: "text-[var(--muted-foreground)] hover:bg-[var(--muted)]"
)}
onClick={() => setStatusFilter(s)}
>
{s === "ALL" ? "All" : s === "OPEN" ? "Open" : "Done"}
</button>
))}
</div>
{/* Type filter */}
<div className="flex rounded-md border">
{(["ALL", "ACTION", "INFO"] as TypeFilter[]).map((t) => (
<button
key={t}
className={cn(
"px-2 py-0.5 text-[10px] font-medium transition-colors",
typeFilter === t
? "bg-[var(--primary)] text-[var(--primary-foreground)]"
: "text-[var(--muted-foreground)] hover:bg-[var(--muted)]"
)}
onClick={() => setTypeFilter(t)}
>
{t === "ALL" ? "All" : t === "ACTION" ? "Actions" : "Info"}
</button>
))}
</div>
</div>
)}
{/* Item list */}
{isLoading ? (
<div className="py-4 text-center text-xs text-[var(--muted-foreground)]">
Loading feedback items...
</div>
) : items.length === 0 ? (
<div className="py-4 text-center text-xs text-[var(--muted-foreground)]">
No feedback items yet. Annotations will automatically create
action items.
</div>
) : filteredItems.length === 0 ? (
<div className="py-4 text-center text-xs text-[var(--muted-foreground)]">
No items match the current filters.
</div>
) : (
<div className="space-y-3">
{/* Action items first */}
{actionItems.length > 0 && (
<div>
<p className="mb-1 text-[9px] font-bold uppercase tracking-widest text-[var(--muted-foreground)]">
Action Items ({actionItems.length})
</p>
<div className="space-y-1">
{actionItems.map((item: any) => (
<FeedbackItemCard
key={item.id}
item={item}
onResolve={handleResolve}
onVerify={handleVerify}
onReopen={handleReopen}
onDelete={handleDelete}
onUpdateSummary={handleUpdateSummary}
onToggleType={handleToggleType}
onAnnotationClick={onAnnotationClick}
onAnnotationHover={onAnnotationHover}
isPending={isPending}
/>
))}
</div>
</div>
)}
{/* Info callouts */}
{infoItems.length > 0 && (
<div>
<p className="mb-1 text-[9px] font-bold uppercase tracking-widest text-[var(--muted-foreground)]">
Info Callouts ({infoItems.length})
</p>
<div className="space-y-1">
{infoItems.map((item: any) => (
<FeedbackItemCard
key={item.id}
item={item}
onResolve={handleResolve}
onVerify={handleVerify}
onReopen={handleReopen}
onDelete={handleDelete}
onUpdateSummary={handleUpdateSummary}
onToggleType={handleToggleType}
onAnnotationClick={onAnnotationClick}
onAnnotationHover={onAnnotationHover}
isPending={isPending}
/>
))}
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
</TooltipProvider>
);
}

View file

@ -0,0 +1,455 @@
"use client";
import { useState } from "react";
import {
Check,
CheckCheck,
ChevronDown,
ChevronUp,
Info,
MapPin,
Pencil,
RotateCcw,
Trash2,
ArrowRight,
CircleDot,
Clock,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
interface FeedbackItemData {
id: string;
summary: string;
isActionItem: boolean;
status: "OPEN" | "IN_PROGRESS" | "RESOLVED" | "VERIFIED" | "REOPENED";
resolutionNote?: string | null;
annotation?: {
id: string;
type: string;
data?: any;
imageX: number;
imageY: number;
timestampSeconds?: number | null;
} | null;
carriedFrom?: { id: string; summary: string; revisionId: string } | null;
createdBy?: { id: string; name: string | null; email: string } | null;
resolvedBy?: { id: string; name: string | null } | null;
verifiedBy?: { id: string; name: string | null } | null;
assignedTo?: { id: string; name: string | null } | null;
}
interface FeedbackItemCardProps {
item: FeedbackItemData;
onResolve: (itemId: string, resolutionNote?: string) => void;
onVerify: (itemId: string) => void;
onReopen: (itemId: string) => void;
onDelete: (itemId: string, annotationId?: string | null) => void;
onUpdateSummary?: (itemId: string, summary: string) => void;
onToggleType?: (itemId: string, isActionItem: boolean) => void;
onAnnotationClick?: (annotation: {
id: string;
imageX: number;
imageY: number;
timestampSeconds?: number | null;
}) => void;
onAnnotationHover?: (annotationId: string | null) => void;
isPending?: boolean;
}
function formatTimestamp(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
const ms = Math.round((seconds % 1) * 100);
return `${m}:${s.toString().padStart(2, "0")}.${ms.toString().padStart(2, "0")}`;
}
const STATUS_LABELS: Record<string, string> = {
OPEN: "Open",
IN_PROGRESS: "In Progress",
RESOLVED: "Resolved",
VERIFIED: "Verified",
REOPENED: "Reopened",
};
export function FeedbackItemCard({
item,
onResolve,
onVerify,
onReopen,
onDelete,
onUpdateSummary,
onToggleType,
onAnnotationClick,
onAnnotationHover,
isPending,
}: FeedbackItemCardProps) {
// Extract annotation color from data (default to accent)
const annotationColor = item.annotation?.data?.color ?? null;
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(item.summary);
const [expanded, setExpanded] = useState(false);
const [resolutionNote, setResolutionNote] = useState("");
const isResolved = item.status === "RESOLVED" || item.status === "VERIFIED";
const handleResolve = () => {
onResolve(item.id, resolutionNote || undefined);
setResolutionNote("");
setExpanded(false);
};
return (
<div
className={cn(
"group relative rounded-lg border border-l-[3px] p-2.5 transition-all",
!annotationColor && item.isActionItem && "border-l-[var(--accent)]",
!annotationColor && !item.isActionItem && "border-l-[var(--muted-foreground)]",
isResolved && "opacity-60"
)}
style={annotationColor ? { borderLeftColor: annotationColor } : undefined}
onMouseEnter={() => {
if (item.annotation && onAnnotationHover) {
onAnnotationHover(item.annotation.id);
}
}}
onMouseLeave={() => {
if (onAnnotationHover) {
onAnnotationHover(null);
}
}}
>
<div className="flex items-start gap-2">
{/* Checkbox (only for action items) */}
{item.isActionItem ? (
<Checkbox
checked={isResolved}
disabled={isPending || item.status === "VERIFIED"}
onCheckedChange={(checked) => {
if (checked) {
if (item.summary.length < 50) {
onResolve(item.id);
} else {
setExpanded(true);
}
} else {
onReopen(item.id);
}
}}
className="mt-0.5"
/>
) : (
<Info className="mt-0.5 h-4 w-4 shrink-0 text-[var(--muted-foreground)]" />
)}
{/* Content */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<Badge
variant="outline"
className={cn(
"h-4 px-1 text-[9px] font-bold",
!annotationColor && item.isActionItem
&& "border-[var(--accent)]/30 bg-[var(--accent)]/10 text-[var(--accent)]",
!annotationColor && !item.isActionItem
&& "border-[var(--muted-foreground)]/30 bg-[var(--muted)]/50 text-[var(--muted-foreground)]",
)}
style={
annotationColor
? {
borderColor: `${annotationColor}30`,
backgroundColor: `${annotationColor}18`,
color: annotationColor,
}
: undefined
}
>
{item.isActionItem ? "Action" : "Info"}
</Badge>
{item.status !== "OPEN" && !isResolved && (
<Badge
variant="secondary"
className="h-4 px-1 text-[9px]"
>
{STATUS_LABELS[item.status]}
</Badge>
)}
{item.status === "VERIFIED" && (
<Tooltip>
<TooltipTrigger asChild>
<CheckCheck className="h-3 w-3 text-[var(--status-approved)]" />
</TooltipTrigger>
<TooltipContent>
Verified{item.verifiedBy?.name ? ` by ${item.verifiedBy.name}` : ""}
</TooltipContent>
</Tooltip>
)}
{item.carriedFrom && (
<Tooltip>
<TooltipTrigger asChild>
<ArrowRight className="h-3 w-3 text-[var(--muted-foreground)]" />
</TooltipTrigger>
<TooltipContent>
Carried forward from previous round
</TooltipContent>
</Tooltip>
)}
{/* Video timestamp badge (A7.3) — click to seek */}
{item.annotation?.timestampSeconds != null && (
<Tooltip>
<TooltipTrigger asChild>
<button
className="inline-flex items-center gap-0.5 rounded bg-[var(--muted)] px-1 py-px font-mono text-[9px] tabular-nums text-[var(--muted-foreground)] transition-colors hover:bg-[var(--primary)]/20 hover:text-[var(--primary)]"
onClick={() => {
if (item.annotation && onAnnotationClick) {
onAnnotationClick(item.annotation);
}
}}
>
<Clock className="h-2.5 w-2.5" />
{formatTimestamp(item.annotation.timestampSeconds)}
</button>
</TooltipTrigger>
<TooltipContent>
Click to jump to {item.annotation.timestampSeconds.toFixed(2)}s
</TooltipContent>
</Tooltip>
)}
</div>
{isEditing ? (
<div className="mt-0.5">
<textarea
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
const trimmed = editValue.trim();
if (trimmed && trimmed !== item.summary && onUpdateSummary) {
onUpdateSummary(item.id, trimmed);
}
setIsEditing(false);
}
if (e.key === "Escape") {
setEditValue(item.summary);
setIsEditing(false);
}
}}
onBlur={() => {
const trimmed = editValue.trim();
if (trimmed && trimmed !== item.summary && onUpdateSummary) {
onUpdateSummary(item.id, trimmed);
}
setIsEditing(false);
}}
className="w-full resize-none rounded border bg-[var(--background)] px-1.5 py-1 text-xs leading-relaxed text-[var(--foreground)] focus:border-[var(--primary)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
rows={2}
autoFocus
/>
<span className="text-[9px] text-[var(--muted-foreground)]">
Enter to save, Esc to cancel
</span>
</div>
) : (
<p
className={cn(
"mt-0.5 cursor-text text-xs leading-relaxed",
isResolved && "line-through"
)}
onDoubleClick={() => {
if (!isResolved && onUpdateSummary) {
setEditValue(item.summary);
setIsEditing(true);
}
}}
>
{item.summary.replace(/\s+at\s+\(\d+,\s*\d+\)$/, "")}
</p>
)}
{/* Resolution note display */}
{item.resolutionNote && isResolved && (
<p className="mt-1 text-[10px] italic text-[var(--muted-foreground)]">
Resolution: {item.resolutionNote}
</p>
)}
{/* Expanded resolve form */}
{expanded && !isResolved && item.isActionItem && (
<div className="mt-2 space-y-1.5">
<Textarea
placeholder="Resolution note (optional)..."
value={resolutionNote}
onChange={(e) => setResolutionNote(e.target.value)}
className="h-16 text-xs"
/>
<div className="flex gap-1.5">
<Button
size="sm"
className="h-6 text-[10px]"
disabled={isPending}
onClick={handleResolve}
>
<Check className="mr-1 h-3 w-3" />
Resolve
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 text-[10px]"
onClick={() => setExpanded(false)}
>
Cancel
</Button>
</div>
</div>
)}
</div>
{/* Action buttons */}
<div className="flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
{item.annotation && onAnnotationClick && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={() => onAnnotationClick(item.annotation!)}
>
<MapPin className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Jump to annotation</TooltipContent>
</Tooltip>
)}
{/* Edit summary */}
{onUpdateSummary && !isResolved && !isEditing && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={() => {
setEditValue(item.summary);
setIsEditing(true);
}}
>
<Pencil className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit comment</TooltipContent>
</Tooltip>
)}
{/* Toggle action item / info */}
{onToggleType && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
disabled={isPending}
onClick={() => onToggleType(item.id, !item.isActionItem)}
>
{item.isActionItem ? (
<Info className="h-3 w-3" />
) : (
<CircleDot className="h-3 w-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{item.isActionItem ? "Change to info callout" : "Change to action item"}
</TooltipContent>
</Tooltip>
)}
{item.isActionItem && !isResolved && !expanded && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Resolve with note</TooltipContent>
</Tooltip>
)}
{item.status === "RESOLVED" && (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 text-[var(--status-approved)]"
disabled={isPending}
onClick={() => onVerify(item.id)}
>
<CheckCheck className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Verify fix</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 text-[var(--status-in-review)]"
disabled={isPending}
onClick={() => onReopen(item.id)}
>
<RotateCcw className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Reopen</TooltipContent>
</Tooltip>
</>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 text-[var(--muted-foreground)] hover:text-red-500"
disabled={isPending}
onClick={() => onDelete(item.id, item.annotation?.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,47 @@
"use client";
import { cn } from "@/lib/utils";
interface FeedbackProgressBarProps {
resolved: number;
total: number;
className?: string;
}
export function FeedbackProgressBar({
resolved,
total,
className,
}: FeedbackProgressBarProps) {
const percentage = total > 0 ? Math.round((resolved / total) * 100) : 0;
const allDone = resolved === total && total > 0;
return (
<div className={cn("space-y-1", className)}>
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Action Items
</span>
<span
className={cn(
"text-xs font-medium tabular-nums",
allDone
? "text-[var(--status-approved)]"
: "text-[var(--foreground)]"
)}
>
{resolved} of {total} resolved
</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-[var(--muted)]">
<div
className={cn(
"h-full rounded-full transition-all duration-300",
allDone ? "bg-[var(--status-approved)]" : "bg-[var(--primary)]"
)}
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,232 @@
"use client";
import { useState } from "react";
import {
History,
MessageSquareText,
PanelRightClose,
PanelRightOpen,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { RevisionTimeline } from "@/components/review/revision-timeline";
import { FeedbackChecklist } from "@/components/review/feedback-checklist";
type SidebarTab = "revisions" | "feedback";
interface ReviewSidebarProps {
stageId: string | null;
revisions: any[];
activeRevisionId: string | null;
onSelectRevision: (revisionId: string, imageUrl: string | null) => void;
onCompareRevisions: (leftId: string, rightId: string) => void;
onCreateRevision?: () => void;
isCreatingRevision?: boolean;
onAnnotationClick?: (annotation: {
id: string;
imageX: number;
imageY: number;
timestampSeconds?: number | null;
}) => void;
onAnnotationHover?: (annotationId: string | null) => void;
onDeleteAnnotation?: (annotationId: string) => void;
/** Seek video to a timestamp (for video annotation click-to-seek) */
onVideoSeek?: (time: number) => void;
}
export function ReviewSidebar({
stageId,
revisions,
activeRevisionId,
onSelectRevision,
onCompareRevisions,
onCreateRevision,
isCreatingRevision,
onAnnotationClick,
onAnnotationHover,
onDeleteAnnotation,
}: ReviewSidebarProps) {
const [activeTab, setActiveTab] = useState<SidebarTab>("revisions");
const [collapsed, setCollapsed] = useState(false);
if (collapsed) {
return (
<div className="flex flex-col items-center gap-1 border-l bg-[var(--card)] px-1.5 py-3">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => setCollapsed(false)}
aria-label="Expand sidebar"
>
<PanelRightOpen className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left" className="text-xs">
Expand sidebar
</TooltipContent>
</Tooltip>
<div className="my-1 h-px w-5 bg-[var(--border)]" />
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => {
setCollapsed(false);
setActiveTab("revisions");
}}
className={cn(
"flex h-7 w-7 items-center justify-center rounded-md transition-colors",
activeTab === "revisions"
? "bg-[var(--primary)]/10 text-[var(--primary)]"
: "text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
)}
aria-label="Revisions"
>
<History className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="left" className="text-xs">
Revisions
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => {
setCollapsed(false);
setActiveTab("feedback");
}}
className={cn(
"flex h-7 w-7 items-center justify-center rounded-md transition-colors",
activeTab === "feedback"
? "bg-[var(--primary)]/10 text-[var(--primary)]"
: "text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
)}
aria-label="Feedback"
>
<MessageSquareText className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="left" className="text-xs">
Feedback
</TooltipContent>
</Tooltip>
</div>
);
}
return (
<div className="flex w-[320px] shrink-0 flex-col border-l bg-[var(--card)]">
{/* Tab bar */}
<div className="flex items-center border-b">
<div className="flex flex-1">
<button
onClick={() => setActiveTab("revisions")}
className={cn(
"relative flex flex-1 items-center justify-center gap-1.5 px-3 py-2.5 text-[10px] font-semibold uppercase tracking-wider transition-colors",
activeTab === "revisions"
? "text-[var(--primary)]"
: "text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
)}
>
<History className="h-3 w-3" />
Revisions
{revisions.length > 0 && (
<span
className={cn(
"rounded-full px-1.5 text-[9px] font-bold tabular-nums",
activeTab === "revisions"
? "bg-[var(--primary)]/10 text-[var(--primary)]"
: "bg-[var(--foreground)]/5 text-[var(--muted-foreground)]"
)}
>
{revisions.length}
</span>
)}
{activeTab === "revisions" && (
<span className="absolute bottom-0 left-3 right-3 h-[2px] rounded-t-full bg-[var(--primary)]" />
)}
</button>
<button
onClick={() => setActiveTab("feedback")}
className={cn(
"relative flex flex-1 items-center justify-center gap-1.5 px-3 py-2.5 text-[10px] font-semibold uppercase tracking-wider transition-colors",
activeTab === "feedback"
? "text-[var(--primary)]"
: "text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
)}
>
<MessageSquareText className="h-3 w-3" />
Feedback
{activeTab === "feedback" && (
<span className="absolute bottom-0 left-3 right-3 h-[2px] rounded-t-full bg-[var(--primary)]" />
)}
</button>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="mr-1 h-6 w-6"
onClick={() => setCollapsed(true)}
aria-label="Collapse sidebar"
>
<PanelRightClose className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="left" className="text-xs">
Collapse
</TooltipContent>
</Tooltip>
</div>
{/* Tab content */}
<div className="flex-1 overflow-y-auto">
{activeTab === "revisions" && (
<RevisionTimeline
stageId={stageId}
revisions={revisions}
activeRevisionId={activeRevisionId}
onSelectRevision={onSelectRevision}
onCompareRevisions={onCompareRevisions}
onCreateRevision={onCreateRevision}
isCreatingRevision={isCreatingRevision}
embedded
/>
)}
{activeTab === "feedback" && stageId && (
<FeedbackChecklist
stageId={stageId}
revisionId={activeRevisionId ?? undefined}
onAnnotationClick={onAnnotationClick}
onAnnotationHover={onAnnotationHover}
onDeleteAnnotation={onDeleteAnnotation}
/>
)}
{activeTab === "feedback" && !stageId && (
<div className="flex flex-col items-center gap-2 px-4 py-12 text-center">
<MessageSquareText className="h-8 w-8 text-[var(--muted-foreground)]/30" />
<p className="text-xs text-[var(--muted-foreground)]">
Select a stage to view feedback
</p>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,479 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import {
GripVertical,
Trash2,
Plus,
Wand2,
Image as ImageIcon,
ChevronDown,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { StageStatusBadge } from "@/components/stages/stage-status-badge";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { useProjects } from "@/hooks/use-projects";
import {
useAddSessionItems,
useRemoveSessionItem,
useReorderSessionItems,
useGenerateSessionItems,
} from "@/hooks/use-review-sessions";
// ── Types ─────────────────────────────────────────────────────────────────
interface SessionItem {
id: string;
sortOrder: number;
decision: string | null;
decisionNote: string | null;
deliverableStage: {
id: string;
status: string;
template: { id: string; name: string; slug: string; order: number };
stageDefinition?: { id: string; name: string; slug: string; order: number } | null;
deliverable: {
id: string;
name: string;
priority: string;
project: { id: string; name: string; projectCode: string };
};
revisions: { id: string; roundNumber: number; attachments: any }[];
assignments: { user: { id: string; name: string; image: string | null } }[];
};
}
interface SessionBuilderProps {
sessionId: string;
items: SessionItem[];
isLoading?: boolean;
}
// ── Stage status options for the generate filter ──────────────────────────
const STAGE_STATUSES = [
{ value: "IN_REVIEW", label: "In Review" },
{ value: "CHANGES_REQUESTED", label: "Changes Requested" },
{ value: "IN_PROGRESS", label: "In Progress" },
{ value: "APPROVED", label: "Approved" },
{ value: "DELIVERED", label: "Delivered" },
];
// ── Component ─────────────────────────────────────────────────────────────
export function SessionBuilder({
sessionId,
items,
isLoading,
}: SessionBuilderProps) {
const [generateOpen, setGenerateOpen] = useState(false);
const [genProjectId, setGenProjectId] = useState("");
const [genStatus, setGenStatus] = useState("");
const [genCandidates, setGenCandidates] = useState<any[] | null>(null);
const addItemsMutation = useAddSessionItems(sessionId);
const removeItemMutation = useRemoveSessionItem(sessionId);
const reorderMutation = useReorderSessionItems(sessionId);
const generateMutation = useGenerateSessionItems(sessionId);
const { data: projectsData } = useProjects();
const projects = (projectsData as any[]) ?? [];
// ── Drag-and-drop reorder (simplified: move up/down buttons) ────────────
const moveItem = useCallback(
(index: number, direction: -1 | 1) => {
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= items.length) return;
const reordered = [...items];
const [moved] = reordered.splice(index, 1);
reordered.splice(newIndex, 0, moved);
reorderMutation.mutate(reordered.map((i) => i.id));
},
[items, reorderMutation]
);
const handleRemoveItem = useCallback(
(itemId: string) => {
removeItemMutation.mutate(itemId, {
onError: (err) =>
toast.error("Failed to remove item", { description: err.message }),
});
},
[removeItemMutation]
);
// ── Generate from filters ───────────────────────────────────────────────
const handleGenerate = useCallback(() => {
if (!genProjectId) {
toast.error("Select a project");
return;
}
generateMutation.mutate(
{
projectId: genProjectId,
stageStatus: genStatus || undefined,
},
{
onSuccess: (data: any) => {
setGenCandidates(data);
},
onError: (err) =>
toast.error("Failed to generate", { description: err.message }),
}
);
}, [genProjectId, genStatus, generateMutation]);
const handleAddGenerated = useCallback(() => {
if (!genCandidates || genCandidates.length === 0) return;
// Filter out stages already in the session
const existingStageIds = new Set(
items.map((i) => i.deliverableStage.id)
);
const newItems = genCandidates.filter(
(c) => !existingStageIds.has(c.deliverableStageId)
);
if (newItems.length === 0) {
toast.info("All matching items are already in the session");
setGenerateOpen(false);
return;
}
addItemsMutation.mutate(
newItems.map((c) => ({
deliverableStageId: c.deliverableStageId,
revisionId: c.revisionId,
})),
{
onSuccess: () => {
toast.success(`Added ${newItems.length} items`);
setGenerateOpen(false);
setGenCandidates(null);
setGenProjectId("");
setGenStatus("");
},
onError: (err) =>
toast.error("Failed to add items", { description: err.message }),
}
);
}, [genCandidates, items, addItemsMutation]);
// ── Thumbnail helper ────────────────────────────────────────────────────
const getThumbnail = (item: SessionItem) => {
const rev = item.deliverableStage.revisions?.[0];
if (!rev?.attachments) return null;
const att = rev.attachments as any;
const img = att.currentImage ?? att.referenceImage;
return img?.url ?? null;
};
// ── Render ──────────────────────────────────────────────────────────────
if (isLoading) {
return (
<div className="space-y-2 px-4 py-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-14 w-full rounded-lg" />
))}
</div>
);
}
return (
<div className="flex flex-col">
{/* ── Toolbar ────────────────────────────────────────────── */}
<div className="flex items-center justify-between border-b px-4 py-2">
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Session Items ({items.length})
</span>
<div className="flex items-center gap-1.5">
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
onClick={() => setGenerateOpen(true)}
>
<Wand2 className="mr-1 h-3 w-3" />
Auto-Fill
</Button>
</div>
</div>
{/* ── Item list ──────────────────────────────────────────── */}
{items.length === 0 && (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-center">
<Plus className="h-8 w-8 text-[var(--muted-foreground)]/30" />
<p className="text-xs text-[var(--muted-foreground)]">
No items in this session yet
</p>
<p className="max-w-xs text-[10px] text-[var(--muted-foreground)]/60">
Use Auto-Fill to add deliverable stages from a project, or add them
individually from the deliverable review page.
</p>
<Button
size="sm"
variant="outline"
className="mt-2 h-7 text-xs"
onClick={() => setGenerateOpen(true)}
>
<Wand2 className="mr-1.5 h-3 w-3" />
Auto-Fill from Project
</Button>
</div>
)}
<div className="flex-1 overflow-y-auto">
{items.map((item, index) => {
const thumb = getThumbnail(item);
const stage = item.deliverableStage;
const deliverable = stage.deliverable;
const artists = stage.assignments?.map((a) => a.user.name).join(", ");
return (
<div
key={item.id}
className={cn(
"group flex items-center gap-3 border-b px-4 py-2.5 transition-colors hover:bg-[var(--background)]/50",
item.decision === "APPROVED" &&
"bg-[var(--status-approved)]/5",
item.decision === "CHANGES_REQUESTED" &&
"bg-[var(--status-in-review)]/5"
)}
>
{/* Drag handle / order number */}
<div className="flex w-6 shrink-0 flex-col items-center gap-0.5">
<span className="text-[10px] font-mono text-[var(--muted-foreground)]">
{index + 1}
</span>
<GripVertical className="h-3 w-3 text-[var(--muted-foreground)]/40" />
</div>
{/* Thumbnail */}
<div className="h-10 w-14 shrink-0 overflow-hidden rounded border bg-[var(--muted)]/20">
{thumb ? (
<img
src={thumb}
alt=""
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<ImageIcon className="h-3.5 w-3.5 text-[var(--muted-foreground)]/30" />
</div>
)}
</div>
{/* Info */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-xs font-medium">
{deliverable.name}
</span>
<span className="text-[10px] text-[var(--muted-foreground)]">
{stage.stageDefinition?.name ?? stage.template.name}
</span>
</div>
<div className="mt-0.5 flex items-center gap-2">
<span className="text-[10px] text-[var(--muted-foreground)]">
{deliverable.project.projectCode}
</span>
<StageStatusBadge status={stage.status} className="text-[9px] px-1 py-0" />
{artists && (
<span className="truncate text-[10px] text-[var(--muted-foreground)]">
{artists}
</span>
)}
</div>
</div>
{/* Decision badge */}
{item.decision && (
<Badge
variant="secondary"
className={cn(
"shrink-0 text-[9px] uppercase",
item.decision === "APPROVED"
? "bg-[var(--status-approved)]/10 text-[var(--status-approved)]"
: "bg-amber-500/10 text-amber-600"
)}
>
{item.decision === "APPROVED" ? "Approved" : "Changes"}
</Badge>
)}
{/* Move / Remove actions */}
<div className="flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<Button
size="icon"
variant="ghost"
className="h-6 w-6"
disabled={index === 0}
onClick={() => moveItem(index, -1)}
aria-label="Move up"
>
<ChevronDown className="h-3 w-3 rotate-180" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-6 w-6"
disabled={index === items.length - 1}
onClick={() => moveItem(index, 1)}
aria-label="Move down"
>
<ChevronDown className="h-3 w-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-red-500 hover:text-red-600"
onClick={() => handleRemoveItem(item.id)}
aria-label="Remove"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
);
})}
</div>
{/* ── Auto-fill dialog ───────────────────────────────────── */}
<Dialog open={generateOpen} onOpenChange={setGenerateOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="font-heading text-sm font-semibold">
Auto-Fill from Project
</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<div>
<label className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Project
</label>
<Select value={genProjectId} onValueChange={setGenProjectId}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Select a project..." />
</SelectTrigger>
<SelectContent>
{projects.map((p: any) => (
<SelectItem key={p.id} value={p.id}>
{p.projectCode} {p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Stage Status (optional)
</label>
<Select value={genStatus} onValueChange={setGenStatus}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Any status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Any status</SelectItem>
{STAGE_STATUSES.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{!genCandidates && (
<Button
size="sm"
className="w-full"
onClick={handleGenerate}
disabled={generateMutation.isPending || !genProjectId}
>
{generateMutation.isPending ? "Searching..." : "Find Matching Stages"}
</Button>
)}
{genCandidates && (
<div className="space-y-2">
<Separator />
<p className="text-xs text-[var(--muted-foreground)]">
Found {genCandidates.length} matching{" "}
{genCandidates.length === 1 ? "stage" : "stages"}
</p>
<div className="max-h-48 space-y-1 overflow-y-auto">
{genCandidates.map((c: any) => (
<div
key={c.deliverableStageId}
className="rounded border px-2 py-1.5 text-xs"
>
{c.label}
</div>
))}
</div>
{genCandidates.length === 0 && (
<p className="text-xs text-[var(--muted-foreground)]">
No stages match the selected filters.
</p>
)}
</div>
)}
</div>
<DialogFooter>
<Button
variant="ghost"
size="sm"
onClick={() => {
setGenerateOpen(false);
setGenCandidates(null);
}}
>
Cancel
</Button>
{genCandidates && genCandidates.length > 0 && (
<Button
size="sm"
onClick={handleAddGenerated}
disabled={addItemsMutation.isPending}
>
{addItemsMutation.isPending
? "Adding..."
: `Add ${genCandidates.length} Items`}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -0,0 +1,544 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ChevronLeft,
ChevronRight,
Check,
RotateCcw,
MessageSquare,
X,
Maximize2,
Minimize2,
Image as ImageIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Separator } from "@/components/ui/separator";
import { StageStatusBadge } from "@/components/stages/stage-status-badge";
import { ImageViewer } from "@/components/review/image-viewer";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import {
useRecordDecision,
useClearDecision,
} from "@/hooks/use-review-sessions";
// ── Types ─────────────────────────────────────────────────────────────────
interface SessionItem {
id: string;
sortOrder: number;
decision: string | null;
decisionNote: string | null;
decidedBy: { id: string; name: string; image: string | null } | null;
decidedAt: string | null;
revisionId: string | null;
deliverableStage: {
id: string;
status: string;
template: { id: string; name: string; slug: string; order: number };
stageDefinition?: { id: string; name: string; slug: string; order: number } | null;
deliverable: {
id: string;
name: string;
priority: string;
project: { id: string; name: string; projectCode: string };
};
revisions: {
id: string;
roundNumber: number;
status: string;
attachments: any;
}[];
assignments: {
user: { id: string; name: string; image: string | null };
}[];
};
}
interface SessionPresenterProps {
sessionId: string;
items: SessionItem[];
sessionName: string;
onExit: () => void;
}
// ── Component ─────────────────────────────────────────────────────────────
export function SessionPresenter({
sessionId,
items,
sessionName,
onExit,
}: SessionPresenterProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const [decisionNote, setDecisionNote] = useState("");
const [showNote, setShowNote] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const recordDecision = useRecordDecision(sessionId);
const clearDecision = useClearDecision(sessionId);
const currentItem = items[currentIndex] ?? null;
// ── Image URL ───────────────────────────────────────────────────────────
const imageUrl = useMemo(() => {
if (!currentItem) return null;
const rev = currentItem.deliverableStage.revisions?.[0];
if (!rev?.attachments) return null;
const att = rev.attachments as any;
return att.currentImage?.url ?? att.referenceImage?.url ?? null;
}, [currentItem]);
// ── Progress ────────────────────────────────────────────────────────────
const progress = useMemo(() => {
const decided = items.filter((i) => i.decision != null).length;
return { decided, total: items.length };
}, [items]);
// ── Navigation ──────────────────────────────────────────────────────────
const goTo = useCallback(
(index: number) => {
if (index >= 0 && index < items.length) {
setCurrentIndex(index);
setDecisionNote("");
setShowNote(false);
}
},
[items.length]
);
const goPrev = useCallback(() => goTo(currentIndex - 1), [currentIndex, goTo]);
const goNext = useCallback(() => goTo(currentIndex + 1), [currentIndex, goTo]);
// ── Decisions ───────────────────────────────────────────────────────────
const handleDecision = useCallback(
(decision: "APPROVED" | "CHANGES_REQUESTED") => {
if (!currentItem) return;
recordDecision.mutate(
{
itemId: currentItem.id,
decision,
decisionNote: decisionNote.trim() || undefined,
},
{
onSuccess: () => {
toast.success(
decision === "APPROVED" ? "Approved" : "Changes requested"
);
setDecisionNote("");
setShowNote(false);
// Auto-advance to next undecided item
const nextUndecided = items.findIndex(
(item, idx) => idx > currentIndex && item.decision == null
);
if (nextUndecided !== -1) {
goTo(nextUndecided);
} else if (currentIndex < items.length - 1) {
goNext();
}
},
onError: (err) =>
toast.error("Failed to record decision", {
description: err.message,
}),
}
);
},
[currentItem, currentIndex, decisionNote, recordDecision, items, goTo, goNext]
);
const handleClearDecision = useCallback(() => {
if (!currentItem) return;
clearDecision.mutate(currentItem.id, {
onSuccess: () => toast.success("Decision cleared"),
onError: (err) =>
toast.error("Failed", { description: err.message }),
});
}, [currentItem, clearDecision]);
// ── Keyboard shortcuts ──────────────────────────────────────────────────
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement
)
return;
switch (e.key) {
case "ArrowLeft":
e.preventDefault();
goPrev();
break;
case "ArrowRight":
e.preventDefault();
goNext();
break;
case "a":
e.preventDefault();
handleDecision("APPROVED");
break;
case "c":
e.preventDefault();
handleDecision("CHANGES_REQUESTED");
break;
case "Escape":
e.preventDefault();
onExit();
break;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [goPrev, goNext, handleDecision, onExit]);
// ── Fullscreen ──────────────────────────────────────────────────────────
const toggleFullscreen = useCallback(() => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(() => {});
setIsFullscreen(true);
} else {
document.exitFullscreen().catch(() => {});
setIsFullscreen(false);
}
}, []);
useEffect(() => {
const handler = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener("fullscreenchange", handler);
return () => document.removeEventListener("fullscreenchange", handler);
}, []);
if (!currentItem) {
return (
<div className="flex h-full items-center justify-center text-[var(--muted-foreground)]">
No items to present.
</div>
);
}
const stage = currentItem.deliverableStage;
const deliverable = stage.deliverable;
const latestRev = stage.revisions?.[0];
return (
<div className="flex h-full flex-col bg-[var(--background)]">
{/* ── Top bar ──────────────────────────────────────────── */}
<div className="flex items-center justify-between border-b px-4 py-2">
{/* Left: session info + navigation */}
<div className="flex items-center gap-3">
<Button
size="sm"
variant="ghost"
className="h-7 text-xs"
onClick={onExit}
>
<X className="mr-1 h-3 w-3" />
Exit
</Button>
<Separator orientation="vertical" className="h-5" />
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{sessionName}
</span>
</div>
{/* Center: item navigation */}
<div className="flex items-center gap-2">
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
disabled={currentIndex <= 0}
onClick={goPrev}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="font-mono text-xs text-[var(--muted-foreground)]">
{currentIndex + 1} / {items.length}
</span>
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
disabled={currentIndex >= items.length - 1}
onClick={goNext}
>
<ChevronRight className="h-4 w-4" />
</Button>
<Separator orientation="vertical" className="h-5" />
{/* Progress */}
<div className="flex items-center gap-1.5">
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-[var(--muted)]">
<div
className="h-full rounded-full bg-[var(--primary)] transition-all"
style={{
width: `${progress.total > 0 ? (progress.decided / progress.total) * 100 : 0}%`,
}}
/>
</div>
<span className="text-[10px] text-[var(--muted-foreground)]">
{progress.decided}/{progress.total}
</span>
</div>
</div>
{/* Right: fullscreen toggle */}
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={toggleFullscreen}
>
{isFullscreen ? (
<Minimize2 className="h-3.5 w-3.5" />
) : (
<Maximize2 className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent className="text-xs">
{isFullscreen ? "Exit fullscreen" : "Fullscreen"}
</TooltipContent>
</Tooltip>
</div>
</div>
{/* ── Main content: viewer + info panel ────────────────── */}
<div className="flex flex-1 overflow-hidden">
{/* Image viewer */}
<div className="flex min-w-0 flex-1 flex-col">
{imageUrl ? (
<ImageViewer src={imageUrl} className="flex-1" />
) : (
<div className="flex flex-1 items-center justify-center bg-[var(--muted)]/10">
<div className="flex flex-col items-center gap-2 text-[var(--muted-foreground)]/40">
<ImageIcon className="h-12 w-12" />
<span className="text-xs">No image uploaded</span>
</div>
</div>
)}
</div>
{/* ── Right info panel ──────────────────────────────── */}
<div className="flex w-[320px] shrink-0 flex-col border-l bg-[var(--card)]">
{/* Item details */}
<div className="border-b px-4 py-3">
<div className="flex items-center gap-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{deliverable.project.projectCode}
</span>
</div>
<h2 className="mt-1 font-heading text-sm font-semibold">
{deliverable.name}
</h2>
<div className="mt-1.5 flex items-center gap-2">
<span className="text-xs text-[var(--muted-foreground)]">
{stage.stageDefinition?.name ?? stage.template.name}
</span>
<StageStatusBadge status={stage.status} className="text-[9px] px-1 py-0" />
</div>
{latestRev && (
<div className="mt-1 text-[10px] text-[var(--muted-foreground)]">
Round {latestRev.roundNumber} {latestRev.status.replace("_", " ")}
</div>
)}
{stage.assignments?.length > 0 && (
<div className="mt-2 flex items-center gap-1">
<span className="text-[10px] text-[var(--muted-foreground)]">
Assigned:
</span>
{stage.assignments.map((a) => (
<Badge
key={a.user.id}
variant="outline"
className="text-[10px] px-1.5 py-0"
>
{a.user.name}
</Badge>
))}
</div>
)}
</div>
{/* Decision area */}
<div className="flex flex-1 flex-col justify-end">
{/* Current decision display */}
{currentItem.decision && (
<div
className={cn(
"mx-4 mb-3 rounded-lg px-3 py-2",
currentItem.decision === "APPROVED"
? "bg-[var(--status-approved)]/10"
: "bg-amber-500/10"
)}
>
<div className="flex items-center justify-between">
<Badge
variant="secondary"
className={cn(
"text-[10px] uppercase",
currentItem.decision === "APPROVED"
? "bg-[var(--status-approved)]/20 text-[var(--status-approved)]"
: "bg-amber-500/20 text-amber-600"
)}
>
{currentItem.decision === "APPROVED"
? "Approved"
: "Changes Requested"}
</Badge>
<Button
size="sm"
variant="ghost"
className="h-6 text-[10px]"
onClick={handleClearDecision}
>
Clear
</Button>
</div>
{currentItem.decisionNote && (
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
{currentItem.decisionNote}
</p>
)}
{currentItem.decidedBy && (
<p className="mt-1 text-[10px] text-[var(--muted-foreground)]">
by {currentItem.decidedBy.name}
</p>
)}
</div>
)}
{/* Decision buttons */}
<div className="border-t px-4 py-3">
{showNote && (
<div className="mb-2">
<Textarea
value={decisionNote}
onChange={(e) => setDecisionNote(e.target.value)}
placeholder="Add a note (optional)..."
className="resize-none text-xs"
rows={2}
autoFocus
/>
</div>
)}
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
className="h-8 flex-1 text-xs text-amber-600 hover:bg-amber-500/10 hover:text-amber-700"
onClick={() => handleDecision("CHANGES_REQUESTED")}
disabled={recordDecision.isPending}
>
<RotateCcw className="mr-1.5 h-3 w-3" />
Changes
</Button>
<Button
size="sm"
className="h-8 flex-1 bg-[var(--status-approved)] text-xs text-white hover:bg-[var(--status-approved)]/90"
onClick={() => handleDecision("APPROVED")}
disabled={recordDecision.isPending}
>
<Check className="mr-1.5 h-3.5 w-3.5" />
Approve
</Button>
</div>
<div className="mt-1.5 flex justify-center">
<Button
size="sm"
variant="ghost"
className="h-6 text-[10px] text-[var(--muted-foreground)]"
onClick={() => setShowNote(!showNote)}
>
<MessageSquare className="mr-1 h-2.5 w-2.5" />
{showNote ? "Hide note" : "Add note"}
</Button>
</div>
<p className="mt-2 text-center text-[9px] text-[var(--muted-foreground)]/60">
Keyboard: A = approve, C = changes, &larr;&rarr; = navigate, Esc = exit
</p>
</div>
</div>
</div>
</div>
{/* ── Bottom thumbnail strip ───────────────────────────── */}
<div className="flex items-center gap-1 overflow-x-auto border-t px-3 py-1.5">
{items.map((item, idx) => {
const rev = item.deliverableStage.revisions?.[0];
const att = rev?.attachments as any;
const thumbUrl =
att?.currentImage?.url ?? att?.referenceImage?.url ?? null;
return (
<Tooltip key={item.id}>
<TooltipTrigger asChild>
<button
onClick={() => goTo(idx)}
className={cn(
"relative h-10 w-14 shrink-0 overflow-hidden rounded border transition-all",
idx === currentIndex
? "border-[var(--primary)] ring-1 ring-[var(--primary)]"
: "border-transparent opacity-60 hover:opacity-100",
item.decision === "APPROVED" &&
idx !== currentIndex &&
"border-[var(--status-approved)]/50",
item.decision === "CHANGES_REQUESTED" &&
idx !== currentIndex &&
"border-amber-500/50"
)}
>
{thumbUrl ? (
<img
src={thumbUrl}
alt=""
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-[var(--muted)]/20">
<ImageIcon className="h-3 w-3 text-[var(--muted-foreground)]/30" />
</div>
)}
{/* Decision dot */}
{item.decision && (
<div
className={cn(
"absolute right-0.5 top-0.5 h-2 w-2 rounded-full",
item.decision === "APPROVED"
? "bg-[var(--status-approved)]"
: "bg-amber-500"
)}
/>
)}
</button>
</TooltipTrigger>
<TooltipContent className="text-xs">
{item.deliverableStage.deliverable.name} {" "}
{item.deliverableStage.stageDefinition?.name ?? item.deliverableStage.template.name}
</TooltipContent>
</Tooltip>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,161 @@
"use client";
import { useMemo } from "react";
import {
Check,
RotateCcw,
Clock,
Image as ImageIcon,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { StageStatusBadge } from "@/components/stages/stage-status-badge";
import { cn } from "@/lib/utils";
// ── Types ─────────────────────────────────────────────────────────────────
interface SessionItem {
id: string;
sortOrder: number;
decision: string | null;
decisionNote: string | null;
decidedBy: { id: string; name: string; image: string | null } | null;
deliverableStage: {
id: string;
status: string;
template: { id: string; name: string; order: number };
stageDefinition?: { id: string; name: string; order: number } | null;
deliverable: {
id: string;
name: string;
priority: string;
project: { id: string; name: string; projectCode: string };
};
revisions: { id: string; roundNumber: number; attachments: any }[];
assignments: { user: { id: string; name: string; image: string | null } }[];
};
}
interface SessionSummaryProps {
items: SessionItem[];
onItemClick?: (index: number) => void;
}
// ── Component ─────────────────────────────────────────────────────────────
export function SessionSummary({ items, onItemClick }: SessionSummaryProps) {
const stats = useMemo(() => {
const approved = items.filter((i) => i.decision === "APPROVED").length;
const changes = items.filter(
(i) => i.decision === "CHANGES_REQUESTED"
).length;
const pending = items.filter((i) => i.decision == null).length;
return { approved, changes, pending, total: items.length };
}, [items]);
return (
<div>
{/* ── Stats strip ──────────────────────────────────────── */}
<div className="mb-4 flex items-center gap-4">
<div className="flex items-center gap-1.5">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-[var(--status-approved)]/10">
<Check className="h-3 w-3 text-[var(--status-approved)]" />
</div>
<span className="text-xs font-medium">
{stats.approved} Approved
</span>
</div>
<div className="flex items-center gap-1.5">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-amber-500/10">
<RotateCcw className="h-3 w-3 text-amber-600" />
</div>
<span className="text-xs font-medium">
{stats.changes} Changes
</span>
</div>
<div className="flex items-center gap-1.5">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-[var(--muted)]/50">
<Clock className="h-3 w-3 text-[var(--muted-foreground)]" />
</div>
<span className="text-xs font-medium">
{stats.pending} Pending
</span>
</div>
</div>
{/* ── Thumbnail grid ───────────────────────────────────── */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{items.map((item, idx) => {
const rev = item.deliverableStage.revisions?.[0];
const att = rev?.attachments as any;
const thumbUrl =
att?.currentImage?.url ?? att?.referenceImage?.url ?? null;
const stage = item.deliverableStage;
return (
<button
key={item.id}
onClick={() => onItemClick?.(idx)}
className={cn(
"group relative overflow-hidden rounded-lg border bg-[var(--card)] text-left transition-all hover:shadow-md",
item.decision === "APPROVED" &&
"border-[var(--status-approved)]/40",
item.decision === "CHANGES_REQUESTED" &&
"border-amber-500/40",
item.decision == null && "border-[var(--border)]"
)}
>
{/* Thumbnail */}
<div className="aspect-[4/3] overflow-hidden bg-[var(--muted)]/10">
{thumbUrl ? (
<img
src={thumbUrl}
alt=""
className="h-full w-full object-cover transition-transform group-hover:scale-105"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<ImageIcon className="h-6 w-6 text-[var(--muted-foreground)]/20" />
</div>
)}
</div>
{/* Decision overlay badge */}
{item.decision && (
<div className="absolute right-1 top-1">
<Badge
variant="secondary"
className={cn(
"text-[9px] uppercase shadow-sm",
item.decision === "APPROVED"
? "bg-[var(--status-approved)] text-white"
: "bg-amber-500 text-white"
)}
>
{item.decision === "APPROVED" ? (
<Check className="mr-0.5 h-2.5 w-2.5" />
) : (
<RotateCcw className="mr-0.5 h-2.5 w-2.5" />
)}
{item.decision === "APPROVED" ? "OK" : "Changes"}
</Badge>
</div>
)}
{/* Info */}
<div className="px-2 py-1.5">
<p className="truncate text-[11px] font-medium">
{stage.deliverable.name}
</p>
<div className="mt-0.5 flex items-center gap-1">
<span className="truncate text-[10px] text-[var(--muted-foreground)]">
{stage.stageDefinition?.name ?? stage.template.name}
</span>
</div>
</div>
</button>
);
})}
</div>
</div>
);
}

View file

@ -50,7 +50,7 @@ const FIGMA_URL_RE =
function figmaEmbedUrl(raw: string): string | null {
if (!FIGMA_URL_RE.test(raw)) return null;
return `https://www.figma.com/embed?embed_host=dow-prod-tracker&url=${encodeURIComponent(raw)}`;
return `https://www.figma.com/embed?embed_host=loreal-prod-tracker&url=${encodeURIComponent(raw)}`;
}
function isImage(mime: string | null): boolean {

View file

@ -0,0 +1,226 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { CheckCircle2, MessageSquare, Plus, RotateCcw, Send, XCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { FeedbackProgressBar } from "@/components/review/feedback-progress-bar";
import { FeedbackChecklist } from "@/components/review/feedback-checklist";
import { useFeedbackSummary } from "@/hooks/use-feedback";
import { useRevisions, useCreateRevision } from "@/hooks/use-revisions";
import { useUpdateStageStatus } from "@/hooks/use-deliverables";
import { apiUrl } from "@/lib/api-client";
import { toast } from "sonner";
type ApprovalType = "NONE" | "SIMPLE" | "FORMAL";
interface StageReviewPanelProps {
stageId: string;
approvalType: ApprovalType;
stageStatus: string;
projectId: string;
deliverableId: string;
canEdit: boolean;
}
// Per-stage review surface. Renders nothing when approvalType=NONE.
// SIMPLE → "New version" button + Approve / Request changes pair.
// FORMAL → progress bar + collapsible feedback checklist + revision controls
// + link to the full annotation review page.
export function StageReviewPanel({
stageId,
approvalType,
stageStatus,
projectId,
deliverableId,
canEdit,
}: StageReviewPanelProps) {
const [showChecklist, setShowChecklist] = useState(false);
const [boxPushing, setBoxPushing] = useState(false);
const summary = useFeedbackSummary(stageId);
const revisionsQ = useRevisions(stageId);
const createRevision = useCreateRevision(stageId);
const updateStageStatus = useUpdateStageStatus();
if (approvalType === "NONE") return null;
const revisions = (revisionsQ.data as any[]) ?? [];
const latest = revisions[0];
const nextRound = (latest?.roundNumber ?? 0) + 1;
async function handleNewRevision() {
try {
await createRevision.mutateAsync({} as any);
toast.success(`Started revision v${nextRound}`);
} catch (e: any) {
toast.error(e?.message || "Failed to start new revision");
}
}
async function handleApprove() {
try {
await updateStageStatus.mutateAsync({ stageId, status: "APPROVED" });
toast.success("Stage approved");
} catch (e: any) {
toast.error(e?.message || "Failed to approve");
}
}
async function handleRequestChanges() {
try {
await updateStageStatus.mutateAsync({ stageId, status: "CHANGES_REQUESTED" });
toast.success("Changes requested");
} catch (e: any) {
toast.error(e?.message || "Failed to record changes");
}
}
// Manual Box push — admin trigger. Auto-fires on the
// NOT_APPROVED → APPROVED transition (see deliverable-status-service),
// so this button is mainly for re-push after failure or for first-time
// send on a deliverable that was approved before Box was configured.
async function handleBoxPush() {
setBoxPushing(true);
try {
const res = await fetch(apiUrl(`/api/deliverables/${deliverableId}/box-push`), {
method: "POST",
});
const body = await res.json();
if (res.ok && body.ok) {
toast.success(
`Pushed ${body.uploadedFiles ?? 0} file(s) to Box (folder ${body.boxFolderId})`
);
} else {
toast.error(body.error || `Box push failed (${res.status})`);
}
} catch (e: any) {
toast.error(e?.message || "Box push failed");
} finally {
setBoxPushing(false);
}
}
const stats = summary.data ?? { total: 0, resolved: 0, open: 0, infoCount: 0 };
const reviewHref = `/projects/${projectId}/deliverables/${deliverableId}/review?stage=${stageId}`;
return (
<div className="mt-2 flex flex-col gap-2 rounded-md border border-dashed border-[var(--border)] bg-[var(--accent)]/30 p-2">
{/* Header row — version badge + counts (FORMAL only) + actions */}
<div className="flex flex-wrap items-center gap-2 text-xs">
{latest ? (
<Badge variant="outline" className="h-5 px-1.5">
v{latest.roundNumber}
</Badge>
) : (
<span className="text-[var(--muted-foreground)]">No version yet</span>
)}
{approvalType === "FORMAL" && stats.total > 0 && (
<div className="flex items-center gap-1 text-[var(--muted-foreground)]">
<MessageSquare className="h-3 w-3" />
<span>
{stats.open} open · {stats.resolved} resolved
</span>
</div>
)}
<div className="ml-auto flex flex-wrap gap-1">
{canEdit && (
<Button
size="sm"
variant="outline"
className="h-6 px-2 text-[12px]"
disabled={createRevision.isPending}
onClick={handleNewRevision}
>
<Plus className="mr-1 h-3 w-3" />
v{nextRound}
</Button>
)}
{approvalType === "FORMAL" && latest && (
<Button
asChild
size="sm"
variant="outline"
className="h-6 px-2 text-[12px]"
>
<Link href={reviewHref}>Open review</Link>
</Button>
)}
{canEdit && stageStatus !== "APPROVED" && (
<Button
size="sm"
className="h-6 px-2 text-[12px]"
disabled={updateStageStatus.isPending}
onClick={handleApprove}
>
<CheckCircle2 className="mr-1 h-3 w-3" />
Approve
</Button>
)}
{canEdit && stageStatus !== "CHANGES_REQUESTED" && stageStatus !== "APPROVED" && (
<Button
size="sm"
variant="outline"
className="h-6 px-2 text-[12px]"
disabled={updateStageStatus.isPending}
onClick={handleRequestChanges}
>
<XCircle className="mr-1 h-3 w-3" />
Request changes
</Button>
)}
{canEdit && stageStatus === "APPROVED" && (
<>
<Button
size="sm"
className="h-6 px-2 text-[12px]"
disabled={boxPushing}
onClick={handleBoxPush}
>
<Send className="mr-1 h-3 w-3" />
{boxPushing ? "Sending…" : "Send to client (Box)"}
</Button>
<Button
size="sm"
variant="outline"
className="h-6 px-2 text-[12px]"
disabled={updateStageStatus.isPending}
onClick={() =>
updateStageStatus.mutate({ stageId, status: "IN_PROGRESS" })
}
>
<RotateCcw className="mr-1 h-3 w-3" />
Reopen
</Button>
</>
)}
</div>
</div>
{/* Progress bar + collapsible checklist (FORMAL only) */}
{approvalType === "FORMAL" && (
<>
<FeedbackProgressBar total={stats.total} resolved={stats.resolved} />
{stats.total > 0 && (
<button
type="button"
className="self-start text-[12px] text-[var(--muted-foreground)] underline-offset-2 hover:underline"
onClick={() => setShowChecklist((v) => !v)}
>
{showChecklist ? "Hide checklist" : "Show checklist"}
</button>
)}
{showChecklist && latest && (
<FeedbackChecklist stageId={stageId} revisionId={latest.id} />
)}
</>
)}
</div>
);
}

View file

@ -1,13 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
// Dow Jones navy #002B5C at 15% opacity — matches the logo background
// and gives a gentle brand-blue pulse instead of the inherited
// Oliver/HP coral that read as "red error state" during load.
// L'Oréal black at 15% opacity — matches the logo background and gives
// a gentle pulse instead of the inherited Oliver/HP coral that read as
// "red error state" during load.
return (
<div
data-slot="skeleton"
className={cn("bg-[#002B5C]/15 animate-pulse rounded-md", className)}
className={cn("bg-black/15 animate-pulse rounded-md", className)}
{...props}
/>
)

126
src/hooks/use-feedback.ts Normal file
View file

@ -0,0 +1,126 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiUrl } from "@/lib/api-client";
import type {
CreateFeedbackInput,
UpdateFeedbackInput,
ResolveFeedbackInput,
} from "@/lib/validators/feedback";
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(apiUrl(url), init);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Request failed: ${res.status}`);
}
return res.json();
}
export function useFeedbackItems(stageId: string, revisionId?: string) {
const params = new URLSearchParams();
if (revisionId) params.set("revisionId", revisionId);
return useQuery({
queryKey: ["feedback", stageId, revisionId ?? "all"],
queryFn: () =>
fetchJson<any[]>(
`/api/stages/${stageId}/feedback${params.toString() ? `?${params}` : ""}`
),
enabled: !!stageId,
});
}
export function useFeedbackSummary(stageId: string) {
return useQuery({
queryKey: ["feedback-summary", stageId],
queryFn: () =>
fetchJson<{
total: number;
resolved: number;
open: number;
infoCount: number;
}>(`/api/stages/${stageId}/feedback?summary=true`),
enabled: !!stageId,
});
}
function invalidateFeedback(qc: ReturnType<typeof useQueryClient>, stageId: string) {
qc.invalidateQueries({ queryKey: ["feedback", stageId] });
qc.invalidateQueries({ queryKey: ["feedback-summary", stageId] });
qc.invalidateQueries({ queryKey: ["deliverables"] });
}
export function useCreateFeedback(stageId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: CreateFeedbackInput) =>
fetchJson(`/api/stages/${stageId}/feedback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
onSuccess: () => invalidateFeedback(qc, stageId),
});
}
export function useUpdateFeedback(stageId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ itemId, data }: { itemId: string; data: UpdateFeedbackInput }) =>
fetchJson(`/api/feedback/${itemId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
onSuccess: () => invalidateFeedback(qc, stageId),
});
}
export function useResolveFeedback(stageId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ itemId, data }: { itemId: string; data: ResolveFeedbackInput }) =>
fetchJson(`/api/feedback/${itemId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "resolve", ...data }),
}),
onSuccess: () => invalidateFeedback(qc, stageId),
});
}
export function useVerifyFeedback(stageId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (itemId: string) =>
fetchJson(`/api/feedback/${itemId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "verify" }),
}),
onSuccess: () => invalidateFeedback(qc, stageId),
});
}
export function useReopenFeedback(stageId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (itemId: string) =>
fetchJson(`/api/feedback/${itemId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "reopen" }),
}),
onSuccess: () => invalidateFeedback(qc, stageId),
});
}
export function useDeleteFeedback(stageId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (itemId: string) =>
fetchJson(`/api/feedback/${itemId}`, { method: "DELETE" }),
onSuccess: () => invalidateFeedback(qc, stageId),
});
}

View file

@ -0,0 +1,155 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiUrl } from "@/lib/api-client";
import type {
CreateReviewSessionInput,
UpdateReviewSessionInput,
RecordDecisionInput,
} from "@/lib/validators/review-session";
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(apiUrl(url), init);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Request failed: ${res.status}`);
}
return res.json();
}
export function useReviewSessions(status?: string) {
const params = new URLSearchParams();
if (status) params.set("status", status);
return useQuery({
queryKey: ["review-sessions", status ?? "all"],
queryFn: () =>
fetchJson<any[]>(`/api/reviews${params.toString() ? `?${params}` : ""}`),
});
}
export function useReviewSession(sessionId: string) {
return useQuery({
queryKey: ["review-session", sessionId],
queryFn: () => fetchJson<any>(`/api/reviews/${sessionId}`),
enabled: !!sessionId,
});
}
export function useCreateReviewSession() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: CreateReviewSessionInput) =>
fetchJson("/api/reviews", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ["review-sessions"] }),
});
}
export function useUpdateReviewSession(sessionId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: UpdateReviewSessionInput) =>
fetchJson(`/api/reviews/${sessionId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["review-session", sessionId] });
qc.invalidateQueries({ queryKey: ["review-sessions"] });
},
});
}
export function useDeleteReviewSession() {
const qc = useQueryClient();
return useMutation({
mutationFn: (sessionId: string) =>
fetchJson(`/api/reviews/${sessionId}`, { method: "DELETE" }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["review-sessions"] }),
});
}
export function useAddSessionItems(sessionId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (items: { deliverableStageId: string; revisionId?: string }[]) =>
fetchJson(`/api/reviews/${sessionId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "add-items", items }),
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ["review-session", sessionId] }),
});
}
export function useRemoveSessionItem(sessionId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (itemId: string) =>
fetchJson(`/api/reviews/${sessionId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "remove-item", itemId }),
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ["review-session", sessionId] }),
});
}
export function useReorderSessionItems(sessionId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (itemIds: string[]) =>
fetchJson(`/api/reviews/${sessionId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "reorder", itemIds }),
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ["review-session", sessionId] }),
});
}
export function useRecordDecision(sessionId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: RecordDecisionInput) =>
fetchJson(`/api/reviews/${sessionId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "decide", ...data }),
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ["review-session", sessionId] }),
});
}
export function useClearDecision(sessionId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (itemId: string) =>
fetchJson(`/api/reviews/${sessionId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "clear-decision", itemId }),
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ["review-session", sessionId] }),
});
}
export function useGenerateSessionItems(sessionId: string) {
return useMutation({
mutationFn: (data: {
projectId: string;
stageStatus?: string;
stageTemplateId?: string;
}) =>
fetchJson(`/api/reviews/${sessionId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "generate", ...data }),
}),
});
}

View file

@ -0,0 +1,84 @@
import { createHash } from "crypto";
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
const TTL_MS = 24 * 60 * 60 * 1000;
function hashBody(body: string): string {
return createHash("sha256").update(body).digest("hex");
}
/**
* If the caller supplied an `Idempotency-Key` header and a record exists
* for (key, routePath), returns the cached NextResponse. Otherwise returns
* null and the caller proceeds to execute the request.
*
* If the cached record's requestHash differs from this request's body
* hash, returns a 409 keys must not be reused for different payloads.
*/
export async function checkIdempotency(
request: Request,
routePath: string,
rawBody: string
): Promise<NextResponse | null> {
const key = request.headers.get("idempotency-key");
if (!key) return null;
const requestHash = hashBody(rawBody);
const existing = await prisma.idempotencyRecord.findUnique({
where: { key_route: { key, route: routePath } },
});
if (!existing) return null;
// Stale records are pruned by a cron sweep but we also expire on read
// to avoid replaying a 24-hour-old response.
if (Date.now() - existing.createdAt.getTime() > TTL_MS) {
await prisma.idempotencyRecord
.delete({ where: { key_route: { key, route: routePath } } })
.catch(() => {});
return null;
}
if (existing.requestHash !== requestHash) {
return NextResponse.json(
{
error:
"Idempotency-Key reused with a different payload. Use a new key or send the same payload.",
},
{ status: 409 }
);
}
return NextResponse.json(existing.responseBody, { status: existing.statusCode });
}
/**
* Stores a successful response keyed by Idempotency-Key + route, so a
* retried request returns the same payload without re-executing.
*/
export async function recordIdempotency(
request: Request,
routePath: string,
rawBody: string,
responseBody: unknown,
statusCode: number
): Promise<void> {
const key = request.headers.get("idempotency-key");
if (!key) return;
await prisma.idempotencyRecord
.upsert({
where: { key_route: { key, route: routePath } },
create: {
key,
route: routePath,
requestHash: hashBody(rawBody),
responseBody: responseBody as any,
statusCode,
},
update: {},
})
.catch((err) => console.error("[idempotency] failed to record:", err));
}

View file

@ -3,5 +3,5 @@
import { signOut } from "@/lib/auth";
export async function signOutAction() {
await signOut({ redirectTo: "/dow-prod-tracker/login" });
await signOut({ redirectTo: "/loreal-prod-tracker/login" });
}

View file

@ -40,6 +40,6 @@ export const { handlers, auth, signOut } = NextAuth({
},
},
pages: {
signIn: "/dow-prod-tracker/login",
signIn: "/loreal-prod-tracker/login",
},
});

View file

@ -31,7 +31,7 @@ export interface ChatResponse {
fallback?: boolean;
}
const SYSTEM_PROMPT = `You are an AI assistant for the Dow Jones Studio Tracker — a tool used by producers to manage CG rendering projects for HP products.
const SYSTEM_PROMPT = `You are an AI assistant for the L'Oréal Studio Tracker — a tool used by producers to manage creative production projects for L'Oréal brands.
## Your Role
You help producers by:

View file

@ -13,7 +13,9 @@ export type OrgScopedModel =
| "automationRule"
| "user"
| "clientTeam"
| "pod";
| "pod"
| "reviewSession"
| "feedbackItem";
export async function assertOrgAccess(
model: OrgScopedModel,
@ -128,6 +130,32 @@ export async function assertOrgAccess(
orgId = pod.organizationId;
break;
}
case "reviewSession": {
const rs = await prisma.reviewSession.findUnique({
where: { id: resourceId },
select: { organizationId: true },
});
if (!rs) throw new OrgAccessError("Review session not found");
orgId = rs.organizationId;
break;
}
case "feedbackItem": {
const fb = await prisma.feedbackItem.findUnique({
where: { id: resourceId },
select: {
deliverableStage: {
select: {
deliverable: {
select: { project: { select: { organizationId: true } } },
},
},
},
},
});
if (!fb) throw new OrgAccessError("Feedback item not found");
orgId = fb.deliverableStage.deliverable.project.organizationId;
break;
}
}
if (orgId !== organizationId) {

View file

@ -4,6 +4,7 @@ import { mkdir } from "fs/promises";
import { prisma } from "@/lib/prisma";
import type { CreateAnnotationInput, UpdateAnnotationInput } from "@/lib/validators/annotation";
import { extractThumbnail, isFFmpegAvailable } from "@/lib/services/video-service";
import { createFeedbackFromAnnotation } from "@/lib/services/feedback-service";
const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
@ -82,6 +83,20 @@ export async function createAnnotation(
);
}
// Auto-create a feedback item linked to this annotation. Fire-and-forget —
// a feedback failure must not block annotation creation (HP convention).
createFeedbackFromAnnotation(
input.stageId,
revisionId,
result.id,
result.commentId,
input.commentContent,
userId,
true
).catch((err) =>
console.error("[Annotation] Auto-feedback creation failed:", err)
);
return result;
}

View file

@ -0,0 +1,243 @@
import path from "path";
import fs from "fs";
import { mkdir } from "fs/promises";
import { prisma } from "@/lib/prisma";
import {
getBoxFileMetadata,
downloadBoxFile,
} from "@/lib/services/box/box-jwt-client";
import { parseInboundFileName } from "@/lib/services/external-delivery-service";
import { createNotification } from "@/lib/services/notification-service";
const UPLOADS_DIR =
process.env.VIDEO_UPLOADS_DIR ||
(process.env.NODE_ENV === "production"
? "/data/uploads/revisions"
: path.join(process.cwd(), "data", "uploads", "revisions"));
const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
interface IngestResult {
ok: boolean;
status: "MATCHED" | "UNMATCHED" | "ERROR";
revisionId?: string;
deliverableId?: string;
error?: string;
}
function slugify(s: string): string {
return s
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
/**
* Ingest a single Box file event. Fetches metadata, parses the filename
* against the naming convention, finds the matching Deliverable, downloads
* the bytes locally, creates a new Revision, attaches the file, and either
* transitions the stage to IN_REVIEW (assignee present) or notifies the
* project/deliverable owner (no assignee).
*/
export async function ingestBoxUpload(fileId: string): Promise<IngestResult> {
let fileName = "";
try {
const meta = await getBoxFileMetadata(fileId);
fileName = meta.name;
const parsed = parseInboundFileName(meta.name);
if (!parsed) {
await logUnmatched(fileId, meta.name, "Filename did not match naming convention");
await notifyOwnersOfUnmatched(meta.name);
return { ok: true, status: "UNMATCHED" };
}
// Resolve project by OMG #
const project = await prisma.project.findFirst({
where: { omgJobNumber: parsed.omgJobNumber },
select: { id: true, organizationId: true, requestorUserId: true },
});
if (!project) {
await logUnmatched(
fileId,
meta.name,
`No project for OMG # ${parsed.omgJobNumber}`
);
await notifyOwnersOfUnmatched(meta.name);
return { ok: true, status: "UNMATCHED" };
}
// Resolve deliverable: try boxAliasSlug first, then slugified name.
const deliverables = await prisma.deliverable.findMany({
where: { projectId: project.id },
include: {
stages: {
include: {
stageDefinition: { select: { id: true, order: true, approvalType: true } },
template: { select: { order: true } },
assignments: { select: { userId: true } },
revisions: { orderBy: { roundNumber: "desc" }, take: 1 },
},
},
},
});
const deliverable =
deliverables.find((d) => d.boxAliasSlug === parsed.slug) ??
deliverables.find((d) => slugify(d.name) === parsed.slug);
if (!deliverable) {
await logUnmatched(
fileId,
meta.name,
`No deliverable for slug ${parsed.slug} on project ${project.id}`
);
await notifyOwnersOfUnmatched(meta.name, project.id);
return { ok: true, status: "UNMATCHED" };
}
// Pick the receiving stage: first stage with approvalType !== NONE that
// hasn't been APPROVED yet. Falls back to the first non-approved stage.
const sortedStages = deliverable.stages
.slice()
.sort(
(a, b) =>
(a.stageDefinition?.order ?? a.template?.order ?? 0) -
(b.stageDefinition?.order ?? b.template?.order ?? 0)
);
const receivingStage =
sortedStages.find(
(s) =>
s.status !== "APPROVED" &&
s.stageDefinition?.approvalType &&
s.stageDefinition.approvalType !== "NONE"
) ??
sortedStages.find((s) => s.status !== "APPROVED") ??
sortedStages[0];
if (!receivingStage) {
await logUnmatched(fileId, meta.name, "Deliverable has no stages");
return { ok: true, status: "UNMATCHED" };
}
// Determine roundNumber for the new revision.
const lastRevision = receivingStage.revisions[0];
const nextRound =
parsed.version ?? (lastRevision ? lastRevision.roundNumber + 1 : 1);
// Create the revision first so we have an id for the local file path.
const revision = await prisma.revision.create({
data: {
deliverableStageId: receivingStage.id,
roundNumber: nextRound,
status: "SUBMITTED",
attachments: {},
},
});
// Download bytes locally so the app can serve them through /api/uploads.
const targetDir = path.join(UPLOADS_DIR, revision.id);
await mkdir(targetDir, { recursive: true });
const targetPath = path.join(targetDir, meta.name);
await downloadBoxFile(fileId, targetPath);
const localUrl = `${BASE_PATH}/api/uploads/revisions/${revision.id}/${meta.name}`;
// Wire the attachment shape — Box files don't fit cleanly into the
// existing "current/reference/video" buckets, so store under a "box"
// key with the source file id. Existing UI tolerates extra keys.
await prisma.revision.update({
where: { id: revision.id },
data: {
attachments: {
box: { url: localUrl, boxFileId: fileId, fileName: meta.name },
},
boxFolderId: meta.parent.id,
},
});
// Stage routing: assignee present → transition to IN_REVIEW + notify
// assignee; no assignee → notify project owner.
if (receivingStage.assignments.length > 0) {
await prisma.deliverableStage.update({
where: { id: receivingStage.id },
data: { status: "IN_REVIEW" },
});
for (const a of receivingStage.assignments) {
await createNotification({
userId: a.userId,
type: "REVISION_SUBMITTED",
title: `New version received for review`,
message: `${deliverable.name} v${nextRound} arrived from Box. Open to review.`,
link: `/projects/${project.id}/deliverables/${deliverable.id}/review?stage=${receivingStage.id}`,
});
}
} else if (project.requestorUserId) {
await createNotification({
userId: project.requestorUserId,
type: "NEW_FILE_AWAITING_REVIEWER",
title: `New file ready — needs a reviewer`,
message: `${deliverable.name} v${nextRound} arrived from Box but no one is assigned to this stage. Assign a reviewer to start QC.`,
link: `/projects/${project.id}/deliverables/${deliverable.id}`,
});
}
await prisma.boxInboundLog.create({
data: {
boxFileId: fileId,
fileName: meta.name,
matchedDeliverableId: deliverable.id,
matchedProjectId: project.id,
status: "MATCHED",
},
});
return {
ok: true,
status: "MATCHED",
revisionId: revision.id,
deliverableId: deliverable.id,
};
} catch (e) {
const err = e as Error;
await prisma.boxInboundLog.create({
data: {
boxFileId: fileId,
fileName: fileName || "(unknown)",
status: "ERROR",
error: err.message,
},
});
return { ok: false, status: "ERROR", error: err.message };
}
}
async function logUnmatched(boxFileId: string, fileName: string, reason: string) {
await prisma.boxInboundLog.create({
data: {
boxFileId,
fileName,
status: "UNMATCHED",
error: reason,
},
});
}
async function notifyOwnersOfUnmatched(fileName: string, projectId?: string) {
// Notify the project owner if we know it, otherwise all ADMINs in the
// org (project unknown means we can't even scope it — admin sweep is
// the only fallback).
const admins = await prisma.user.findMany({
where: { role: "ADMIN" },
select: { id: true },
});
for (const a of admins) {
await createNotification({
userId: a.id,
type: "BOX_UNMATCHED_FILE",
title: `Unmatched file in Box watch folder`,
message: `"${fileName}" arrived but didn't match a deliverable. Check the file name follows {omgJobNumber}_{slug}_v{n} or set a boxAliasSlug.`,
link: projectId ? `/projects/${projectId}` : `/settings/box`,
});
}
}

View file

@ -0,0 +1,228 @@
import fs from "fs";
import crypto from "crypto";
import { SignJWT, importPKCS8 } from "jose";
// Box JWT app config — downloaded from the Box developer console as a
// JSON file. We mount it as a docker secret and point BOX_CONFIG_FILE at
// the path. The shape below matches what Box emits verbatim.
interface BoxConfig {
boxAppSettings: {
clientID: string;
clientSecret: string;
appAuth: {
publicKeyID: string;
privateKey: string;
passphrase: string;
};
};
enterpriseID: string;
}
let cachedConfig: BoxConfig | null = null;
let cachedToken: { accessToken: string; expiresAt: number } | null = null;
function loadConfig(): BoxConfig {
if (cachedConfig) return cachedConfig;
const path = process.env.BOX_CONFIG_FILE;
if (!path) throw new Error("BOX_CONFIG_FILE env var not set");
if (!fs.existsSync(path)) throw new Error(`Box config not found at ${path}`);
const raw = fs.readFileSync(path, "utf8");
cachedConfig = JSON.parse(raw) as BoxConfig;
return cachedConfig;
}
// Decrypts the passphrase-protected PEM private key from the Box config
// and re-emits an unencrypted PKCS#8 PEM that jose can consume.
function decryptPrivateKey(cfg: BoxConfig): string {
const { privateKey, passphrase } = cfg.boxAppSettings.appAuth;
const keyObject = crypto.createPrivateKey({ key: privateKey, passphrase });
return keyObject.export({ type: "pkcs8", format: "pem" }).toString();
}
/**
* Returns a valid Box access token, refreshing if expired.
* Tokens are 60-minute Box-issued bearer tokens. We renew 60s early to
* avoid the boundary case.
*/
export async function getBoxAccessToken(): Promise<string> {
if (cachedToken && Date.now() < cachedToken.expiresAt - 60_000) {
return cachedToken.accessToken;
}
const cfg = loadConfig();
const pkcs8 = decryptPrivateKey(cfg);
const privateKey = await importPKCS8(pkcs8, "RS256");
const jwt = await new SignJWT({
box_sub_type: "enterprise",
})
.setProtectedHeader({ alg: "RS256", kid: cfg.boxAppSettings.appAuth.publicKeyID })
.setIssuer(cfg.boxAppSettings.clientID)
.setSubject(cfg.enterpriseID)
.setAudience("https://api.box.com/oauth2/token")
.setJti(crypto.randomUUID())
.setExpirationTime("30s")
.sign(privateKey);
const res = await fetch("https://api.box.com/oauth2/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion: jwt,
client_id: cfg.boxAppSettings.clientID,
client_secret: cfg.boxAppSettings.clientSecret,
}),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Box token exchange failed: ${res.status} ${body}`);
}
const data = (await res.json()) as { access_token: string; expires_in: number };
cachedToken = {
accessToken: data.access_token,
expiresAt: Date.now() + data.expires_in * 1000,
};
return cachedToken.accessToken;
}
interface BoxFolder {
id: string;
name: string;
}
interface BoxFile {
id: string;
name: string;
parent: { id: string };
}
async function boxFetch(path: string, init: RequestInit = {}): Promise<Response> {
const token = await getBoxAccessToken();
return fetch(`https://api.box.com${path}`, {
...init,
headers: {
...init.headers,
Authorization: `Bearer ${token}`,
},
});
}
/**
* Creates a sub-folder inside `parentFolderId`. If a folder with the same
* name already exists, returns the existing one (Box returns 409 with
* conflicts[].id which we follow).
*/
export async function createOrGetBoxFolder(
parentFolderId: string,
name: string
): Promise<BoxFolder> {
const res = await boxFetch("/2.0/folders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, parent: { id: parentFolderId } }),
});
if (res.ok) return (await res.json()) as BoxFolder;
if (res.status === 409) {
const body = (await res.json()) as {
context_info?: { conflicts?: BoxFolder[] };
};
const conflict = body.context_info?.conflicts?.[0];
if (conflict) return conflict;
}
const errBody = await res.text();
throw new Error(`Box folder create failed: ${res.status} ${errBody}`);
}
/**
* Uploads a local file to a Box folder. Returns the new file's id + name.
* Uses Box's content upload endpoint (multipart/form-data, host
* `upload.box.com`).
*/
export async function uploadFileToBox(
localPath: string,
folderId: string,
fileName: string
): Promise<BoxFile> {
const token = await getBoxAccessToken();
const fileBuffer = fs.readFileSync(localPath);
const form = new FormData();
form.append(
"attributes",
JSON.stringify({ name: fileName, parent: { id: folderId } })
);
form.append("file", new Blob([fileBuffer]), fileName);
const res = await fetch("https://upload.box.com/api/2.0/files/content", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: form,
});
if (!res.ok) {
const errBody = await res.text();
throw new Error(`Box file upload failed: ${res.status} ${errBody}`);
}
const data = (await res.json()) as { entries: BoxFile[] };
return data.entries[0];
}
interface BoxFileMeta {
id: string;
name: string;
parent: { id: string };
size: number;
}
export async function getBoxFileMetadata(fileId: string): Promise<BoxFileMeta> {
const res = await boxFetch(`/2.0/files/${fileId}`);
if (!res.ok) {
throw new Error(`Box file metadata fetch failed: ${res.status}`);
}
return (await res.json()) as BoxFileMeta;
}
/**
* Downloads a Box file's contents to a local path. Returns the byte
* length written. Box returns a 302 to a CDN url first `fetch` follows
* redirects automatically.
*/
export async function downloadBoxFile(
fileId: string,
localPath: string
): Promise<number> {
const res = await boxFetch(`/2.0/files/${fileId}/content`);
if (!res.ok) {
throw new Error(`Box file download failed: ${res.status}`);
}
const buf = Buffer.from(await res.arrayBuffer());
fs.writeFileSync(localPath, buf);
return buf.length;
}
/**
* Returns true when Box is configured (BOX_CONFIG_FILE points at a real
* JWT app config not the `{}` stub deploy.sh creates by default).
* Used by UI gates that hide the "Send to client" button when Box hasn't
* been wired up yet.
*/
export function isBoxConfigured(): boolean {
const path = process.env.BOX_CONFIG_FILE;
if (!path || !fs.existsSync(path)) return false;
try {
const raw = fs.readFileSync(path, "utf8");
const parsed = JSON.parse(raw) as Partial<BoxConfig>;
return !!(
parsed.boxAppSettings?.clientID &&
parsed.boxAppSettings?.appAuth?.privateKey &&
parsed.enterpriseID
);
} catch {
return false;
}
}

View file

@ -0,0 +1,210 @@
import path from "path";
import fs from "fs";
import { prisma } from "@/lib/prisma";
import {
createOrGetBoxFolder,
uploadFileToBox,
isBoxConfigured,
} from "@/lib/services/box/box-jwt-client";
import { buildDeliveryNaming } from "@/lib/services/external-delivery-service";
const UPLOADS_DIR =
process.env.VIDEO_UPLOADS_DIR ||
(process.env.NODE_ENV === "production"
? "/data/uploads"
: path.join(process.cwd(), "data", "uploads"));
interface PushResult {
ok: boolean;
boxFolderId?: string;
uploadedFiles: number;
error?: string;
}
/**
* Pushes the deliverable's latest approved revision's assets into a Box
* sub-folder using the naming convention. Retries up to 3 times with
* exponential backoff before marking the push FAILED. Every attempt is
* logged to `BoxPushLog` so the producer UI can surface state.
*
* Idempotent on the Box side: createOrGetBoxFolder reuses an existing
* folder with the same name, and file uploads use the standard Box API
* which 409s on duplicate names (caller decides what to do we don't
* re-upload on conflict).
*/
export async function pushDeliverableToBox(
deliverableId: string
): Promise<PushResult> {
if (!isBoxConfigured()) {
await prisma.boxPushLog.create({
data: {
deliverableId,
status: "FAILED",
error: "BOX_CONFIG_FILE not configured",
},
});
return { ok: false, uploadedFiles: 0, error: "Box not configured" };
}
const outFolderId = process.env.BOX_OUT_FOLDER_ID;
if (!outFolderId) {
await prisma.boxPushLog.create({
data: {
deliverableId,
status: "FAILED",
error: "BOX_OUT_FOLDER_ID not set",
},
});
return { ok: false, uploadedFiles: 0, error: "Out folder not configured" };
}
const deliverable = await prisma.deliverable.findUnique({
where: { id: deliverableId },
select: {
id: true,
name: true,
boxAliasSlug: true,
project: { select: { omgJobNumber: true } },
stages: {
select: {
id: true,
status: true,
stageDefinition: { select: { order: true, approvalType: true } },
template: { select: { order: true } },
revisions: {
orderBy: { roundNumber: "desc" },
take: 1,
select: {
id: true,
roundNumber: true,
status: true,
attachments: true,
},
},
},
},
attachments: { select: { id: true, kind: true, title: true, url: true, stageDefinitionId: true } },
},
});
if (!deliverable) {
return { ok: false, uploadedFiles: 0, error: "Deliverable not found" };
}
if (!deliverable.project.omgJobNumber) {
await prisma.boxPushLog.create({
data: {
deliverableId,
status: "FAILED",
error: "Project has no OMG job number",
},
});
return { ok: false, uploadedFiles: 0, error: "Missing OMG job number" };
}
// Find the highest-order stage whose latest revision is APPROVED.
const stages = deliverable.stages
.slice()
.sort(
(a, b) =>
(b.stageDefinition?.order ?? b.template?.order ?? 0) -
(a.stageDefinition?.order ?? a.template?.order ?? 0)
);
const approvedStage = stages.find((s) => s.status === "APPROVED");
const latestRevision = approvedStage?.revisions[0] ?? null;
const roundNumber = latestRevision?.roundNumber ?? 1;
const naming = buildDeliveryNaming({
omgJobNumber: deliverable.project.omgJobNumber,
deliverableName: deliverable.name,
boxAliasSlug: deliverable.boxAliasSlug,
roundNumber,
});
const log = await prisma.boxPushLog.create({
data: {
deliverableId,
revisionId: latestRevision?.id ?? null,
status: "PENDING",
},
});
let attempt = 0;
let lastError: Error | null = null;
while (attempt < 3) {
attempt++;
try {
const folder = await createOrGetBoxFolder(outFolderId, naming.folderName);
let uploaded = 0;
// Upload revision-level attachments (from Revision.attachments JSON).
const revAttachments = (latestRevision?.attachments as any) ?? {};
for (const [kind, meta] of Object.entries(revAttachments)) {
const m = meta as { url?: string } | null;
if (!m?.url) continue;
const localPath = resolveLocalPath(m.url);
if (!localPath || !fs.existsSync(localPath)) continue;
const name = path.basename(localPath);
await uploadFileToBox(localPath, folder.id, name);
uploaded++;
}
// Upload stage-tagged file attachments on the approved stage def.
const approvedStageDefId = approvedStage?.stageDefinition
? // @ts-expect-error — id present at runtime when stageDefinition selected
(approvedStage.stageDefinition.id as string | undefined)
: undefined;
const stageAttachments = deliverable.attachments.filter(
(a) =>
a.kind === "file" &&
(a.stageDefinitionId === approvedStageDefId || a.stageDefinitionId === null)
);
for (const att of stageAttachments) {
const localPath = resolveLocalPath(att.url);
if (!localPath || !fs.existsSync(localPath)) continue;
await uploadFileToBox(localPath, folder.id, path.basename(localPath));
uploaded++;
}
await prisma.revision.update({
where: { id: latestRevision!.id },
data: { boxFolderId: folder.id },
}).catch(() => {});
await prisma.boxPushLog.update({
where: { id: log.id },
data: {
status: "SUCCESS",
boxFolderId: folder.id,
attempt,
},
});
return { ok: true, boxFolderId: folder.id, uploadedFiles: uploaded };
} catch (e) {
lastError = e as Error;
// Exponential backoff between attempts: 1s, 2s, 4s
if (attempt < 3) {
await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt - 1)));
}
}
}
await prisma.boxPushLog.update({
where: { id: log.id },
data: {
status: "FAILED",
attempt,
error: lastError?.message ?? "Unknown error",
},
});
return { ok: false, uploadedFiles: 0, error: lastError?.message };
}
// Resolves the /api/uploads/... URL into a local absolute path.
function resolveLocalPath(url: string): string | null {
// Expected form: /api/uploads/<bucket>/<id>/<filename>
const match = url.match(/\/api\/uploads\/(.+)/);
if (!match) return null;
return path.join(UPLOADS_DIR, match[1]);
}

View file

@ -1,5 +1,7 @@
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/prisma";
import { pushDeliverableToBox } from "@/lib/services/box/box-outbound-service";
import { isBoxConfigured } from "@/lib/services/box/box-jwt-client";
/**
* Deliverable.status is a denormalised field it summarises the
@ -71,5 +73,14 @@ export async function recomputeDeliverableStatus(
where: { id: deliverableId },
data: { status: next },
});
// Outbound Box push fires on the NOT_APPROVED → APPROVED transition.
// Fire-and-forget — Box errors must not roll back the status update.
if (next === "APPROVED" && row.status !== "APPROVED" && isBoxConfigured()) {
pushDeliverableToBox(deliverableId).catch((err) =>
console.error("[deliverable-status] Box push failed:", err)
);
}
return { changed: true, status: next };
}

View file

@ -40,7 +40,7 @@ export async function exportProjectToExcel(projectId: string) {
if (!project) throw new Error("Project not found");
const wb = new ExcelJS.Workbook();
wb.creator = "Dow Jones Studio Tracker";
wb.creator = "L'Oréal Studio Tracker";
wb.created = new Date();
// ── Sheet 1: Deliverables Overview ──

View file

@ -0,0 +1,61 @@
// External-delivery abstraction. Today dispatches to Box; in future may
// switch to an OMG API. All naming-convention logic lives here so the
// matcher contract is portable.
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
export interface DeliveryNaming {
/** Folder name written into Box (outbound). */
folderName: string;
/** Slug used to match inbound filenames against a Deliverable. */
matcherSlug: string;
}
/**
* Naming convention shared by outbound and inbound:
* {omgJobNumber}_{slug}_v{roundNumber}
*
* The slug source is:
* 1. `Deliverable.boxAliasSlug` when set, otherwise
* 2. slugify(Deliverable.name)
*/
export function buildDeliveryNaming(args: {
omgJobNumber: string;
deliverableName: string;
boxAliasSlug?: string | null;
roundNumber: number;
}): DeliveryNaming {
const slug = (args.boxAliasSlug ?? slugify(args.deliverableName)).toLowerCase();
return {
folderName: `${args.omgJobNumber}_${slug}_v${args.roundNumber}`,
matcherSlug: slug,
};
}
/**
* Regex used by the inbound webhook to parse an incoming filename.
* Captures: (1) omgJobNumber, (2) slug, (3) optional version.
*/
export const INBOUND_NAME_RE =
/^(\d+)_([a-z0-9-]+)(?:_v(\d+))?(?:\.[a-z0-9]+)?$/i;
export interface ParsedInboundName {
omgJobNumber: string;
slug: string;
version: number | null;
}
export function parseInboundFileName(name: string): ParsedInboundName | null {
const m = name.match(INBOUND_NAME_RE);
if (!m) return null;
return {
omgJobNumber: m[1],
slug: m[2].toLowerCase(),
version: m[3] ? Number(m[3]) : null,
};
}

View file

@ -0,0 +1,233 @@
import { prisma } from "@/lib/prisma";
import type {
CreateFeedbackInput,
UpdateFeedbackInput,
ResolveFeedbackInput,
} from "@/lib/validators/feedback";
const FEEDBACK_INCLUDE = {
assignedTo: { select: { id: true, name: true, email: true, image: true } },
createdBy: { select: { id: true, name: true, email: true, image: true } },
resolvedBy: { select: { id: true, name: true, email: true, image: true } },
verifiedBy: { select: { id: true, name: true, email: true, image: true } },
annotation: {
select: {
id: true,
type: true,
data: true,
imageX: true,
imageY: true,
timestampSeconds: true,
},
},
carriedFrom: { select: { id: true, summary: true, revisionId: true } },
} as const;
export async function listFeedbackItems(
stageId: string,
filters?: { revisionId?: string; status?: string; isActionItem?: boolean }
) {
const where: any = { deliverableStageId: stageId };
if (filters?.revisionId) where.revisionId = filters.revisionId;
if (filters?.status) where.status = filters.status;
if (filters?.isActionItem !== undefined) where.isActionItem = filters.isActionItem;
return prisma.feedbackItem.findMany({
where,
include: FEEDBACK_INCLUDE,
orderBy: [{ isActionItem: "desc" }, { sortOrder: "asc" }, { createdAt: "asc" }],
});
}
export async function getFeedbackItem(itemId: string) {
return prisma.feedbackItem.findUnique({
where: { id: itemId },
include: FEEDBACK_INCLUDE,
});
}
export async function createFeedbackItem(
stageId: string,
userId: string,
input: CreateFeedbackInput
) {
const maxSort = await prisma.feedbackItem.aggregate({
where: { revisionId: input.revisionId },
_max: { sortOrder: true },
});
return prisma.feedbackItem.create({
data: {
deliverableStageId: stageId,
revisionId: input.revisionId,
annotationId: input.annotationId ?? null,
commentId: input.commentId ?? null,
summary: input.summary,
isActionItem: input.isActionItem ?? true,
status: "OPEN",
sortOrder: (maxSort._max.sortOrder ?? 0) + 1,
assignedToId: input.assignedToId ?? null,
createdById: userId,
},
include: FEEDBACK_INCLUDE,
});
}
// Auto-create feedback when an annotation is created. Action items by default.
export async function createFeedbackFromAnnotation(
stageId: string,
revisionId: string,
annotationId: string,
commentId: string,
commentText: string,
userId: string,
isActionItem: boolean = true
) {
const summary =
commentText.length > 200 ? commentText.slice(0, 197) + "..." : commentText;
const maxSort = await prisma.feedbackItem.aggregate({
where: { revisionId },
_max: { sortOrder: true },
});
return prisma.feedbackItem.create({
data: {
deliverableStageId: stageId,
revisionId,
annotationId,
commentId,
summary,
isActionItem,
status: "OPEN",
sortOrder: (maxSort._max.sortOrder ?? 0) + 1,
createdById: userId,
},
include: FEEDBACK_INCLUDE,
});
}
export async function updateFeedbackItem(
itemId: string,
input: UpdateFeedbackInput
) {
return prisma.feedbackItem.update({
where: { id: itemId },
data: {
...(input.summary !== undefined && { summary: input.summary }),
...(input.isActionItem !== undefined && { isActionItem: input.isActionItem }),
...(input.status !== undefined && { status: input.status as any }),
...(input.assignedToId !== undefined && { assignedToId: input.assignedToId }),
...(input.sortOrder !== undefined && { sortOrder: input.sortOrder }),
},
include: FEEDBACK_INCLUDE,
});
}
export async function resolveFeedbackItem(
itemId: string,
userId: string,
input: ResolveFeedbackInput
) {
return prisma.feedbackItem.update({
where: { id: itemId },
data: {
status: "RESOLVED",
resolvedById: userId,
resolvedAt: new Date(),
resolutionNote: input.resolutionNote ?? null,
},
include: FEEDBACK_INCLUDE,
});
}
export async function verifyFeedbackItem(itemId: string, userId: string) {
return prisma.feedbackItem.update({
where: { id: itemId },
data: {
status: "VERIFIED",
verifiedById: userId,
verifiedAt: new Date(),
},
include: FEEDBACK_INCLUDE,
});
}
export async function reopenFeedbackItem(itemId: string) {
return prisma.feedbackItem.update({
where: { id: itemId },
data: {
status: "OPEN",
resolvedById: null,
resolvedAt: null,
resolutionNote: null,
verifiedById: null,
verifiedAt: null,
},
include: FEEDBACK_INCLUDE,
});
}
export async function deleteFeedbackItem(itemId: string) {
await prisma.feedbackItem.delete({ where: { id: itemId } });
return { ok: true };
}
// Carry forward unresolved action items from a previous revision to a new one.
export async function carryForwardFeedback(
stageId: string,
previousRevisionId: string,
newRevisionId: string,
userId: string
) {
const unresolvedItems = await prisma.feedbackItem.findMany({
where: {
revisionId: previousRevisionId,
status: { in: ["OPEN", "IN_PROGRESS", "REOPENED"] },
isActionItem: true,
},
});
if (unresolvedItems.length === 0) return [];
const carriedItems = await prisma.$transaction(
unresolvedItems.map((item, idx) =>
prisma.feedbackItem.create({
data: {
deliverableStageId: stageId,
revisionId: newRevisionId,
annotationId: item.annotationId,
commentId: item.commentId,
summary: item.summary,
isActionItem: true,
status: "OPEN",
sortOrder: idx + 1,
assignedToId: item.assignedToId,
createdById: userId,
carriedFromId: item.id,
},
include: FEEDBACK_INCLUDE,
})
)
);
return carriedItems;
}
export async function getFeedbackSummary(stageId: string) {
const items = await prisma.feedbackItem.findMany({
where: { deliverableStageId: stageId },
select: { status: true, isActionItem: true },
});
const actionItems = items.filter((i) => i.isActionItem);
const total = actionItems.length;
const resolved = actionItems.filter(
(i) => i.status === "RESOLVED" || i.status === "VERIFIED"
).length;
const open = total - resolved;
const infoCount = items.filter((i) => !i.isActionItem).length;
return { total, resolved, open, infoCount };
}

View file

@ -59,7 +59,7 @@ export async function buildFullExportWorkbook(
ctx: VisibilityContext
): Promise<ExcelJS.Workbook> {
const wb = new ExcelJS.Workbook();
wb.creator = "Dow Jones Studio Tracker";
wb.creator = "L'Oréal Studio Tracker";
wb.created = new Date();
const projects = await prisma.project.findMany({

View file

@ -193,9 +193,22 @@ export async function createProject(
Object.entries(data).map(([k, v]) => [k, v === "" ? undefined : v])
) as typeof data;
// Default-pipeline fallback: if the caller didn't specify a template,
// attach the org's default. Lets external API callers create projects
// without having to know template IDs.
let pipelineTemplateId = cleaned.pipelineTemplateId;
if (!pipelineTemplateId) {
const def = await prisma.pipelineTemplate.findFirst({
where: { organizationId, isDefault: true, isArchived: false },
select: { id: true },
});
if (def) pipelineTemplateId = def.id;
}
const project = await prisma.project.create({
data: {
...cleaned,
pipelineTemplateId,
startDate: cleaned.startDate ? new Date(cleaned.startDate) : null,
dueDate: cleaned.dueDate ? new Date(cleaned.dueDate) : null,
organizationId,

View file

@ -0,0 +1,227 @@
import { prisma } from "@/lib/prisma";
import type {
CreateReviewSessionInput,
UpdateReviewSessionInput,
AddSessionItemsInput,
ReorderSessionItemsInput,
RecordDecisionInput,
GenerateSessionItemsInput,
} from "@/lib/validators/review-session";
const SESSION_INCLUDE = {
createdBy: { select: { id: true, name: true, email: true, image: true } },
items: {
orderBy: { sortOrder: "asc" as const },
include: {
deliverableStage: {
include: {
template: { select: { id: true, name: true, slug: true, order: true } },
deliverable: {
select: {
id: true,
name: true,
priority: true,
project: { select: { id: true, name: true, projectCode: true } },
},
},
revisions: {
orderBy: { roundNumber: "desc" as const },
take: 1,
select: {
id: true,
roundNumber: true,
status: true,
attachments: true,
},
},
assignments: {
include: {
user: { select: { id: true, name: true, image: true } },
},
},
},
},
revision: {
select: {
id: true,
roundNumber: true,
status: true,
attachments: true,
},
},
decidedBy: { select: { id: true, name: true, image: true } },
},
},
} as const;
const SESSION_LIST_INCLUDE = {
createdBy: { select: { id: true, name: true, image: true } },
_count: { select: { items: true } },
items: {
select: { decision: true },
},
} as const;
export async function listReviewSessions(
organizationId: string,
filters?: { status?: string }
) {
const where: any = { organizationId };
if (filters?.status) where.status = filters.status;
return prisma.reviewSession.findMany({
where,
include: SESSION_LIST_INCLUDE,
orderBy: { updatedAt: "desc" },
});
}
export async function getReviewSession(sessionId: string) {
return prisma.reviewSession.findUnique({
where: { id: sessionId },
include: SESSION_INCLUDE,
});
}
export async function createReviewSession(
organizationId: string,
userId: string,
input: CreateReviewSessionInput
) {
return prisma.reviewSession.create({
data: {
name: input.name,
description: input.description ?? null,
status: "DRAFT",
createdById: userId,
organizationId,
},
include: SESSION_INCLUDE,
});
}
export async function updateReviewSession(
sessionId: string,
input: UpdateReviewSessionInput
) {
return prisma.reviewSession.update({
where: { id: sessionId },
data: {
...(input.name !== undefined && { name: input.name }),
...(input.description !== undefined && { description: input.description }),
...(input.status !== undefined && { status: input.status as any }),
},
include: SESSION_INCLUDE,
});
}
export async function deleteReviewSession(sessionId: string) {
await prisma.reviewSession.delete({ where: { id: sessionId } });
return { ok: true };
}
export async function addSessionItems(
sessionId: string,
input: AddSessionItemsInput
) {
const maxSort = await prisma.reviewSessionItem.aggregate({
where: { sessionId },
_max: { sortOrder: true },
});
let nextSort = (maxSort._max.sortOrder ?? 0) + 1;
const items = await prisma.$transaction(
input.items.map((item) =>
prisma.reviewSessionItem.create({
data: {
sessionId,
deliverableStageId: item.deliverableStageId,
revisionId: item.revisionId ?? null,
sortOrder: nextSort++,
},
})
)
);
return items;
}
export async function removeSessionItem(itemId: string) {
await prisma.reviewSessionItem.delete({ where: { id: itemId } });
return { ok: true };
}
export async function reorderSessionItems(
_sessionId: string,
input: ReorderSessionItemsInput
) {
await prisma.$transaction(
input.itemIds.map((id, index) =>
prisma.reviewSessionItem.update({
where: { id },
data: { sortOrder: index + 1 },
})
)
);
return { ok: true };
}
export async function recordDecision(userId: string, input: RecordDecisionInput) {
return prisma.reviewSessionItem.update({
where: { id: input.itemId },
data: {
decision: input.decision,
decisionNote: input.decisionNote ?? null,
decidedById: userId,
decidedAt: new Date(),
},
include: {
decidedBy: { select: { id: true, name: true, image: true } },
},
});
}
export async function clearDecision(itemId: string) {
return prisma.reviewSessionItem.update({
where: { id: itemId },
data: {
decision: null,
decisionNote: null,
decidedById: null,
decidedAt: null,
},
});
}
// Generate candidate session items from a project, optionally filtered.
export async function generateSessionItems(input: GenerateSessionItemsInput) {
const where: any = {
deliverable: { projectId: input.projectId },
};
if (input.stageStatus) where.status = input.stageStatus;
if (input.stageTemplateId) where.templateId = input.stageTemplateId;
const stages = await prisma.deliverableStage.findMany({
where,
select: {
id: true,
template: { select: { name: true, order: true } },
deliverable: { select: { name: true } },
revisions: {
orderBy: { roundNumber: "desc" },
take: 1,
select: { id: true },
},
},
orderBy: [
{ deliverable: { name: "asc" } },
{ template: { order: "asc" } },
],
});
return stages.map((s) => ({
deliverableStageId: s.id,
revisionId: s.revisions[0]?.id ?? undefined,
label: `${s.deliverable.name}${s.template.name}`,
}));
}

View file

@ -2,6 +2,7 @@ import { prisma } from "@/lib/prisma";
import type { CreateRevisionInput, UpdateRevisionInput } from "@/lib/validators/revision";
import type { RevisionStatus } from "@/generated/prisma/client";
import { emitRevisionSubmitted } from "@/lib/automation/event-bus";
import { carryForwardFeedback } from "@/lib/services/feedback-service";
// Register automation handler (side-effect import)
import "@/lib/services/automation-service";
@ -13,7 +14,7 @@ import "@/lib/services/automation-service";
export async function createRevision(
stageId: string,
data: CreateRevisionInput,
_userId?: string
userId?: string
) {
const lastRevision = await prisma.revision.findFirst({
where: { deliverableStageId: stageId },
@ -33,6 +34,14 @@ export async function createRevision(
},
});
// Carry forward unresolved action items from the previous revision.
// Fire-and-forget — failure here must not block revision creation.
if (lastRevision && userId) {
carryForwardFeedback(stageId, lastRevision.id, revision.id, userId).catch(
(err) => console.error("[Revision] carryForwardFeedback failed:", err)
);
}
// Emit automation event (non-blocking)
prisma.deliverableStage.findUnique({
where: { id: stageId },

View file

@ -447,7 +447,7 @@ async function generateSummary(
})
.join("\n");
const systemPrompt = `You are a helpful assistant for the Dow Jones Studio Tracker, a project management tool for HP product photography and CG rendering.
const systemPrompt = `You are a helpful assistant for the L'Oréal Studio Tracker, a project management tool for L'Oréal creative production work.
Response guidelines:
- Start with a 1-sentence direct answer to the question

View file

@ -0,0 +1,42 @@
import { z } from "zod/v4";
const feedbackStatusEnum = z.enum([
"OPEN",
"IN_PROGRESS",
"RESOLVED",
"VERIFIED",
"REOPENED",
]);
export const createFeedbackSchema = z.object({
revisionId: z.string().min(1, "Revision ID is required"),
annotationId: z.string().optional(),
commentId: z.string().optional(),
summary: z.string().min(1, "Summary is required"),
isActionItem: z.boolean().optional(),
assignedToId: z.string().optional(),
});
export type CreateFeedbackInput = z.infer<typeof createFeedbackSchema>;
export const updateFeedbackSchema = z.object({
summary: z.string().min(1).optional(),
isActionItem: z.boolean().optional(),
status: feedbackStatusEnum.optional(),
assignedToId: z.string().nullable().optional(),
sortOrder: z.number().int().optional(),
});
export type UpdateFeedbackInput = z.infer<typeof updateFeedbackSchema>;
export const resolveFeedbackSchema = z.object({
resolutionNote: z.string().optional(),
});
export type ResolveFeedbackInput = z.infer<typeof resolveFeedbackSchema>;
export const verifyFeedbackSchema = z.object({
reopen: z.boolean().optional(),
});
export type VerifyFeedbackInput = z.infer<typeof verifyFeedbackSchema>;

View file

@ -1,5 +1,7 @@
import { z } from "zod/v4";
const approvalTypeEnum = z.enum(["NONE", "SIMPLE", "FORMAL"]);
export const createPipelineTemplateSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
@ -21,6 +23,7 @@ export const addStageSchema = z.object({
estimatedDays: z.number().positive().nullable().optional(),
color: z.string().max(20).nullable().optional(),
customStatuses: z.any().optional(),
approvalType: approvalTypeEnum.optional(),
});
export const updateStageDefSchema = z.object({
@ -32,6 +35,7 @@ export const updateStageDefSchema = z.object({
estimatedDays: z.number().positive().nullable().optional(),
color: z.string().max(20).nullable().optional(),
customStatuses: z.any().optional(),
approvalType: approvalTypeEnum.optional(),
});
export const reorderStagesSchema = z.object({

View file

@ -0,0 +1,62 @@
import { z } from "zod/v4";
export const createReviewSessionSchema = z.object({
name: z.string().min(1, "Session name is required"),
description: z.string().optional(),
});
export type CreateReviewSessionInput = z.infer<typeof createReviewSessionSchema>;
export const updateReviewSessionSchema = z.object({
name: z.string().min(1).optional(),
description: z.string().nullable().optional(),
status: z.enum(["DRAFT", "IN_PROGRESS", "COMPLETED"]).optional(),
});
export type UpdateReviewSessionInput = z.infer<typeof updateReviewSessionSchema>;
export const addSessionItemsSchema = z.object({
items: z
.array(
z.object({
deliverableStageId: z.string().min(1),
revisionId: z.string().optional(),
})
)
.min(1, "At least one item is required"),
});
export type AddSessionItemsInput = z.infer<typeof addSessionItemsSchema>;
export const reorderSessionItemsSchema = z.object({
itemIds: z.array(z.string().min(1)).min(1),
});
export type ReorderSessionItemsInput = z.infer<typeof reorderSessionItemsSchema>;
export const recordDecisionSchema = z.object({
itemId: z.string().min(1, "Item ID is required"),
decision: z.enum(["APPROVED", "CHANGES_REQUESTED"]),
decisionNote: z.string().optional(),
});
export type RecordDecisionInput = z.infer<typeof recordDecisionSchema>;
export const generateSessionItemsSchema = z.object({
projectId: z.string().min(1, "Project ID is required"),
stageStatus: z
.enum([
"BLOCKED",
"NOT_STARTED",
"IN_PROGRESS",
"IN_REVIEW",
"CHANGES_REQUESTED",
"APPROVED",
"DELIVERED",
"SKIPPED",
])
.optional(),
stageTemplateId: z.string().optional(),
});
export type GenerateSessionItemsInput = z.infer<typeof generateSessionItemsSchema>;

View file

@ -7,7 +7,7 @@
*
* This is deliberately simple and process-local. For multi-instance
* deployments we'd swap the map for Redis or upstash/ratelimit but
* dow-prod-tracker runs as a single Next.js instance behind Apache,
* loreal-prod-tracker runs as a single Next.js instance behind Apache,
* so an in-memory map is enough.
*
* Dev bypass: if the matching `*_WEBHOOK_ALLOW_INSECURE` env var is
@ -95,7 +95,7 @@ export function clientIpFromRequest(req: Request): string {
*/
export function rateLimitWebhook(
request: Request,
scope: "omg" | "deliverables" | "briefs",
scope: "omg" | "deliverables" | "briefs" | "box",
limit: number = DEFAULT_LIMIT
): RateLimitResult {
const insecureVar =
@ -103,7 +103,9 @@ export function rateLimitWebhook(
? "OMG_WEBHOOK_ALLOW_INSECURE"
: scope === "deliverables"
? "DELIVERABLE_WEBHOOK_ALLOW_INSECURE"
: "BRIEF_WEBHOOK_ALLOW_INSECURE";
: scope === "briefs"
? "BRIEF_WEBHOOK_ALLOW_INSECURE"
: "BOX_WEBHOOK_ALLOW_INSECURE";
if (process.env[insecureVar] === "true") {
return { ok: true, remaining: limit, retryAfterSeconds: 0 };