diff --git a/API.md b/API.md new file mode 100644 index 0000000..a081bef --- /dev/null +++ b/API.md @@ -0,0 +1,526 @@ +# 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 +origin — substitute your own when self-hosting. + +**The canonical key for jobs is `omgJobNumber`.** XLSX uploads and the OMG +webhook both upsert projects on this field. The REST API exposes it on every +project payload. + +--- + +## Authentication + +Three ways to call the API. + +### 1. Session cookie (browser / user-flow) + +After a user signs in via `POST /api/local-auth/login`, the response sets an +`authjs.session-token` cookie. Subsequent requests in the same browser use +that cookie automatically. Session gets per-team visibility scoping +(admins see everything; others see only their `ClientTeam`'s projects). + +### 2. API key (machine-to-machine) + +Send `X-API-Key: ` where `` matches the server's `API_KEY` env +var. The caller is authenticated as the first ADMIN in the resolved org. +The `X-Org-Id: ` header can narrow to a specific org (defaults to the +first one found). + +### 3. HMAC (webhooks only — see the OMG section below) + +Scoped to `/api/webhooks/*`, not the rest of the API. + +--- + +## Data model — what is a "job"? + +In Dow terms: a **job** ≈ one row in the Studio Tracker XLSX ≈ one `Project` +in the system. Key fields: + +| App field | XLSX column | Notes | +|---|---|---| +| `omgJobNumber` | Number | **Canonical key.** String. Upsert primary. | +| `name` | Project Name | E.g. `"2337959 — BRND-CMKT-WSJ-March Education Media Pack"` | +| `status` | Status | Enum: `PIPELINE`, `ACTIVE`, `ON_HOLD`, `COMPLETED`, `CANCELED`, `ARCHIVED` | +| `priority` | Risk | Enum: `LOW`, `MEDIUM`, `HIGH`, `URGENT` (from Low / Medium / High / Priority) | +| `clientTeamId` | Team | FK → ClientTeam. Drives visibility. | +| `businessUnit` | Project Category | Copywriting / Display / GIF / PDF / Print / Social / Static / Video | +| `requestor` | Owner | Project manager name | +| `startDate` | Brief Acceptance Date | ISO 8601 | +| `dueDate` | External Deadline | ISO 8601 | +| `description` | Status Details | Free-form | + +Under each project are **deliverables** (1:N), under each deliverable are +**pipeline stages** (N per template), which carry **revisions**, **comments**, +**annotations**. + +--- + +## Endpoints + +### Health + +``` +GET /api/health → 200, liveness only (DB + env vars) +GET /api/health?strict=1 → 200 only if seeded (org + pipeline templates present) +``` + +Return shape: + +```json +{ + "status": "healthy", + "mode": "liveness", + "checks": { + "database": { "status": "ok" }, + "pgvector": { "status": "ok", "detail": "v0.8.2" }, + "organization": { "status": "ok", "detail": "1 org(s)" }, + "pipeline_templates": { "status": "ok", "detail": "1 template(s)" }, + "dev_bypass": { "status": "ok" }, + "auth_secret": { "status": "ok" } + }, + "timestamp": "2026-04-21T..." +} +``` + +Use the liveness endpoint for Docker/Kubernetes health checks; `strict=1` is +for post-deploy verification. + +--- + +### Projects (CRUD) + +#### List + +``` +GET /api/projects +``` + +Scoped by visibility. Returns JSON array of: + +```jsonc +[ + { + "id": "cmo7...", + "omgJobNumber": "2337959", + "projectCode": "BRND-CMKT-WSJ-03", + "name": "2337959 — BRND-CMKT-WSJ-March Education Media Pack", + "status": "ACTIVE", + "priority": "HIGH", + "startDate": "2026-03-10T00:00:00.000Z", + "dueDate": "2026-04-03T00:00:00.000Z", + "businessUnit": "PDF", + "requestor": "Celena", + "clientTeam": { "id": "...", "name": "Brand", "slug": "brand" }, + "_count": { "deliverables": 42 }, + "updatedAt": "2026-04-20T..." + } +] +``` + +#### Get one + +``` +GET /api/projects/:projectId +``` + +Returns the project with nested `deliverables[].stages[]` — enough data to +render the Kanban / deliverable detail page in one fetch. + +#### Create + +``` +POST /api/projects +Content-Type: application/json + +{ + "projectCode": "BRND-CMKT-WSJ-03", + "name": "2337959 — BRND-CMKT-WSJ-March Education Media Pack", + "omgJobNumber": "2337959", + "clientTeamId": "cmo7...", + "businessUnit": "PDF", + "priority": "HIGH", + "status": "ACTIVE", + "startDate": "2026-03-10", + "dueDate": "2026-04-03", + "requestor": "Celena", + "description": "Timeline notes go here" +} +``` + +Responses: `201` + project body, `400` on validation errors, `403` if caller +lacks `PROJECT_CREATE`. Caller needs an existing client team — fetch via +`GET /api/client-teams`. + +#### Update + +``` +PATCH /api/projects/:projectId +``` + +Same body shape, all fields optional. Visibility pre-checked. + +#### Delete + +``` +DELETE /api/projects/:projectId +``` + +Cascades deliverables + stages. + +--- + +### XLSX upload — the "Job Tracker " sheet + +The bulk-import endpoint parses the Dow Studio Tracker workbook and upserts +one project per row (keyed on `omgJobNumber`). + +#### Preview (dry-run, nothing written) + +```bash +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" +``` + +Response: + +```json +{ + "preview": true, + "totalRows": 27, + "validRows": 26, + "errors": [ + { "row": 21, "reason": "omgNumber: Invalid input" } + ], + "rows": [ + { + "omgNumber": "2337959", + "projectName": "2337959 - BRND-CMKT-WSJ-March Education Media Pack-26", + "clientTeamSlug": "brand", + "status": "ACTIVE", + "priority": "HIGH", + "externalDeadline": "2026-04-03T00:00:00.000Z" + } + ] +} +``` + +The first 25 normalized rows ship in the response so you can show a +preview UI. Errors are row-scoped and don't abort the batch. + +#### Commit (actually write) + +```bash +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" +``` + +Response: + +```json +{ + "preview": false, + "totalRows": 27, + "imported": 26, + "created": 18, + "updated": 8, + "deliverablesCreated": 267, + "errors": [ + { "row": 21, "reason": "omgNumber: Invalid input" } + ] +} +``` + +- Idempotent on `omgJobNumber` — re-upload the same file tomorrow and rows + with unchanged numbers update in place; rows with new numbers create. +- Rows that already exist: only fields that are set on the new row overwrite; + blank cells don't clobber producer-edited data. +- On create, one `Deliverable` is spawned per unit of `# of outputs` and the + default Dow pipeline stages are attached. + +#### Expected XLSX structure + +Sheet name: **`Job Tracker `** (trailing space is tolerated — parser +normalizes headers). Header row detected automatically within the first +5 rows. Row 2 (the example/instructions row) is skipped. + +Column mapping is substring-matched on normalized headers: + +| Header (any case / trailing space) | Maps to | +|---|---| +| `Owner` | `requestor` | +| `Risk` | `priority` (Priority→URGENT, High→HIGH, etc.) | +| `Creative Team Member…` | assignee (not persisted on Project; flagged in errors) | +| `Number` | `omgJobNumber` **(required)** | +| `Team` | `clientTeamId` (resolved by slug; auto-creates if missing) | +| `Status` | `status` (Brief in Review→PIPELINE, Amends/In production/Client Review→ACTIVE, On hold→ON_HOLD) | +| `Project Category` | `businessUnit` | +| `Project Name` | `name` **(required)** | +| `Brief Acceptance Date` | `startDate` | +| `External Deadline` | `dueDate` | +| `# of outputs` | count of deliverables created | +| `Status Details` | `description` | + +--- + +### OMG webhook — near-real-time ingest + +For the OMG platform to push job updates directly. Same upsert path as the +XLSX importer, so the two can never drift. + +#### Endpoint + +``` +POST /api/webhooks/omg +Content-Type: application/json +X-OMG-Signature: sha256= +``` + +HMAC is computed with the server's `OMG_WEBHOOK_SECRET` (a shared-secret +ops hands to the OMG team). Timing-safe compare. + +**Dev bypass**: set `OMG_WEBHOOK_ALLOW_INSECURE=true` in `.env` to skip +signature verification. DO NOT use in production. + +Route is exempt from session auth; middleware passes all `/api/webhooks/*` +through. + +#### Payload shape (speculative — confirm with Shashank) + +```jsonc +{ + "event": "job.created", // or job.updated | job.assigned | job.status_changed | job.completed + "timestamp": "2026-04-21T12:34:56Z", + "job": { + "number": "2337959", // → omgJobNumber. REQUIRED. + "name": "2337959 — BRND-CMKT-WSJ-March Education Media Pack", + "client": "Dow Jones", + "team": "Brand", // → clientTeamId, resolved by name + "category": "PDF", // → businessUnit + "status": "In production", // → ProjectStatus, mapped + "priority": "High", // → Priority, mapped + "assignees": [ + { "email": "foo@dowjones.com", "role": "Creative" } + ], + "dates": { + "accepted": "2026-03-10T00:00:00Z", + "externalDeadline": "2026-04-03T00:00:00Z" + }, + "outputs": 42 + }, + "raw": { + // any OMG-native fields we haven't mapped — land on Project.customFields + } +} +``` + +#### Responses + +- `200 { "ok": true, "projectId": "cmo7..." }` — upsert succeeded +- `401 { "error": "Invalid signature" }` — HMAC mismatch +- `400 { "error": "Invalid payload" }` — body didn't parse +- `500 { "error": "Internal server error" }` — something went wrong server-side + +#### Replay safety + +Idempotent: same `(omgJobNumber, timestamp)` replayed is effectively a noop +(the upsert just writes the same fields back). OMG can replay a queue +without worrying about duplicates. + +#### Example — signing a payload in bash + +```bash +SECRET="$(grep OMG_WEBHOOK_SECRET .env | cut -d= -f2- | tr -d '"')" +BODY='{"event":"job.updated","timestamp":"2026-04-21T12:00:00Z","job":{"number":"2337959","name":"Test","team":"Brand"}}' +SIG=$(printf "%s" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}') +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 +``` + +--- + +### Client Teams + +``` +GET /api/client-teams # list +POST /api/client-teams # admin only +PATCH /api/client-teams/:teamId # admin only +DELETE /api/client-teams/:teamId # admin only (blocks if team has projects) + +POST /api/client-teams/:teamId/members # body: { userId, isPrimary? } +DELETE /api/client-teams/:teamId/members?userId=... # remove +``` + +Team slugs drive the XLSX/webhook resolution — keep them stable. + +--- + +### Pods + +``` +GET /api/pods +POST /api/pods # { name, slug?, leadUserId? } +PATCH /api/pods/:podId +DELETE /api/pods/:podId +POST /api/pods/:podId/members # body: { userId } — sets homePodId +DELETE /api/pods/:podId/members?userId=... +``` + +Pods are orthogonal to client teams — they drive internal capacity +planning, not project visibility. + +--- + +### Invitations (add users) + +``` +GET /api/org/invitations +POST /api/org/invitations # body: { email, role, isExternal? } +DELETE /api/org/invitations/:id +``` + +`POST` creates/upserts the placeholder User, issues a password-reset token, +and returns `acceptUrl` — the `/reset-password/` link you hand to the +invitee. + +--- + +### Resource bookings (capacity planner) + +``` +GET /api/resources/bookings?weekStart=YYYY-MM-DD +POST /api/resources/bookings # ADMIN or PRODUCER only +DELETE /api/resources/bookings/:id + +GET /api/resources/job-numbers # autocomplete source for the UI +``` + +Body for POST: + +```json +{ + "userId": "cmo7...", + "date": "2026-04-21", + "jobNumber": "2337959", + "hours": 4, + "note": "Client review prep" +} +``` + +--- + +### Dashboards / reads + +``` +GET /api/dashboard/stats +GET /api/my-work +GET /api/workload?numWeeks=8 +GET /api/calendar?startDate=...&endDate=... +GET /api/reports/weekly?date=2026-04-21 +POST /api/search/semantic # { query } +``` + +All visibility-scoped — non-admins see only their client-team projects. + +--- + +## Common flows + +### Bootstrap a tenant from zero + +```bash +# 1. Health +curl https://optical-dev.oliver.solutions/dow-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 + +# response: {"id":"...","email":"...","acceptUrl":".../reset-password/"} +# hand that URL over for the producer to set a password. + +# 3. Upload the current XLSX tracker +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" +``` + +### OMG publishes a status change + +OMG side (pseudocode): + +```python +import hmac, hashlib, json, requests + +secret = os.environ["DOW_TRACKER_SECRET"] +body = json.dumps({ + "event": "job.status_changed", + "timestamp": datetime.utcnow().isoformat() + "Z", + "job": { + "number": "2337959", + "name": "…", + "team": "Brand", + "status": "Client Review", + } +}).encode("utf-8") + +sig = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() +requests.post( + "https://optical-dev.oliver.solutions/dow-prod-tracker/api/webhooks/omg", + data=body, + headers={ + "Content-Type": "application/json", + "X-OMG-Signature": f"sha256={sig}", + }, +) +``` + +Returns `200 { "ok": true, "projectId": "..." }` — project updated in place +if the `number` already exists, created otherwise. + +### Update a single job from an external script + +```bash +JOB="2337959" +PROJECT_ID=$(curl -s -H "X-API-Key: $API_KEY" \ + "https://optical-dev.oliver.solutions/dow-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" +``` + +--- + +## Error conventions + +All endpoints return JSON on error: + +- `400 { "error": "" }` — validation / bad input +- `401 { "error": "Unauthorized" }` — no valid session / API key +- `403 { "error": "Forbidden" }` — authenticated but lacks the required permission +- `404 { "error": "Not found" }` — resource doesn't exist or isn't visible to this caller + (visibility-scoped endpoints return 404 instead of 403 so ACLs aren't leaked) +- `500 { "error": "Internal server error" }` — server-side bug; check logs + +Permission errors surface the missing permission: + +```json +{ "error": "Missing permission: PROJECT_CREATE" } +``` diff --git a/HOWTO.md b/HOWTO.md new file mode 100644 index 0000000..720140e --- /dev/null +++ b/HOWTO.md @@ -0,0 +1,468 @@ +# Dow Jones Studio Tracker — How-to Guide + +Everything you need to get, run, use, admin, integrate, and extend the tracker. +Pairs with [DEPLOY.md](./DEPLOY.md) (prod deploy) and [API.md](./API.md) (REST/webhook +reference). + +--- + +## Table of contents + +1. [Quick mental model](#quick-mental-model) +2. [Run it locally](#run-it-locally) +3. [Run it in production](#run-it-in-production) +4. [The first-login ritual](#the-first-login-ritual) +5. [Add users](#add-users) +6. [Configure client teams + pods](#configure-client-teams--pods) +7. [Get real data in (XLSX)](#get-real-data-in-xlsx) +8. [Wire the OMG webhook](#wire-the-omg-webhook) +9. [Day-to-day: producer workflow](#day-to-day-producer-workflow) +10. [Day-to-day: external client viewer](#day-to-day-external-client-viewer) +11. [Resource planning](#resource-planning) +12. [Who can do what (RBAC)](#who-can-do-what-rbac) +13. [Common problems + fixes](#common-problems--fixes) +14. [Change the model](#change-the-model) + +--- + +## Quick mental model + +**One tracker, one tenant.** Every row is a `Project` — equivalent to one row in +Dow's existing Studio Tracker XLSX. Projects belong to a **ClientTeam** (Brand, +Events, B2B, Content, Briefing Team, Performance) which drives *who can see +what*. Under each project are **deliverables**, and each deliverable runs through +the Dow 11-stage pipeline: + +``` +Pipeline → New → Copywriter → Client Review (Copy) → In Progress Creative + → Internal Review → Client Feedback → Final Approval → Completed + ± On Hold / Canceled (terminal parking states) +``` + +Orthogonal to visibility, everyone belongs to a **Pod** (Sergio's / Deborah's / +Shared) — pods are for *capacity planning*, not for project access. + +**The `omgJobNumber` is the canonical key.** Both the XLSX importer and the OMG +webhook upsert on it, so if OMG publishes a job update while someone's editing +the same row in the UI, the next ingest correctly merges onto that row. + +--- + +## Run it locally + +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 +npm install + +# 2. Env +cp .env.example .env +# Fill in AUTH_SECRET at minimum: +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 + +# If 5492 is busy on your Mac (e.g. another project's postgres), create a local +# override — gitignored: +cat > docker-compose.override.yml < +``` + +Sign in with those. The app forces a password change on first login: + +1. Go to `/login` → enter email + temp password +2. Land on `/change-password?first=1` → enter temp password + new password × 2 +3. On success → redirected to the Dashboard + +The `mustChangePassword` flag is DB-tracked per user. Invited users go through +the same flow via `/reset-password/`. + +--- + +## Add users + +The "add user" flow is an **invitation** that creates both the User row AND a +password-reset token in one go. No separate "accept" step — the invitee just +sets their password via the reset link. + +### Through the UI + +1. Sign in as admin +2. **Settings → Team** +3. Type email, pick a role (Admin / Producer / Artist / **Client (read-only)**), + click **Send Invite** +4. A green banner appears with the accept URL. Click **Copy**, paste into + Teams / email / Slack to hand it to the user +5. User visits the URL → sets password → signs in + +### Programmatically + +```bash +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 +# → response includes { "acceptUrl": ".../reset-password/" } +``` + +### Notes + +- **Role choice matters for what they see:** + - `ADMIN` — everything across all client teams. + - `PRODUCER` — the studio side. Can create/update projects and bookings. + - `ARTIST` — can update stage statuses / submit revisions but not create + projects. + - `CLIENT_VIEWER` — *external* user. Read-only. Only sees the client teams + they're explicitly assigned to. +- **Invitation tokens expire in 7 days.** Just re-invite to re-issue. +- **Placeholder seed users exist but can't log in** until an admin invites them + (they have no passwordHash). Go to **Settings → Team** and invite any of them + — their role/pod/department are already set. + +--- + +## Configure client teams + pods + +### Client teams + +Six teams are seeded: Brand, Events, B2B, Content, Briefing Team, Performance. + +1. **Settings → Client Teams** +2. Click a team to see its members +3. Add users via the dropdown → they'll now see projects on this team +4. Admins see all teams by default regardless of membership + +A user who belongs to zero client teams **sees zero projects** (fail-closed). + +### Pods + +1. **Settings → Pods** +2. Three placeholder pods seeded: Sergio's / Deborah's / Shared +3. Create new pods or rename existing ones +4. Each user has one `homePod`. The Resources page groups by `department` for + now — swap to pod-grouping when the roster stabilizes + +--- + +## Get real data in (XLSX) + +The fastest way to populate the tracker from Dow's existing spreadsheet. + +### UI path + +1. **Projects → Import XLSX** +2. Pick the file (expected: a Dow Studio Tracker workbook with a `Job Tracker ` + sheet — trailing space tolerated, example/instructions row 2 auto-skipped) +3. Preview opens with normalized rows + per-row errors +4. Review errors. Hit **Commit** when you're happy +5. 18-ish projects land, ~250 deliverables are auto-created, pipeline stages + attach + +### API path + +```bash +# dry-run +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" + +# 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" +``` + +### Idempotency + +Re-upload tomorrow's snapshot of the same tracker: + +- Projects with an **existing `omgJobNumber`** → fields merge into the existing + row (blanks don't clobber producer-edited data) +- Projects with a **new `omgJobNumber`** → freshly created + pipeline stages + spawned +- Dropped rows in the XLSX are NOT deleted (would be destructive). To kill a + project, do it in the UI or via `DELETE /api/projects/:id`. + +See [API.md § XLSX upload](./API.md#xlsx-upload--the-job-tracker--sheet) for +the full column map and ingest edge cases. + +--- + +## Wire the OMG webhook + +The long-term ingest channel. Set it up once, OMG pushes updates in real time. + +### 1. Pick a secret + +```bash +openssl rand -hex 32 +``` + +### 2. Set it on the server + +```bash +# /opt/dow-prod-tracker/.env +OMG_WEBHOOK_SECRET="" +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 +``` + +### 3. Give the secret + endpoint to Shashank / OMG + +- URL: `https://optical-dev.oliver.solutions/dow-prod-tracker/api/webhooks/omg` +- Method: `POST` +- Content-Type: `application/json` +- Signature header: `X-OMG-Signature: sha256=` +- Payload: see [API.md § OMG webhook](./API.md#omg-webhook--near-real-time-ingest) + +### 4. Test with a stub payload + +```bash +SECRET="$(grep OMG_WEBHOOK_SECRET /opt/dow-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}') + +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 +``` + +Expected: `200 { "ok": true, "projectId": "..." }`. A TEST-001 project should +now be visible on the Brand team. + +### 5. Replay / idempotency + +Same `(number, timestamp)` replayed = noop. OMG can safely replay queues. + +### 6. Verification + +Both paths write through **the same upsert function** (`upsertProjectFromDow` +in `src/lib/services/dow-excel-service.ts`). If XLSX works but the webhook +doesn't, it's the signature or the HTTP layer — not the data. + +--- + +## Day-to-day: producer workflow + +The happy path. + +1. **Dashboard** — glance at the KPIs, overdue deliverables, recent activity +2. **Projects** — the Excel-shaped grid. Filter by team, search by OMG #, sort + by deadline. Click a project to open it +3. **Project detail** — see deliverables + their pipeline stages. Advance + stages as work progresses (click the stage status badge → pick the new + status). Transitions are gated: you can't jump to *In Progress Creative* + before *New* is accepted +4. **Upload XLSX / sync from OMG** — both are additive. Producer-edited data + survives re-ingest +5. **Client Feedback → CHANGES_REQUESTED** — automation rule kicks in, + re-opens *In Progress Creative* on the same deliverable and increments + its revision round. Notifies the assignee + PM +6. **Resources page** (admins + producers) — assign N hours of a job to a + person on a day. Capacity bars go amber at 85%, red over 100% + +--- + +## Day-to-day: external client viewer + +For `@dowjones.com` users you want to give read-only visibility. + +1. **Admin invites them** with role `CLIENT_VIEWER` (Settings → Team → + Client (read-only)) +2. **Admin adds them to exactly the client teams they should see** (Settings + → Client Teams → Add member). Usually one team; could be multiple +3. User sets password via the reset link +4. On sign-in they see: + - Only projects on teams they're a member of + - Dashboard KPIs scoped to those same projects + - No edit buttons (the UI checks `session.user.role === "CLIENT_VIEWER"` + and hides mutations) + - Can still comment (that's intentional — client feedback is normal for + review flows and doesn't mutate state) +5. Any attempt to POST/PATCH/DELETE anything returns 403. + +When you're ready for Entra guest-invite SSO instead of local accounts, set +`NEXT_PUBLIC_AUTH_ENTRA_ENABLED=true` and fill in the Azure env vars. No +code change. + +--- + +## Resource planning + +The **Resources** page (in the sidebar) is the daily-hours capacity view. + +- Users grouped by department (swap to pod grouping when the real roster + lands) +- Each cell = one person × one weekday. Shows job chips + capacity bar +- Click **Assign** on any cell → job-number autocomplete (pulls from your + project `omgJobNumber` list) + hour picker → commit +- Job chips are color-coded by a hash of the job number — same number always + gets the same color across the grid +- Over-cap days get a red outline + a "8h/6h" red badge + +ADMIN + PRODUCER can write; ARTIST / CLIENT_VIEWER see read-only. + +--- + +## Who can do what (RBAC) + +Defaults per role (Settings → Permissions to view / customize per org): + +| Role | Key permissions | +|---|---| +| `ADMIN` | Everything. Managing users, teams, pods, pipelines, automations. | +| `PRODUCER` | Projects CRUD, deliverables, stages, revisions, comments, bookings. Can't manage users/teams/pods. | +| `ARTIST` | Read projects/deliverables/stages. Update stage status + submit revisions. Can comment. | +| `CLIENT_VIEWER` | Read-only on visible projects. Comments only (no state mutation). | + +Per-team visibility is layered on top — an Artist on Team X only sees Team X's +projects, an Admin sees everything. + +--- + +## Common problems + fixes + +### "No organizations found" on `/api/health` + +You haven't run the seed. `docker compose -p dow-prod-tracker exec app npm run db:seed`. + +### Seed says `tsx: not found` + +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 +``` + +### Browser returns 404 at `/dow-prod-tracker/...` + +Apache Include didn't land. Check: + +```bash +grep dow-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 +symlink to `sites-available` or a separate file). + +### Port 5492 is busy on the server + +`deploy.sh` auto-picks the next free port. If you want to force a specific +one, `DB_HOST_PORT=5499 ./deploy.sh`. + +### Password reset email isn't sent + +SMTP isn't wired yet. `POST /api/local-auth/forgot-password` returns the +reset URL in the response when `NODE_ENV !== production` (dev path). In +prod, the URL is only server-logged. For now: admin reads the URL from the +create-invite response and hands it to the user out-of-band. + +### "Missing permission: X" on an action + +Check the role. Settings → Team → see everyone's role. Only ADMIN gets full +powers by default. + +### Apache 502 after deploy + +App container didn't start. `docker compose -p dow-prod-tracker logs app --tail 50`. + +### XLSX upload rejects most rows + +Usually means the Job Tracker sheet's headers drifted or Excel wrapped some +cells as hyperlinks. The importer handles the common cases; paste the +`?commit=false` preview errors and we'll iterate. + +### OMG webhook returns 401 + +Shared secret mismatch. Double-check both sides have the same +`OMG_WEBHOOK_SECRET` and that the signing header is +`X-OMG-Signature: sha256=` (lowercase `sha256=`, no extra whitespace). + +--- + +## Change the model + +When the shape of "a job" changes, edits go here: + +| Field of interest | Where to touch | +|---|---| +| Add a Project field | `prisma/schema.prisma` → new migration → `src/lib/validators/project.ts` → `src/components/projects/project-form-dialog.tsx` → optionally the Projects-page table column | +| Add/change an enum value | `schema.prisma` enum → new migration (Postgres: ALTER TYPE ADD VALUE) → update map/usage | +| Rename a ClientTeam | UI only — slugs are immutable (ingest depends on them) | +| Add a new pipeline stage | `prisma/seed-dow.ts` → `DOW_STAGES` + `DOW_DEPENDENCIES` → re-run seed | +| New XLSX column to ingest | `src/lib/validators/dow-import.ts` (schema) + `src/lib/services/dow-excel-service.ts` (`HEADER_MATCHERS` + `upsertProjectFromDow`) | +| New OMG webhook event | `src/app/api/webhooks/omg/route.ts` — the switch statement on `event` | +| New automation action | `src/lib/automation/action-executor.ts` — add a case + register it in `validateActions` | + +Migrations: the repo is on Prisma 7 with a clean baseline plus one delta. +For a new schema change, generate a fresh migration against a running local +DB: + +```bash +npx prisma migrate dev --name +``` + +If a local DB isn't running, hand-write the SQL into +`prisma/migrations/_/migration.sql` (idempotent, forward-only). diff --git a/README.md b/README.md index 7a9ad83..95ae41c 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,13 @@ Progress Creative → Internal Review → Client Feedback → Final Approval → Completed + On Hold + Canceled), and ingest from both the OMG webhook and the Studio Tracker XLSX. -Forked from `hp-prod-tracker`, customized for Dow Jones. See [DEPLOY.md](./DEPLOY.md) -for deployment to `optical-dev.oliver.solutions/dow-prod-tracker`. +Forked from `hp-prod-tracker`, customized for Dow Jones. + +**Docs:** +- [HOWTO.md](./HOWTO.md) — run it locally/prod, add users, configure teams + pods, + ingest XLSX, wire the OMG webhook, day-to-day usage, RBAC, common problems. +- [API.md](./API.md) — full REST + webhook reference. `omgJobNumber` is the key. +- [DEPLOY.md](./DEPLOY.md) — production deploy to `optical-dev.oliver.solutions`. Built for producers and creative team members who need real-time visibility into where every deliverable stands across every stage of the pipeline. diff --git a/prisma/migrations/20260421000000_resource_bookings/migration.sql b/prisma/migrations/20260421000000_resource_bookings/migration.sql new file mode 100644 index 0000000..c58bf22 --- /dev/null +++ b/prisma/migrations/20260421000000_resource_bookings/migration.sql @@ -0,0 +1,33 @@ +-- Dow resource bookings — daily hours-per-job grid for the capacity planner. +-- One row per (user, date, jobNumber). Multiple rows per (user, date) are +-- expected and encouraged (split days across jobs). + +CREATE TABLE "resource_bookings" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "date" TIMESTAMP(3) NOT NULL, + "jobNumber" TEXT NOT NULL, + "hours" DOUBLE PRECISION NOT NULL, + "note" TEXT, + "createdById" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "resource_bookings_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "resource_bookings_organizationId_date_idx" ON "resource_bookings"("organizationId", "date"); +CREATE INDEX "resource_bookings_userId_date_idx" ON "resource_bookings"("userId", "date"); + +ALTER TABLE "resource_bookings" ADD CONSTRAINT "resource_bookings_organizationId_fkey" + FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "resource_bookings" ADD CONSTRAINT "resource_bookings_userId_fkey" + FOREIGN KEY ("userId") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "resource_bookings" ADD CONSTRAINT "resource_bookings_createdById_fkey" + FOREIGN KEY ("createdById") REFERENCES "users"("id") + ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4c2f51b..93bdabd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -116,6 +116,7 @@ model Organization { notificationRules NotificationRule[] clientTeams ClientTeam[] pods Pod[] + resourceBookings ResourceBooking[] @@map("organizations") } @@ -162,6 +163,8 @@ model User { annotations Annotation[] clientTeams ClientTeamMembership[] podsLed Pod[] @relation("PodLead") + bookings ResourceBooking[] @relation("BookingResource") + bookingsCreated ResourceBooking[] @relation("BookingCreator") @@index([homePodId]) @@index([isExternal]) @@ -807,3 +810,31 @@ model Pod { @@index([organizationId]) @@map("pods") } + +// ─── Dow: Resource bookings (daily capacity grid) ────── +// One row per (user, date, jobNumber) — matches the Resources.html +// prototype's model: a producer assigns N hours of a given job to a +// person on a specific day. Multiple rows per (user, date) are expected +// (split days across jobs). jobNumber is a freeform string so it can +// hold Project.omgJobNumber, Project.projectCode, or an ad-hoc scratch +// label — the capacity planner doesn't care what project the hours are +// against, just that the total for the day/week is tracked. +model ResourceBooking { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + userId String + user User @relation("BookingResource", fields: [userId], references: [id], onDelete: Cascade) + date DateTime // date-only — we store 00:00:00 UTC + jobNumber String + hours Float + note String? + createdById String + createdBy User @relation("BookingCreator", fields: [createdById], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([organizationId, date]) + @@index([userId, date]) + @@map("resource_bookings") +} diff --git a/prisma/seed-dow.ts b/prisma/seed-dow.ts index 9b1b0f2..3345b49 100644 --- a/prisma/seed-dow.ts +++ b/prisma/seed-dow.ts @@ -46,6 +46,34 @@ const PODS = [ { slug: "shared-pod", name: "Shared / Floating" }, ]; +/** + * Placeholder studio roster — matches the 9 names in the Resources.html + * prototype. These are meant to be REPLACED with the real Dow/Oliver staff + * roster when Zia hands it over. Distributing round-robin across the 3 pods + * so the capacity planner has something to show out of the box. + * + * No passwordHash — these users can't log in until an admin re-invites them + * from Settings → Team (which issues a reset link). + */ +const PLACEHOLDER_TEAM: Array<{ + email: string; + name: string; + role: "PRODUCER" | "ARTIST"; + department: string; + maxCapacity: number; + podSlug: string; +}> = [ + { email: "alice.chen@example.com", name: "Alice Chen", role: "ARTIST", department: "Creative", maxCapacity: 8, podSlug: "sergio-pod" }, + { email: "ben.marsh@example.com", name: "Ben Marsh", role: "ARTIST", department: "Creative", maxCapacity: 8, podSlug: "deborah-pod" }, + { email: "cara.wu@example.com", name: "Cara Wu", role: "ARTIST", department: "Design", maxCapacity: 8, podSlug: "shared-pod" }, + { email: "dan.koch@example.com", name: "Dan Koch", role: "ARTIST", department: "Design", maxCapacity: 6, podSlug: "sergio-pod" }, + { email: "eva.stone@example.com", name: "Eva Stone", role: "ARTIST", department: "Production", maxCapacity: 8, podSlug: "deborah-pod" }, + { email: "frank.osei@example.com", name: "Frank Osei", role: "ARTIST", department: "Production", maxCapacity: 8, podSlug: "shared-pod" }, + { email: "grace.lee@example.com", name: "Grace Lee", role: "ARTIST", department: "Strategy", maxCapacity: 8, podSlug: "sergio-pod" }, + { email: "hiro.tanaka@example.com", name: "Hiro Tanaka", role: "PRODUCER", department: "PM", maxCapacity: 8, podSlug: "deborah-pod" }, + { email: "isla.reeve@example.com", name: "Isla Reeve", role: "PRODUCER", department: "PM", maxCapacity: 6, podSlug: "shared-pod" }, +]; + const DOW_PIPELINE_ID = "dow-pipeline-standard"; const DOW_STAGES: Array<{ @@ -325,6 +353,47 @@ async function main() { console.log(`✓ Admin user: ${admin.email} (id=${admin.id})`); + // Placeholder studio team + pod assignments + const podMap = new Map(); + for (const pod of PODS) { + const row = await prisma.pod.findUnique({ + where: { organizationId_slug: { organizationId: org.id, slug: pod.slug } }, + select: { id: true }, + }); + if (row) podMap.set(pod.slug, row.id); + } + + let seededUserCount = 0; + for (const member of PLACEHOLDER_TEAM) { + const podId = podMap.get(member.podSlug) ?? null; + await prisma.user.upsert({ + where: { email: member.email }, + update: { + name: member.name, + role: member.role, + department: member.department, + maxCapacity: member.maxCapacity, + organizationId: org.id, + homePodId: podId, + }, + create: { + email: member.email, + name: member.name, + role: member.role, + department: member.department, + maxCapacity: member.maxCapacity, + organizationId: org.id, + homePodId: podId, + // No passwordHash — admin re-sends them a reset link from the Team + // settings page when they're ready to activate this user. + mustChangePassword: true, + isExternal: false, + }, + }); + seededUserCount++; + } + console.log(`✓ Seeded ${seededUserCount} placeholder team members across ${PODS.length} pods`); + // Automation rule: Client Feedback CHANGES_REQUESTED → reopen creative const rejectionRule = await prisma.automationRule.upsert({ where: { id: "dow-rule-client-feedback-rejection" }, diff --git a/src/app/(app)/resources/page.tsx b/src/app/(app)/resources/page.tsx new file mode 100644 index 0000000..e271f44 --- /dev/null +++ b/src/app/(app)/resources/page.tsx @@ -0,0 +1,671 @@ +"use client"; + +/** + * Resource Manager — daily capacity planner. + * + * Grid of Resource × Weekday. Each cell holds job chips (job # + hours) + * and a capacity bar. Matches the Resources.html prototype's behaviour, + * re-implemented with the app's Tailwind + shadcn primitives and wired + * to the ResourceBooking API. + * + * Write path is ADMIN / PRODUCER only; ARTIST and CLIENT_VIEWER get + * read-only via hidden + disabled actions. + */ + +import { useMemo, useState, useCallback, useEffect, useRef } from "react"; +import { format, addDays, startOfWeek, addWeeks, isSameDay } from "date-fns"; +import { + ChevronLeft, + ChevronRight, + Plus, + X, + AlertCircle, + Users2, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useQuery } from "@tanstack/react-query"; +import { apiUrl } from "@/lib/api-client"; +import { cn } from "@/lib/utils"; +import { + useBookingsForWeek, + useCreateBooking, + useDeleteBooking, + useKnownJobNumbers, + type Booking, +} from "@/hooks/use-bookings"; +import { toast } from "sonner"; + +// Stable color per job number — hashed palette, matches Resources.html. +const JOB_PALETTE = [ + "#6C3FC5", "#0F7AE5", "#12A053", "#C97A10", "#D63B3B", + "#0891B2", "#7C3AED", "#059669", "#D97706", "#DC2626", + "#2563EB", "#9333EA", "#16A34A", "#EA580C", "#0284C7", +]; +function jobColor(id: string): string { + let hash = 0; + for (const c of id) hash = (hash * 31 + c.charCodeAt(0)) & 0xffffffff; + return JOB_PALETTE[Math.abs(hash) % JOB_PALETTE.length]; +} + +const HOUR_CHIPS = [1, 2, 3, 4, 6, 8]; + +interface UserRow { + id: string; + name: string | null; + email: string; + role: string; + department: string | null; + maxCapacity: number; +} + +function isoDateOnly(d: Date): string { + return format(d, "yyyy-MM-dd"); +} + +function initialsOf(name: string | null, email: string): string { + const base = name?.trim() || email.split("@")[0]; + const parts = base.split(/[\s._-]+/).filter(Boolean); + return ((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")).toUpperCase() || "?"; +} + +export default function ResourcesPage() { + const [weekStart, setWeekStart] = useState(() => + startOfWeek(new Date(), { weekStartsOn: 1 }) + ); + const weekStartIso = isoDateOnly(weekStart); + const weekDays = useMemo( + () => Array.from({ length: 5 }, (_, i) => addDays(weekStart, i)), + [weekStart] + ); + const today = useMemo(() => new Date(), []); + + const { data: users, isLoading: usersLoading } = useQuery({ + queryKey: ["users"], + queryFn: async () => { + const res = await fetch(apiUrl("/api/users")); + if (!res.ok) throw new Error("Failed to fetch users"); + return res.json() as Promise; + }, + }); + + const { data: bookings, isLoading: bookingsLoading } = + useBookingsForWeek(weekStartIso); + const { data: knownJobs } = useKnownJobNumbers(); + const createBooking = useCreateBooking(weekStartIso); + const deleteBooking = useDeleteBooking(weekStartIso); + + const [popover, setPopover] = useState< + | { anchor: { x: number; y: number }; userId: string; date: Date } + | null + >(null); + + const bookingsByCell = useMemo(() => { + const map = new Map(); + for (const b of bookings ?? []) { + const key = `${b.userId}_${b.date.slice(0, 10)}`; + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(b); + } + return map; + }, [bookings]); + + const cellBookings = (userId: string, date: Date): Booking[] => + bookingsByCell.get(`${userId}_${isoDateOnly(date)}`) ?? []; + + const cellHours = (userId: string, date: Date): number => + cellBookings(userId, date).reduce((s, b) => s + b.hours, 0); + + const weekHours = (userId: string): number => + weekDays.reduce((s, d) => s + cellHours(userId, d), 0); + + // Group users by department for the Resources.html-style role bands. + const userGroups = useMemo(() => { + const groups: Record = {}; + for (const u of users ?? []) { + const bucket = u.department || "Unassigned"; + if (!groups[bucket]) groups[bucket] = []; + groups[bucket].push(u); + } + return Object.entries(groups).sort((a, b) => a[0].localeCompare(b[0])); + }, [users]); + + const [collapsed, setCollapsed] = useState>({}); + + const handleCreate = useCallback( + async (userId: string, date: Date, jobNumber: string, hours: number) => { + try { + await createBooking.mutateAsync({ + userId, + date: isoDateOnly(date), + jobNumber, + hours, + }); + setPopover(null); + } catch (e: any) { + toast.error(e?.message ?? "Failed to create booking"); + } + }, + [createBooking] + ); + + const handleDelete = useCallback( + async (id: string) => { + try { + await deleteBooking.mutateAsync(id); + } catch (e: any) { + toast.error(e?.message ?? "Failed to delete booking"); + } + }, + [deleteBooking] + ); + + return ( +
+ {/* Header */} +
+
+ +
+

Resources

+

+ Daily capacity planner — drag jobs onto the grid, track hours per person. +

+
+
+
+ +
+ {format(weekStart, "MMM d")} – {format(addDays(weekStart, 4), "MMM d, yyyy")} +
+ + +
+
+ + {/* Grid */} +
+ + + + {weekDays.map((d, i) => ( + + ))} + + + + + + {weekDays.map((d) => { + const isToday = isSameDay(d, today); + return ( + + ); + })} + + + + + {usersLoading || bookingsLoading ? ( + Array.from({ length: 6 }).map((_, i) => ( + + {Array.from({ length: 7 }).map((_, j) => ( + + ))} + + )) + ) : !users || users.length === 0 ? ( + + + + ) : ( + userGroups.map(([group, members]) => { + const isCollapsed = collapsed[group]; + return ( + + setCollapsed((c) => ({ ...c, [group]: !c[group] })) + } + > + {!isCollapsed && + members.map((user) => { + const weekTotal = weekHours(user.id); + const weekCap = user.maxCapacity * 5; + const overWeek = weekTotal > weekCap; + const warnWeek = + weekTotal >= weekCap * 0.85 && !overWeek; + const barColor = overWeek + ? "bg-red-500" + : warnWeek + ? "bg-amber-500" + : "bg-emerald-500"; + return ( + + + {weekDays.map((day) => { + const dayBookings = cellBookings(user.id, day); + const booked = cellHours(user.id, day); + const over = booked > user.maxCapacity; + const isToday = isSameDay(day, today); + return ( + + ); + })} + + + ); + })} + + ); + }) + )} + +
+ Resource + +
+ {format(d, "EEE")} +
+
+ {format(d, "d")} +
+
+ Week +
+ +
+ No users yet — seed placeholders or invite team members in + Settings → Team. +
+
+
+ {initialsOf(user.name, user.email)} +
+
+
+ {user.name ?? user.email} +
+
+ {user.maxCapacity}h/day +
+
+
+
+
+ {dayBookings.map((b) => ( + handleDelete(b.id)} + /> + ))} + +
+ {booked > 0 && ( + + )} +
+
+ {weekTotal}h +
+
+ / {weekCap}h +
+
+
+
+
+
+ + {popover && ( + setPopover(null)} + onAssign={(jobNumber, hours) => + handleCreate(popover.userId, popover.date, jobNumber, hours) + } + knownJobs={knownJobs ?? []} + isPending={createBooking.isPending} + /> + )} +
+ ); +} + +// ── Group header + member rows ──────────────────────────────── + +function FragmentRows({ + group, + count, + collapsed, + onToggle, + children, +}: { + group: string; + count: number; + collapsed: boolean; + onToggle: () => void; + children: React.ReactNode; +}) { + return ( + <> + + + {collapsed ? "▶" : "▼"} + {group} · {count} {count === 1 ? "person" : "people"} + + + {children} + + ); +} + +// ── Job chip (hover to delete) ──────────────────────────────── + +function JobChip({ + jobNumber, + hours, + onRemove, +}: { + jobNumber: string; + hours: number; + onRemove: () => void; +}) { + const color = jobColor(jobNumber); + return ( +
+ + {jobNumber} + {hours}h + +
+ ); +} + +// ── Capacity bar under each cell ────────────────────────────── + +function CapBar({ booked, capacity }: { booked: number; capacity: number }) { + const pct = Math.min((booked / capacity) * 100, 120); + const over = booked > capacity; + const warn = booked >= capacity * 0.85 && !over; + return ( +
+
+
+
+
+ {booked}h / {capacity}h +
+
+ ); +} + +// ── Assign-job popover ──────────────────────────────────────── + +function AssignPopover({ + anchor, + onClose, + onAssign, + knownJobs, + isPending, +}: { + anchor: { x: number; y: number }; + onClose: () => void; + onAssign: (jobNumber: string, hours: number) => void; + knownJobs: Array<{ jobNumber: string; name: string }>; + isPending: boolean; +}) { + const [jobNumber, setJobNumber] = useState(""); + const [hours, setHours] = useState(4); + const ref = useRef(null); + + useEffect(() => { + const onDocClick = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) onClose(); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("mousedown", onDocClick); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDocClick); + document.removeEventListener("keydown", onKey); + }; + }, [onClose]); + + const suggestions = useMemo(() => { + const q = jobNumber.trim().toLowerCase(); + if (!q) return knownJobs.slice(0, 8); + return knownJobs + .filter( + (j) => + j.jobNumber.toLowerCase().startsWith(q) || + j.name.toLowerCase().includes(q) + ) + .slice(0, 8); + }, [jobNumber, knownJobs]); + + const valid = jobNumber.trim().length > 0; + + // Clamp popover to viewport + const left = Math.min(anchor.x, (typeof window !== "undefined" ? window.innerWidth : 1000) - 304); + const top = Math.min(anchor.y, (typeof window !== "undefined" ? window.innerHeight : 800) - 360); + + return ( +
+
+ Assign Job +
+
+
+ + setJobNumber(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && valid && onAssign(jobNumber.trim(), hours)} + placeholder="e.g. 2337959 or project name…" + className="h-8 text-xs" + /> + {suggestions.length > 0 && ( +
+ {suggestions.map((j) => ( + + ))} +
+ )} +
+
+ +
+ {HOUR_CHIPS.map((h) => ( + + ))} +
+
+ Custom: + + setHours(parseFloat(e.target.value) || 0) + } + placeholder="—" + className="h-6 w-16 text-xs" + /> + hrs +
+
+ {hours > 8 && ( +
+ + {hours}h will likely push this person over their daily cap. +
+ )} + +
+
+ ); +} diff --git a/src/app/api/resources/bookings/[bookingId]/route.ts b/src/app/api/resources/bookings/[bookingId]/route.ts new file mode 100644 index 0000000..78ed084 --- /dev/null +++ b/src/app/api/resources/bookings/[bookingId]/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server"; +import { badRequest, serverError, forbidden } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { deleteBooking } from "@/lib/services/booking-service"; + +type Params = { params: Promise<{ bookingId: string }> }; + +export async function DELETE(_req: NextRequest, { params }: Params) { + const { session, error } = await requireAuth(); + if (error) return error; + if (session.user.role !== "ADMIN" && session.user.role !== "PRODUCER") { + return forbidden("Only admins and producers can delete bookings"); + } + + try { + const { bookingId } = await params; + await deleteBooking(session.user.organizationId, bookingId); + return NextResponse.json({ ok: true }); + } catch (e: any) { + if (e.message?.includes("not found")) return badRequest(e.message); + return serverError(e); + } +} diff --git a/src/app/api/resources/bookings/route.ts b/src/app/api/resources/bookings/route.ts new file mode 100644 index 0000000..c080a83 --- /dev/null +++ b/src/app/api/resources/bookings/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from "next/server"; +import { badRequest, serverError, forbidden } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { + createBooking, + listBookingsForWeek, +} from "@/lib/services/booking-service"; +import { + createBookingSchema, + listBookingsQuerySchema, +} from "@/lib/validators/booking"; + +// GET /api/resources/bookings?weekStart=YYYY-MM-DD[&userId=...] +// Any signed-in member of the org can list — capacity view is shared. +export async function GET(req: NextRequest) { + const { session, error } = await requireAuth(); + if (error) return error; + + try { + const url = new URL(req.url); + const parsed = listBookingsQuerySchema.safeParse({ + weekStart: url.searchParams.get("weekStart") ?? undefined, + userId: url.searchParams.get("userId") ?? undefined, + }); + if (!parsed.success) { + return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); + } + + const weekStart = parsed.data.weekStart + ? new Date(parsed.data.weekStart) + : new Date(); + if (isNaN(weekStart.getTime())) { + return badRequest("Invalid weekStart — use YYYY-MM-DD"); + } + + const bookings = await listBookingsForWeek( + session.user.organizationId, + weekStart, + { userId: parsed.data.userId } + ); + return NextResponse.json(bookings); + } catch (e) { + return serverError(e); + } +} + +// POST /api/resources/bookings — ADMIN or PRODUCER only. +// Assignees aren't allowed to book themselves — keeps the capacity picture +// under producer control. Easy to relax later. +export async function POST(req: NextRequest) { + const { session, error } = await requireAuth(); + if (error) return error; + if (session.user.role !== "ADMIN" && session.user.role !== "PRODUCER") { + return forbidden("Only admins and producers can create bookings"); + } + + try { + const body = await req.json(); + const parsed = createBookingSchema.safeParse(body); + if (!parsed.success) { + return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); + } + const booking = await createBooking( + session.user.organizationId, + session.user.id, + parsed.data + ); + return NextResponse.json(booking, { status: 201 }); + } catch (e: any) { + if (e.message?.includes("not found")) return badRequest(e.message); + return serverError(e); + } +} diff --git a/src/app/api/resources/job-numbers/route.ts b/src/app/api/resources/job-numbers/route.ts new file mode 100644 index 0000000..fcbd799 --- /dev/null +++ b/src/app/api/resources/job-numbers/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import { serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { listKnownJobNumbers } from "@/lib/services/booking-service"; + +// GET /api/resources/job-numbers +// Powers the autocomplete in the Assign-Job popover on the Resources page. +// Org-scoped (all projects); doesn't apply per-team visibility because the +// capacity planner is internal studio-only and needs to see everything. +export async function GET() { + const { session, error } = await requireAuth(); + if (error) return error; + try { + const rows = await listKnownJobNumbers(session.user.organizationId); + return NextResponse.json(rows); + } catch (e) { + return serverError(e); + } +} diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 4be062e..b406f87 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -9,6 +9,7 @@ import { ClipboardList, Bell, Users, + Users2, GanttChart, Settings, PanelLeftClose, @@ -31,6 +32,7 @@ const navItems = [ { href: "/projects", label: "Projects", icon: FolderKanban }, { href: "/my-work", label: "My Work", icon: ClipboardList }, { href: "/workload", label: "Workload", icon: Users }, + { href: "/resources", label: "Resources", icon: Users2 }, { href: "/timeline", label: "Timeline", icon: GanttChart }, { href: "/calendar", label: "Calendar", icon: CalendarDays }, { href: "/reports", label: "Reports", icon: FileBarChart }, diff --git a/src/hooks/use-bookings.ts b/src/hooks/use-bookings.ts new file mode 100644 index 0000000..449a107 --- /dev/null +++ b/src/hooks/use-bookings.ts @@ -0,0 +1,73 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { apiUrl } from "@/lib/api-client"; + +async function fetchJson(url: string, init?: RequestInit): Promise { + 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 interface Booking { + id: string; + userId: string; + date: string; + jobNumber: string; + hours: number; + note: string | null; +} + +export function useBookingsForWeek(weekStartIso: string) { + return useQuery({ + queryKey: ["bookings", weekStartIso], + queryFn: () => + fetchJson( + `/api/resources/bookings?weekStart=${encodeURIComponent(weekStartIso)}` + ), + }); +} + +export function useCreateBooking(weekStartIso: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data: { + userId: string; + date: string; + jobNumber: string; + hours: number; + note?: string; + }) => + fetchJson("/api/resources/bookings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }), + onSuccess: () => + qc.invalidateQueries({ queryKey: ["bookings", weekStartIso] }), + }); +} + +export function useDeleteBooking(weekStartIso: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => + fetchJson(`/api/resources/bookings/${id}`, { method: "DELETE" }), + onSuccess: () => + qc.invalidateQueries({ queryKey: ["bookings", weekStartIso] }), + }); +} + +export function useKnownJobNumbers() { + return useQuery({ + queryKey: ["resource-job-numbers"], + queryFn: () => + fetchJson>( + "/api/resources/job-numbers" + ), + staleTime: 60_000, + }); +} diff --git a/src/lib/services/booking-service.ts b/src/lib/services/booking-service.ts new file mode 100644 index 0000000..7b4fc45 --- /dev/null +++ b/src/lib/services/booking-service.ts @@ -0,0 +1,128 @@ +import { prisma } from "@/lib/prisma"; +import type { CreateBookingInput } from "@/lib/validators/booking"; + +/** + * Resource booking service — daily capacity planner backend. + * + * Org-scoped. Write operations require ADMIN or PRODUCER role (checked in + * the API route). Reads are open to any signed-in member of the org — the + * capacity view is a shared studio-level thing, not per-project. + */ + +export function startOfWeekUtc(d: Date): Date { + // Monday as the start of the week — matches Resources.html + the Dow calendar. + const copy = new Date( + Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()) + ); + const day = copy.getUTCDay(); // 0=Sun, 1=Mon, ... + const diff = day === 0 ? -6 : 1 - day; + copy.setUTCDate(copy.getUTCDate() + diff); + return copy; +} + +export function endOfWeekUtc(d: Date): Date { + const start = startOfWeekUtc(d); + const end = new Date(start); + end.setUTCDate(end.getUTCDate() + 6); + end.setUTCHours(23, 59, 59, 999); + return end; +} + +/** + * List bookings for a given week. Returns bookings for all users in the org + * so the UI can build the full grid in one fetch. + */ +export async function listBookingsForWeek( + organizationId: string, + weekStart: Date, + opts: { userId?: string } = {} +) { + const from = startOfWeekUtc(weekStart); + const to = endOfWeekUtc(weekStart); + + return prisma.resourceBooking.findMany({ + where: { + organizationId, + date: { gte: from, lte: to }, + ...(opts.userId ? { userId: opts.userId } : {}), + }, + orderBy: [{ date: "asc" }, { createdAt: "asc" }], + select: { + id: true, + userId: true, + date: true, + jobNumber: true, + hours: true, + note: true, + }, + }); +} + +export async function createBooking( + organizationId: string, + createdById: string, + input: CreateBookingInput +) { + // Verify the user being booked belongs to this org. + const target = await prisma.user.findFirst({ + where: { id: input.userId, organizationId }, + select: { id: true }, + }); + if (!target) throw new Error("User not found in this organization"); + + // Normalize the date to UTC midnight so grouping by date is exact. + const dateUtc = new Date( + Date.UTC( + input.date.getUTCFullYear(), + input.date.getUTCMonth(), + input.date.getUTCDate() + ) + ); + + return prisma.resourceBooking.create({ + data: { + organizationId, + userId: input.userId, + date: dateUtc, + jobNumber: input.jobNumber, + hours: input.hours, + note: input.note, + createdById, + }, + select: { + id: true, + userId: true, + date: true, + jobNumber: true, + hours: true, + note: true, + }, + }); +} + +export async function deleteBooking(organizationId: string, bookingId: string) { + const existing = await prisma.resourceBooking.findFirst({ + where: { id: bookingId, organizationId }, + select: { id: true }, + }); + if (!existing) throw new Error("Booking not found"); + await prisma.resourceBooking.delete({ where: { id: bookingId } }); + return { ok: true }; +} + +/** + * Used by the Resource Manager UI to populate the job-number autocomplete. + * Returns distinct OMG job numbers + project names from all projects the + * caller can see (visibility is enforced upstream in the API route). + */ +export async function listKnownJobNumbers(organizationId: string) { + const rows = await prisma.project.findMany({ + where: { organizationId, omgJobNumber: { not: null } }, + select: { omgJobNumber: true, name: true }, + orderBy: { updatedAt: "desc" }, + take: 200, + }); + return rows + .filter((r): r is { omgJobNumber: string; name: string } => !!r.omgJobNumber) + .map((r) => ({ jobNumber: r.omgJobNumber, name: r.name })); +} diff --git a/src/lib/validators/booking.ts b/src/lib/validators/booking.ts new file mode 100644 index 0000000..2dc3342 --- /dev/null +++ b/src/lib/validators/booking.ts @@ -0,0 +1,36 @@ +import { z } from "zod/v4"; + +/** + * Resource booking validators. + * + * `date` is normalized to a date-only ISO string (YYYY-MM-DD) on both input + * and output. The DB stores it as a DateTime at 00:00:00 UTC; the Zod + * coerce-to-Date + `.toISOString().slice(0,10)` trick lets us accept both + * "2026-04-21" and "2026-04-21T00:00:00Z" from clients without fuss. + */ + +const dateOnly = z + .union([z.string(), z.date()]) + .transform((v) => { + if (v instanceof Date) return v; + const d = new Date(v); + if (isNaN(d.getTime())) throw new Error(`invalid date: ${v}`); + return d; + }) + .pipe(z.date()); + +export const createBookingSchema = z.object({ + userId: z.string().min(1, "userId is required"), + date: dateOnly, + jobNumber: z.string().trim().min(1).max(80), + hours: z.number().positive().max(24), + note: z.string().max(400).optional(), +}); + +export const listBookingsQuerySchema = z.object({ + weekStart: z.string().optional(), + userId: z.string().optional(), +}); + +export type CreateBookingInput = z.infer; +export type ListBookingsQuery = z.infer; diff --git a/src/lib/validators/project.ts b/src/lib/validators/project.ts index 625950c..b737714 100644 --- a/src/lib/validators/project.ts +++ b/src/lib/validators/project.ts @@ -7,16 +7,23 @@ export const createProjectSchema = z.object({ .max(50, "Project code must be 50 characters or less"), name: z.string().min(1, "Name is required").max(200), description: z.string().max(2000).optional(), - status: z.enum(["ACTIVE", "ON_HOLD", "COMPLETED", "ARCHIVED"]), + status: z.enum(["PIPELINE", "ACTIVE", "ON_HOLD", "COMPLETED", "CANCELED", "ARCHIVED"]), priority: z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]), startDate: z.string().optional(), dueDate: z.string().optional(), + // Dow fields + clientTeamId: z.string().optional(), + omgJobNumber: z.string().max(50).optional(), + // Category label (maps to Dow Project Category column from the XLSX: + // Copywriting / Display / GIF / PDF / Print / Social / Static / Video) businessUnit: z.string().max(100).optional(), + // Freeform carry-overs that can still be handy (owner, notes, etc.) + quarter: z.string().max(20).optional(), + requestor: z.string().max(2000).optional(), + // Legacy HP fields kept in schema for back-compat but no longer in the form: formFactor: z.string().max(100).optional(), codeName: z.string().max(100).optional(), npiOrRefresh: z.string().max(50).optional(), - quarter: z.string().max(20).optional(), - requestor: z.string().max(2000).optional(), workfrontId: z.string().max(100).optional(), omgCode: z.string().max(100).optional(), bmtId: z.string().max(100).optional(),