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:
parent
ae6ebc6da2
commit
1b73d6b8db
84 changed files with 8185 additions and 261 deletions
18
.env.example
18
.env.example
|
|
@ -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
6
.gitignore
vendored
|
|
@ -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
22
API.md
|
|
@ -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"
|
||||
```
|
||||
|
||||
---
|
||||
|
|
|
|||
82
DEPLOY.md
82
DEPLOY.md
|
|
@ -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
|
||||
|
|
|
|||
42
HOWTO.md
42
HOWTO.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`.
|
||||
|
|
|
|||
14
README.md
14
README.md
|
|
@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
|
|
|||
18
SETUP.md
18
SETUP.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
27
apache/loreal-prod-tracker.conf.tmpl
Normal file
27
apache/loreal-prod-tracker.conf.tmpl
Normal 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
|
||||
31
deploy.sh
31
deploy.sh
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
145
docs/EXTERNAL_API.md
Normal 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
158
docs/RENAME_RUNBOOK.md
Normal 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 10–15 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 5–8, 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`.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const basePath = "/dow-prod-tracker";
|
||||
const basePath = "/loreal-prod-tracker";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "dow-prod-tracker",
|
||||
"name": "loreal-prod-tracker",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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");
|
||||
|
|
@ -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;
|
||||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
311
src/app/(app)/reviews/[sessionId]/page.tsx
Normal file
311
src/app/(app)/reviews/[sessionId]/page.tsx
Normal 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} ·{" "}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
275
src/app/(app)/reviews/page.tsx
Normal file
275
src/app/(app)/reviews/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
213
src/app/(app)/settings/box/page.tsx
Normal file
213
src/app/(app)/settings/box/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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'Oré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'Oré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'Oréal Studio Tracker
|
||||
</h1>
|
||||
<p className="mt-1 text-[12px] font-semibold tracking-[0.1em] uppercase text-[var(--muted-foreground)]">
|
||||
Oliver Agency
|
||||
|
|
|
|||
|
|
@ -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'Oré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"
|
||||
>
|
||||
|
|
|
|||
55
src/app/api/deliverables/[deliverableId]/box-push/route.ts
Normal file
55
src/app/api/deliverables/[deliverableId]/box-push/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
93
src/app/api/feedback/[itemId]/route.ts
Normal file
93
src/app/api/feedback/[itemId]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
136
src/app/api/reviews/[sessionId]/route.ts
Normal file
136
src/app/api/reviews/[sessionId]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
51
src/app/api/reviews/route.ts
Normal file
51
src/app/api/reviews/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
49
src/app/api/settings/box/route.ts
Normal file
49
src/app/api/settings/box/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
76
src/app/api/stages/[stageId]/feedback/route.ts
Normal file
76
src/app/api/stages/[stageId]/feedback/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
46
src/app/api/v1/projects/[projectId]/deliverables/route.ts
Normal file
46
src/app/api/v1/projects/[projectId]/deliverables/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
30
src/app/api/v1/projects/[projectId]/route.ts
Normal file
30
src/app/api/v1/projects/[projectId]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
53
src/app/api/v1/projects/route.ts
Normal file
53
src/app/api/v1/projects/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
85
src/app/api/webhooks/box/route.ts
Normal file
85
src/app/api/webhooks/box/route.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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]">
|
||||
|
|
|
|||
|
|
@ -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'Oréal Studio
|
||||
</span>
|
||||
<span className="label-upper opacity-40">/</span>
|
||||
<span className="label-upper">Oliver Agency</span>
|
||||
|
|
|
|||
434
src/components/review/annotation-layer.tsx
Normal file
434
src/components/review/annotation-layer.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
104
src/components/review/create-session-dialog.tsx
Normal file
104
src/components/review/create-session-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
335
src/components/review/feedback-checklist.tsx
Normal file
335
src/components/review/feedback-checklist.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
455
src/components/review/feedback-item-card.tsx
Normal file
455
src/components/review/feedback-item-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
src/components/review/feedback-progress-bar.tsx
Normal file
47
src/components/review/feedback-progress-bar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
232
src/components/review/review-sidebar.tsx
Normal file
232
src/components/review/review-sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
479
src/components/review/session-builder.tsx
Normal file
479
src/components/review/session-builder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
544
src/components/review/session-presenter.tsx
Normal file
544
src/components/review/session-presenter.tsx
Normal 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, ←→ = 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>
|
||||
);
|
||||
}
|
||||
161
src/components/review/session-summary.tsx
Normal file
161
src/components/review/session-summary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
226
src/components/stages/stage-review-panel.tsx
Normal file
226
src/components/stages/stage-review-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
126
src/hooks/use-feedback.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
155
src/hooks/use-review-sessions.ts
Normal file
155
src/hooks/use-review-sessions.ts
Normal 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 }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
84
src/lib/api/idempotency.ts
Normal file
84
src/lib/api/idempotency.ts
Normal 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));
|
||||
}
|
||||
|
|
@ -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" });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,6 @@ export const { handlers, auth, signOut } = NextAuth({
|
|||
},
|
||||
},
|
||||
pages: {
|
||||
signIn: "/dow-prod-tracker/login",
|
||||
signIn: "/loreal-prod-tracker/login",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
243
src/lib/services/box/box-inbound-service.ts
Normal file
243
src/lib/services/box/box-inbound-service.ts
Normal 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`,
|
||||
});
|
||||
}
|
||||
}
|
||||
228
src/lib/services/box/box-jwt-client.ts
Normal file
228
src/lib/services/box/box-jwt-client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
210
src/lib/services/box/box-outbound-service.ts
Normal file
210
src/lib/services/box/box-outbound-service.ts
Normal 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]);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ──
|
||||
|
|
|
|||
61
src/lib/services/external-delivery-service.ts
Normal file
61
src/lib/services/external-delivery-service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
233
src/lib/services/feedback-service.ts
Normal file
233
src/lib/services/feedback-service.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
227
src/lib/services/review-session-service.ts
Normal file
227
src/lib/services/review-session-service.ts
Normal 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}`,
|
||||
}));
|
||||
}
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
42
src/lib/validators/feedback.ts
Normal file
42
src/lib/validators/feedback.ts
Normal 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>;
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
62
src/lib/validators/review-session.ts
Normal file
62
src/lib/validators/review-session.ts
Normal 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>;
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue