diff --git a/.env.example b/.env.example index a2337d9..c6c174b 100644 --- a/.env.example +++ b/.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" diff --git a/.gitignore b/.gitignore index 6df33ea..75b4c3a 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/API.md b/API.md index a081bef..7a977a4 100644 --- a/API.md +++ b/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/"} # 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" ``` --- diff --git a/DEPLOY.md b/DEPLOY.md index 87ba3d3..5a22633 100644 --- a/DEPLOY.md +++ b/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:5432/dow_prod_tracker?schema=public +DATABASE_URL=postgresql://postgres:@db:5432/loreal_prod_tracker?schema=public DB_PASSWORD= AUTH_SECRET= @@ -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= # 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=`. 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 ./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 diff --git a/HOWTO.md b/HOWTO.md index 720140e..7c98bcf 100644 --- a/HOWTO.md +++ b/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/" } ``` @@ -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="" 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= dow-prod-tracker -cd dow-prod-tracker +git clone 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 ``` --- diff --git a/SETUP.md b/SETUP.md index 6158392..bb46508 100644 --- a/SETUP.md +++ b/SETUP.md @@ -40,8 +40,8 @@ nvm use 22 ## 1. Clone the Repository ```bash -git clone dow_prod_tracker -cd dow_prod_tracker +git clone 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. diff --git a/apache/dow-prod-tracker.conf.tmpl b/apache/dow-prod-tracker.conf.tmpl deleted file mode 100644 index 58d46e6..0000000 --- a/apache/dow-prod-tracker.conf.tmpl +++ /dev/null @@ -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) - - LimitRequestBody 524288000 - - -# 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 diff --git a/apache/loreal-prod-tracker.conf.tmpl b/apache/loreal-prod-tracker.conf.tmpl new file mode 100644 index 0000000..99b1162 --- /dev/null +++ b/apache/loreal-prod-tracker.conf.tmpl @@ -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) + + LimitRequestBody 524288000 + + +# 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 diff --git a/deploy.sh b/deploy.sh index 9b6ed1f..2d8849d 100644 --- a/deploy.sh +++ b/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 sudo sed -i "s||$INCLUDE_LINE\n|" "$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" diff --git a/docker-compose.yml b/docker-compose.yml index 2ac00da..f7c61a9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/EXTERNAL_API.md b/docs/EXTERNAL_API.md new file mode 100644 index 0000000..c4d5fd1 --- /dev/null +++ b/docs/EXTERNAL_API.md @@ -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: +``` + +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: +``` + +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. diff --git a/docs/RENAME_RUNBOOK.md b/docs/RENAME_RUNBOOK.md new file mode 100644 index 0000000..5ee8730 --- /dev/null +++ b/docs/RENAME_RUNBOOK.md @@ -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 # 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`. diff --git a/next.config.ts b/next.config.ts index e5627ee..004b531 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,6 @@ import type { NextConfig } from "next"; -const basePath = "/dow-prod-tracker"; +const basePath = "/loreal-prod-tracker"; const nextConfig: NextConfig = { output: "standalone", diff --git a/package.json b/package.json index 8ea4eb8..71b8e34 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "dow-prod-tracker", + "name": "loreal-prod-tracker", "version": "0.1.0", "private": true, "scripts": { diff --git a/prisma/migrations/20260512000000_restore_review_workflow/migration.sql b/prisma/migrations/20260512000000_restore_review_workflow/migration.sql new file mode 100644 index 0000000..e5a8e39 --- /dev/null +++ b/prisma/migrations/20260512000000_restore_review_workflow/migration.sql @@ -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; diff --git a/prisma/migrations/20260512100000_idempotency_records/migration.sql b/prisma/migrations/20260512100000_idempotency_records/migration.sql new file mode 100644 index 0000000..cb7fad0 --- /dev/null +++ b/prisma/migrations/20260512100000_idempotency_records/migration.sql @@ -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"); diff --git a/prisma/migrations/20260512200000_box_integration/migration.sql b/prisma/migrations/20260512200000_box_integration/migration.sql new file mode 100644 index 0000000..8a5cc52 --- /dev/null +++ b/prisma/migrations/20260512200000_box_integration/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index db2fead..db7a4ec 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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:" 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]) diff --git a/scripts/backup-db.sh b/scripts/backup-db.sh index d4fe630..c5f29cc 100755 --- a/scripts/backup-db.sh +++ b/scripts/backup-db.sh @@ -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" diff --git a/src/app/(app)/calendar/page.tsx b/src/app/(app)/calendar/page.tsx index f3f7a1e..f10b121 100644 --- a/src/app/(app)/calendar/page.tsx +++ b/src/app/(app)/calendar/page.tsx @@ -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() { diff --git a/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/page.tsx b/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/page.tsx index d2ac5c0..2e7d952 100644 --- a/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/page.tsx +++ b/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/page.tsx @@ -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. */} + + {/* 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. */} + ); })} diff --git a/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/review/page.tsx b/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/review/page.tsx new file mode 100644 index 0000000..3ee1353 --- /dev/null +++ b/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/review/page.tsx @@ -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(null); + const [uploadPanelOpen, setUploadPanelOpen] = useState(false); + const [activeImageUrl, setActiveImageUrl] = useState(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(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("side-by-side"); + const [leftRevisionKey, setLeftRevisionKey] = useState(""); + const [rightRevisionKey, setRightRevisionKey] = useState(""); + 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 ( +
+ + +
+ ); + } + + if (!deliverable) { + return ( +
+ Deliverable not found. +
+ ); + } + + return ( +
+ {/* ── Top bar ──────────────────────────────────────────────── */} +
+ {/* Left: back + deliverable name */} +
+ + + Back + + +

+ {deliverable.name} +

+
+ + {/* Center: stage navigator */} + {selectedStage && ( +
+ +
+ + {selectedStage.stageDefinition?.order ?? selectedStage.template.order} + + + {selectedStage.stageDefinition?.name ?? selectedStage.template.name} + + +
+ +
+ )} + + {/* Right: actions */} +
+ {/* Image/Video toggle */} + {hasImageAttachment && hasVideoAttachment && ( +
+ + +
+ )} + + {/* Compare toggle */} + {!comparisonActive && galleryImages.length >= 2 && viewerMode === "image" && ( + + )} + + {/* Upload */} + + + + + + + Upload Media + + Upload images and video for review + + + + {latestRevision ? ( +
+

+ Uploading to{" "} + Round {latestRevision.roundNumber} +

+
+

+ Reference Image +

+ +
+
+

+ Current Render +

+ +
+ +
+

+ Video +

+ +
+
+

+ Reference Video +

+ +
+
+ ) : ( +
+

+ No rounds yet for this stage. +

+ +

+ Creates Round 1 so you can start uploading images +

+
+ )} +
+
+
+
+ + {/* ── Comparison toolbar (when active) ──────────────────────── */} + {comparisonActive && ( + setFlipA((f) => !f)} + onFlipB={() => setFlipB((f) => !f)} + onExit={handleExitComparison} + /> + )} + + {/* ── Main content: viewer + sidebar ────────────────────────── */} +
+ {/* ── Viewer column ──────────────────────────────────────── */} +
+ {comparisonActive ? ( + + ) : viewerMode === "video" && activeVideo ? ( + { + // 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 ( + + ); + }} + /> + ) : ( + ( + + )} + /> + )} + + {/* Gallery strip — collapsible */} + {!comparisonActive && galleryImages.length > 0 && ( +
+ + {galleryOpen && ( +
+ setActiveImageUrl(img.url)} + /> +
+ )} +
+ )} +
+ + {/* ── Unified sidebar: revisions + feedback tabs ─────────── */} + +
+
+ ); +} diff --git a/src/app/(app)/projects/page.tsx b/src/app/(app)/projects/page.tsx index 5b1d35a..2cfd486 100644 --- a/src/app/(app)/projects/page.tsx +++ b/src/app/(app)/projects/page.tsx @@ -253,7 +253,7 @@ export default function ProjectsPage() {

Projects

- Studio tracker — all Dow Jones projects in the pipeline + Studio tracker — all L'Oréal projects in the pipeline

diff --git a/src/app/(app)/reports/weekly/[date]/page.tsx b/src/app/(app)/reports/weekly/[date]/page.tsx index 8b7786c..cfb3b7e 100644 --- a/src/app/(app)/reports/weekly/[date]/page.tsx +++ b/src/app/(app)/reports/weekly/[date]/page.tsx @@ -93,7 +93,7 @@ export default function WeeklyReportPage() {