Compare commits
12 Commits
b2ba0b4e0a
...
3e78c2d4ab
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e78c2d4ab | |||
| 608641c23b | |||
| e7e498dedd | |||
| 98fe295675 | |||
| f85948488d | |||
| 025648c40b | |||
| 2d0a49e0d1 | |||
| 27f8db4c67 | |||
| 2c57082d8d | |||
| e469b2b6a6 | |||
| 85bd0d82e1 | |||
| 446342aa69 |
@@ -32,7 +32,9 @@ services:
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
command: redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
# BullMQ requires `noeviction` — under memory pressure, allkeys-lru
|
||||
# silently drops queue keys and jobs disappear. See post-audit fix F4.
|
||||
command: redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 256mb --maxmemory-policy noeviction
|
||||
volumes:
|
||||
- redisdata:/data
|
||||
healthcheck:
|
||||
|
||||
@@ -18,7 +18,9 @@ services:
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
command: redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
# BullMQ requires `noeviction` — under memory pressure, allkeys-lru
|
||||
# silently drops queue keys and jobs disappear. See post-audit fix F4.
|
||||
command: redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 256mb --maxmemory-policy noeviction
|
||||
volumes:
|
||||
- redisdata:/data
|
||||
healthcheck:
|
||||
|
||||
305
docs/POST-AUDIT-FIX-PLAN.md
Normal file
305
docs/POST-AUDIT-FIX-PLAN.md
Normal file
@@ -0,0 +1,305 @@
|
||||
# Post-Audit Fix Plan
|
||||
|
||||
Generated 2026-05-14 from two rounds of deep Playwright + API audit on `feat/documents-folders` → `main`.
|
||||
|
||||
**Total findings:** 24 fixes + 1 new feature. Grouped by priority. Each entry has impact, file pointer, and effort estimate.
|
||||
|
||||
---
|
||||
|
||||
## TIER 0 — Already Applied in Working Tree (uncommitted)
|
||||
|
||||
Status: **fixed in code, not yet committed**. Commit + push to ship.
|
||||
|
||||
### F1. `/api/v1/bootstrap/*` proxy allow-list (task #22)
|
||||
|
||||
- **Impact:** Cold-start VPS deploy can't bootstrap its first super-admin. `/setup` page calls `/api/v1/bootstrap/status` which 401s; setup form never renders.
|
||||
- **File:** `src/proxy.ts` — added to `PUBLIC_PATHS`.
|
||||
- **Effort:** XS.
|
||||
|
||||
### F2. Interest detail page 500s on every visit (task #25)
|
||||
|
||||
- **Impact:** Sales workflow non-functional. Raw `Date` passed to postgres-js `sql\`${col} >= ${dateVar}\`` template crashes the Bind step.
|
||||
- **File:** `src/lib/services/interests.service.ts:566` — switched to `gte(col, date)`.
|
||||
- **Effort:** XS.
|
||||
|
||||
---
|
||||
|
||||
## TIER 1 — Pre-Deploy Blockers (P1)
|
||||
|
||||
Ship before any real client touches the system.
|
||||
|
||||
### F3. GDPR export 500s — BullMQ rejects job IDs with colons (task #51)
|
||||
|
||||
- **Impact:** GDPR Article 15 right-to-access non-functional. Legal/compliance gate.
|
||||
- **File:** `src/lib/services/gdpr-export.service.ts:113` — change `jobId: \`gdpr-export:${row.id}\`` → `jobId: \`gdpr-export-${row.id}\``.
|
||||
- **Effort:** XS (one char).
|
||||
|
||||
### F4. Redis eviction policy is `allkeys-lru` but BullMQ requires `noeviction` (companion to F3)
|
||||
|
||||
- **Impact:** Under memory pressure, Redis will evict BullMQ keys; jobs disappear silently.
|
||||
- **File:** production Redis config (`maxmemory-policy noeviction`) + the docker-compose redis service.
|
||||
- **Effort:** XS (config).
|
||||
|
||||
### F5. `deleteBerth()` hard-deletes rows instead of soft-archiving (task #65)
|
||||
|
||||
- **Impact:** Permanent data loss on accidental delete. Junction tables CASCADE-vanish. Audit log points to non-existent rows. Public feed could 404 mid-customer-inquiry.
|
||||
- **Files:**
|
||||
- `src/lib/services/berths.service.ts:673-685` — replace `db.delete()` with `set archivedAt = now(), archivedBy = userId, archiveReason = input.reason`.
|
||||
- Add filter `isNull(berths.archivedAt)` to all default berth queries (recommender, public feed, list, dashboard heat).
|
||||
- Add restore endpoint `POST /api/v1/berths/[id]/restore` mirroring the interests pattern.
|
||||
- Require `reason` (min 5 chars) before destructive call.
|
||||
- **Effort:** M.
|
||||
|
||||
### F6. Weak input validation on `/api/v1/clients` (task #50)
|
||||
|
||||
- **Impact:** Email format not validated (bounces silently); whitespace-only names accepted (blank chips everywhere); XSS payload stored verbatim (depends on every render path being safe).
|
||||
- **Files:**
|
||||
- `src/lib/validators/clients.ts` — add `.email()` refinement on contacts where `channel === 'email'`; trim+min(1) on `fullName`; regex-strip control chars + zero-width chars.
|
||||
- Audit every fullName render path for `dangerouslySetInnerHTML` / pdfme / react-pdf / email template merges and ensure escaping.
|
||||
- Apply similar hardening to yachts, companies, interests, notes, berths, reminders (audit all string fields).
|
||||
- **Effort:** S for the obvious zod tweaks, M for the full audit.
|
||||
|
||||
### F7. No rate limiting on login (task #68)
|
||||
|
||||
- **Impact:** Brute force is wide open. 20 wrong-password attempts in a row all returned 401 with no lockout.
|
||||
- **Files:**
|
||||
- `src/lib/auth/` — add a `rateLimit` block to the better-auth config: `{ window: 60, max: 5 }` per IP+email.
|
||||
- Optionally: Redis sliding window via existing ioredis client.
|
||||
- Optionally: per-user lockout table (`auth_lockouts`) after 5 failures, locked 15min.
|
||||
- **Effort:** S.
|
||||
|
||||
### F8. postgres-js pool corruption causes CONNECT_TIMEOUT (task #46)
|
||||
|
||||
- **Impact:** During the audit the dev server twice entered a stuck state where every query 500'd with `CONNECT_TIMEOUT` while the DB was healthy (1/100 connections used). Production VPS will hit this under load.
|
||||
- **Files:**
|
||||
- `src/lib/db/index.ts` — add `connect_timeout: 5`, `max_lifetime: 60 * 60`, `idle_timeout: 30`.
|
||||
- Wrap critical-path queries in retry-on-CONNECT_TIMEOUT logic (one retry, then 503).
|
||||
- Consider pgbouncer in front of postgres for production multi-process deployments.
|
||||
- **Effort:** S for the postgres-js options, M for full pgbouncer.
|
||||
|
||||
---
|
||||
|
||||
## TIER 2 — High Impact Architectural / UX
|
||||
|
||||
Not strictly deploy-blocking, but each one breaks the UX in observable ways every day.
|
||||
|
||||
### F9. Layout-wide duplicate mobile/desktop DOM rendering (task #26)
|
||||
|
||||
- **Impact:** Single highest leverage UX bug. EVERY page mounts BOTH responsive layouts; both Radix Tabs providers are concurrently active with `data-state="active"`. Half my click attempts on tabs/filters/popovers went to the wrong layer. Doubled network requests, doubled component state, doubled a11y landmarks.
|
||||
- **Files:** the responsive shell (likely `src/components/layout/*-shell.tsx` and detail-page wrappers).
|
||||
- **Fix options:** use `useMediaQuery` to mount only one tree; or hoist `<Tabs>` to a single provider and let both layouts consume context.
|
||||
- **Effort:** L (architectural refactor across multiple pages).
|
||||
|
||||
### F10. Archiving a client doesn't cascade-archive their interests (task #66)
|
||||
|
||||
- **Impact:** Orphan refs. Archived clients have active interests; active queries surface them with broken breadcrumbs / silent 404s on drill-in.
|
||||
- **Files:** `src/lib/services/clients.service.ts:archiveClient()` — wrap in transaction, archive open interests too. OR extend `activeInterestsWhere()` to filter on `client.archived_at IS NULL`.
|
||||
- **Effort:** S.
|
||||
|
||||
---
|
||||
|
||||
## TIER 3 — Standard Fixes (P3)
|
||||
|
||||
UX polish + missing entry points. Each is small, but the sum matters.
|
||||
|
||||
### F11. "Mark as won" dialog still says "moves to Completed" (task #27)
|
||||
|
||||
- **Impact:** Stale copy from before the 7-stage refactor. Misleads users.
|
||||
- **File:** `src/components/interests/won-dialog.tsx` (or similar) — update copy to "marks Won; stage stays at <current>".
|
||||
- **Effort:** XS.
|
||||
|
||||
### F12. Activity feed + tab count concatenation (task #23)
|
||||
|
||||
- **Impact:** "Test Person 1interest", "Interests0", "Click Test Co.company" — unprofessional.
|
||||
- **Files:** `src/components/dashboard/activity-feed.tsx` (entity name + type), every detail-page tab count render. Audit log FTS `search_text` should also include entity names.
|
||||
- **Effort:** S.
|
||||
|
||||
### F13. Bulk-add berths wizard has no UI entry point (task #28)
|
||||
|
||||
- **Impact:** Feature built for new-port setup, but invisible. Operator must know the URL.
|
||||
- **Files:** Add a "Bulk add" button next to "New berth" on `/[portSlug]/berths`. Add link on `/admin` landing card.
|
||||
- **Effort:** S.
|
||||
|
||||
### F14. Audit Log page has no UI entry point (task #49)
|
||||
|
||||
- **Impact:** Feature built, no nav link. Discovery requires URL knowledge.
|
||||
- **Files:** Sidebar Admin section — add "Audit Log" entry under `documents` settings or as its own item, gated by `audit_log.view` permission.
|
||||
- **Effort:** S.
|
||||
|
||||
### F15. New Yacht dialog only lists clients in owner picker (task #44)
|
||||
|
||||
- **Impact:** Data model supports `'client' | 'company'` ownership; UI only lets you pick clients. Cannot create company-owned yacht via UI.
|
||||
- **Files:** `src/components/yachts/new-yacht-dialog.tsx` — add owner-type segmented control (Client / Company) above the owner picker; switch data source.
|
||||
- **Effort:** S.
|
||||
|
||||
### F16. InlineTagEditor "Add tag" focus + create flow (task #45)
|
||||
|
||||
- **Impact:** Typing in the tag widget set the CONTACT LABEL instead. Plus no "Create new tag" affordance for new tag names.
|
||||
- **Files:** `src/components/shared/inline-tag-editor.tsx`. Fix focus target; surface "Create new: X" as a popover item; orchestrate POST /api/v1/tags then PUT .../tags.
|
||||
- **Effort:** S.
|
||||
|
||||
### F17. Cross-port (and 404) detail URLs silently render list shell (task #48)
|
||||
|
||||
- **Impact:** User pastes a wrong-port URL → API 404s correctly but UI silently shows the list shell. No explicit "not found" message.
|
||||
- **Files:** every entity-detail client component — render `<EmptyState title="Not found" />` when GET returns 404. Apply to clients, interests, yachts, companies, berths.
|
||||
- **Effort:** M (apply pattern to each detail page).
|
||||
|
||||
### F18. Recommender `limit` param ignored (task #69)
|
||||
|
||||
- **Impact:** Request with `{"limit": 3}` returned 8 berths. Either param name mismatch or no clamp.
|
||||
- **Files:** `src/lib/services/berth-recommender.service.ts` + the recommend-berths validator.
|
||||
- **Effort:** XS.
|
||||
|
||||
---
|
||||
|
||||
## TIER 4 — Polish & UX Reductions (P4)
|
||||
|
||||
The `UX EFFICIENCY` list (task #24). Each is small, mostly copy/flow improvements.
|
||||
|
||||
### F19. New Client form — primary contact default trap
|
||||
|
||||
- Default-checked "Primary contact" with empty email silently rejects on submit. Either don't pre-add OR drop empty contacts on save.
|
||||
|
||||
### F20. New Interest dialog — redirect to detail page on create
|
||||
|
||||
- Currently returns to the list. Add `router.push('/interests/' + newId)` to land on the workflow page immediately.
|
||||
|
||||
### F21. Stage-transition error toast leaks developer language
|
||||
|
||||
- "yachtId is required before leaving stage=enquiry" → "Yacht is required before leaving the Enquiry stage."
|
||||
- Audit ALL ValidationError + ConflictError + service error messages for user-readable copy.
|
||||
|
||||
### F22. Stage menu uses unicode emoji `⚑` as prereq-blocked indicator
|
||||
|
||||
- Per user preference (memory: avoid decorative emoji), replace with a Lucide icon (`Lock`, `AlertCircle`, or `FlagOff`).
|
||||
|
||||
### F23. Blocked-stage UX — show prereq picker inline
|
||||
|
||||
- Clicking a blocked stage currently dismisses with a toast. Better: open the prereq picker inline ("Pick a yacht to leave Enquiry" with combobox right there).
|
||||
|
||||
### F24. New Client form — "Country" optional but prominent
|
||||
|
||||
- Drop from quick-path OR move to a "More details" disclosure.
|
||||
|
||||
### F25. Documents Hub — folder navigation doesn't update URL
|
||||
|
||||
- Drilling into a folder updates "Current location" but doesn't change `location.search`. Can't deep-link, browser-back broken, refresh resets to root.
|
||||
|
||||
### F26. "Reopen" outcome action silent — no toast
|
||||
|
||||
- After clicking Reopen, no feedback. Add `toast.success('Outcome cleared')` or similar.
|
||||
|
||||
### F27. Same-stage write returns full body — should be 204
|
||||
|
||||
- PATCH /stage with same stage = current stage returns 200 + full interest. Should be 204 No Content (no-op).
|
||||
|
||||
### F28. Recommender empty-result UI
|
||||
|
||||
- 300ft yacht returns `data: []` — UI Recommendations tab silently shows blank. Should render "No berths match — try relaxing constraints."
|
||||
|
||||
### F29. Inbox first-load "Loading..." stuck
|
||||
|
||||
- First navigation to /inbox shows "Loading..." indefinitely; subsequent reload renders fine. TanStack Query cache initialization issue.
|
||||
|
||||
### F30. Berths in default queries should filter `archivedAt IS NULL`
|
||||
|
||||
- Companion to F5 — once soft-delete lands, every default list query must filter archived rows.
|
||||
|
||||
---
|
||||
|
||||
## NEW FEATURE — Manual Berth Status Catch-Up Workflow (task #67)
|
||||
|
||||
User-requested. Foundation already exists (column `berths.status_override_mode` is in schema but never written).
|
||||
|
||||
### Phase 1 — Wire the status_override_mode field
|
||||
|
||||
- `updateBerthStatus()` sets `status_override_mode = 'manual'` when called via the user-facing API.
|
||||
- `berth-rules-engine.ts` triggers set `status_override_mode = 'automated'`.
|
||||
- When a backing interest is successfully created and links the berth, clear `status_override_mode` back to null in the same transaction; set `status_last_changed_reason` to "Reconciled via interest [id]".
|
||||
- **Effort:** S.
|
||||
|
||||
### Phase 2 — Visual indicator
|
||||
|
||||
- On berth list rows: small chip "Manual" next to the status badge when `status_override_mode = 'manual'` AND no active interest is linked.
|
||||
- On berth detail page header: badge + tooltip showing last reason, user, when.
|
||||
- On dashboard "Berth Heat" widget: filter or annotate the manual rows.
|
||||
- **Effort:** S.
|
||||
|
||||
### Phase 3 — Reconciliation Queue page
|
||||
|
||||
- New page `/[portSlug]/admin/berths/reconcile`.
|
||||
- Lists every berth where `status_override_mode = 'manual'` and no active interest. Sortable by `status_last_modified DESC`.
|
||||
- Each row links to the catch-up wizard.
|
||||
- Sidebar Admin section gets a link with the queue count badge.
|
||||
- **Effort:** S.
|
||||
|
||||
### Phase 4 — Catch-Up Wizard (the core piece)
|
||||
|
||||
- Multi-step modal. Steps:
|
||||
1. **Pick or create client** — combobox + inline quick-create (name + email only).
|
||||
2. **Pick or create yacht** — optional if pre-EOI; quick-create with name + dimensions.
|
||||
3. **Pick the matching stage** — based on current berth status:
|
||||
- `under_offer` → enquiry / qualified / nurturing / eoi (default eoi)
|
||||
- `sold` → contract + outcome=won
|
||||
- Allow override.
|
||||
4. **Upload existing docs** — EOI PDF, contract PDF, reservation form. Each auto-filed to the right entity folder.
|
||||
5. **Optional payments** — if status=sold, prompt for deposit/full amount.
|
||||
6. **Review + submit.** On submit, transaction:
|
||||
- Create/select client + yacht
|
||||
- Create interest at chosen stage with `assigned_to = current user`
|
||||
- Upsert `interest_berths(is_primary=true, is_specific_interest=true, is_in_eoi_bundle=true)`
|
||||
- Upload + attach files
|
||||
- Insert payments
|
||||
- Set `berth.status_override_mode = null` + `status_last_changed_reason = 'Reconciled via interest [id]'`
|
||||
- Audit log single "reconcile" event linking berth + new interest.
|
||||
- **Effort:** M (wizard) + S (transaction service) + S (API endpoint). Total M-L.
|
||||
|
||||
### Phase 5 — Entry points
|
||||
|
||||
- Berth list row menu → "Catch up..."
|
||||
- Berth detail page next to manual badge → "Catch up"
|
||||
- Dashboard widget "Manual statuses awaiting reconciliation" (count + link)
|
||||
- Sidebar link
|
||||
- **Effort:** S.
|
||||
|
||||
### Total feature effort: M-L (2-3 dev days).
|
||||
|
||||
---
|
||||
|
||||
## What I Tested in Round 2 (15 deep journeys, all passed structural validation)
|
||||
|
||||
| Journey | Result |
|
||||
| -------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| State machine — stage skipping | ✓ Rejects forward/backward jumps with friendly copy + override path |
|
||||
| Double outcome write | ⚠ Allowed (won→lost flips freely); audit log just says "update" — should tag outcome change |
|
||||
| Cascade — delete with dependents | ✗ Inconsistent: clients soft-archive, **berths HARD-delete**, companies soft-archive |
|
||||
| Manual berth status without backing interest | ✗ Foundation column exists, never written |
|
||||
| Unicode (emoji/RTL/zero-width) | ⚠ Emoji + RTL OK; zero-width chars NOT stripped (search blind spot) |
|
||||
| Storage / file upload magic-byte | ✓ Rejects JPEG/HTML disguised as PDF |
|
||||
| Documenso webhook idempotency | ✓ Timing-safe + rate-limited bad-secret check |
|
||||
| Berth recommender edge cases | ⚠ Empty dims OK; extreme dims return empty; **limit param ignored** |
|
||||
| Email body XSS via markdown | ✓ Escape-first-then-rules, javascript: URLs stripped |
|
||||
| Public berth feed correctness | ✓ Port allow-list, archive filter, status enum validation |
|
||||
| Rate limiting / abuse | ✗ Login: no rate limit; public feed: CDN-cached |
|
||||
| Health check + dependency probes | ✓ Anonymous minimal payload, secret-mode for website-intake |
|
||||
| Direct ID enumeration | ✓ Uniform 404 — no leak |
|
||||
| Cross-port API access | ✓ 404 at API; **silent at UI** |
|
||||
| CSRF — fake Origin | ✓ Prod-only protection — dev intentionally skips |
|
||||
|
||||
---
|
||||
|
||||
## Recommended Commit Sequence
|
||||
|
||||
1. **Squash-commit T0 fixes** (F1 + F2) — these are deploy-blockers already applied. Push to main.
|
||||
2. **T1 batch commit** (F3, F4, F5, F6, F7, F8) — pre-deploy blockers. Single commit per fix for clean review.
|
||||
3. **T2** (F9, F10) — schedule for next sprint (F9 is architectural).
|
||||
4. **T3** (F11-F18) — knock out in a few hours. Quick polish wave.
|
||||
5. **T4** (F19-F30) — UX list. Bundle into a single PR over a few sessions.
|
||||
6. **NEW FEATURE — Catch-Up Workflow** — 2-3 dev days. Higher business value than T2; prioritize after T1.
|
||||
|
||||
---
|
||||
|
||||
## Risk Notes
|
||||
|
||||
- The audit polluted the dev DB with test entities: `Smoke Test Client (renamed)`, `Aurora Marine Holdings Ltd`, `Bad Email Test`, `Phone Test`, `Robert'; DROP TABLE clients`, `François 🏄 المعتمد`, `محمد عبد الله`, `CSRF Test`, etc. Also **hard-deleted berth A1 in port-amador** + soft-archived Test Person 1. Consider `pnpm db:reseed:synthetic` before the next clean run.
|
||||
- The Smoke Test Client interest had `outcome=lost_other` set during the won-then-lost test (R2-B). Audit log preserved both transitions but with action="update" not action="outcome_change".
|
||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import './.next/types/routes.d.ts';
|
||||
import './.next/dev/types/routes.d.ts';
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
23
src/app/api/v1/berths/[id]/restore/route.ts
Normal file
23
src/app/api/v1/berths/[id]/restore/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { restoreBerth } from '@/lib/services/berths.service';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
// POST /api/v1/berths/[id]/restore
|
||||
// Post-audit F5: reverses an archive. No body. Audit-logged.
|
||||
export const POST = withAuth(
|
||||
withPermission('berths', 'edit', async (_req, ctx, params) => {
|
||||
try {
|
||||
await restoreBerth(params.id!, ctx.portId, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -2,8 +2,8 @@ import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { updateBerthSchema } from '@/lib/validators/berths';
|
||||
import { getBerthById, updateBerth, deleteBerth } from '@/lib/services/berths.service';
|
||||
import { updateBerthSchema, archiveBerthSchema } from '@/lib/validators/berths';
|
||||
import { getBerthById, updateBerth, archiveBerth } from '@/lib/services/berths.service';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
// GET /api/v1/berths/[id]
|
||||
@@ -37,10 +37,14 @@ export const PATCH = withAuth(
|
||||
);
|
||||
|
||||
// DELETE /api/v1/berths/[id]
|
||||
// Post-audit F5: this is a SOFT-ARCHIVE, not a hard delete. The body
|
||||
// must carry `{ reason: string (>=5 chars) }`. Use POST /restore to
|
||||
// reverse. Archive is blocked when an active interest is still linked.
|
||||
export const DELETE = withAuth(
|
||||
withPermission('berths', 'edit', async (_req, ctx, params) => {
|
||||
withPermission('berths', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
await deleteBerth(params.id!, ctx.portId, {
|
||||
const body = await parseBody(req, archiveBerthSchema);
|
||||
await archiveBerth(params.id!, ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
|
||||
@@ -15,7 +15,10 @@ import { bulkAddBerthsSchema } from '@/lib/validators/berths';
|
||||
* /[portSlug]/admin/berths/bulk-add.
|
||||
*/
|
||||
export const POST = withAuth(
|
||||
withPermission('berths', 'create', async (req, ctx) => {
|
||||
// F13: aligned with the seed-permissions scope (`berths.import`).
|
||||
// The previous `berths.create` was a phantom key — not in the role
|
||||
// matrix, so non-super-admins silently failed permission resolution.
|
||||
withPermission('berths', 'import', async (req, ctx) => {
|
||||
try {
|
||||
const input = await parseBody(req, bulkAddBerthsSchema);
|
||||
const result = await bulkAddBerths(ctx.portId, input.berths, {
|
||||
|
||||
@@ -11,20 +11,29 @@ import { recommendBerths } from '@/lib/services/berth-recommender.service';
|
||||
* param) and `portId` (resolved from the auth context — never trust a
|
||||
* client-supplied port, plan §14.10).
|
||||
*/
|
||||
const recommendBerthsSchema = z.object({
|
||||
topN: z.number().int().min(1).max(999).optional(),
|
||||
maxOversizePct: z.number().min(0).max(1000).optional(),
|
||||
showLateStage: z.boolean().optional(),
|
||||
amenityFilters: z
|
||||
.object({
|
||||
minPowerCapacityKw: z.number().min(0).optional(),
|
||||
requiredVoltage: z.number().int().min(0).optional(),
|
||||
requiredAccess: z.string().min(1).optional(),
|
||||
requiredMooringType: z.string().min(1).optional(),
|
||||
requiredCleatCapacity: z.string().min(1).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
const recommendBerthsSchema = z
|
||||
.object({
|
||||
topN: z.number().int().min(1).max(999).optional(),
|
||||
// F18: accept `limit` as a friendlier alias for `topN`. The audit
|
||||
// sent `{limit: 3}` and was silently ignored; conventional API users
|
||||
// expect the limit name. Both resolve to the same internal value.
|
||||
limit: z.number().int().min(1).max(999).optional(),
|
||||
maxOversizePct: z.number().min(0).max(1000).optional(),
|
||||
showLateStage: z.boolean().optional(),
|
||||
amenityFilters: z
|
||||
.object({
|
||||
minPowerCapacityKw: z.number().min(0).optional(),
|
||||
requiredVoltage: z.number().int().min(0).optional(),
|
||||
requiredAccess: z.string().min(1).optional(),
|
||||
requiredMooringType: z.string().min(1).optional(),
|
||||
requiredCleatCapacity: z.string().min(1).optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.transform(({ limit, topN, ...rest }) => ({
|
||||
...rest,
|
||||
topN: topN ?? limit,
|
||||
}));
|
||||
|
||||
// POST /api/v1/interests/[id]/recommend-berths
|
||||
export const POST = withAuth(
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { useSearchParams, useRouter, useParams } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { DetailLayout } from '@/components/shared/detail-layout';
|
||||
import { DetailNotFound } from '@/components/shared/detail-not-found';
|
||||
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
@@ -17,10 +18,18 @@ interface BerthDetailProps {
|
||||
}
|
||||
|
||||
export function BerthDetail({ berthId }: BerthDetailProps) {
|
||||
const { data, isLoading } = useQuery<BerthDetailData>({
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
|
||||
const { data, isLoading, error } = useQuery<BerthDetailData>({
|
||||
queryKey: ['berth', berthId],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: BerthDetailData }>(`/api/v1/berths/${berthId}`).then((r) => r.data),
|
||||
retry: (failureCount, err) => {
|
||||
const status = (err as { status?: number } | null | undefined)?.status;
|
||||
if (status === 404 || status === 403) return false;
|
||||
return failureCount < 2;
|
||||
},
|
||||
});
|
||||
|
||||
useRealtimeInvalidation({
|
||||
@@ -56,6 +65,18 @@ export function BerthDetail({ berthId }: BerthDetailProps) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams]);
|
||||
|
||||
if (error && !isLoading) {
|
||||
const status = (error as { status?: number } | null | undefined)?.status;
|
||||
return (
|
||||
<DetailNotFound
|
||||
entity="berth"
|
||||
backHref={`/${portSlug}/berths`}
|
||||
backLabel="Back to berths"
|
||||
status={status}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const berth = data;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { Anchor } from 'lucide-react';
|
||||
import { Anchor, Plus } from 'lucide-react';
|
||||
|
||||
import { DataTable } from '@/components/shared/data-table';
|
||||
import { FilterBar } from '@/components/shared/filter-bar';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown';
|
||||
import { ColumnPicker } from '@/components/shared/column-picker';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||
import { usePermissions } from '@/hooks/use-permissions';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { useTablePreferences } from '@/hooks/use-table-preferences';
|
||||
import { BerthCard } from './berth-card';
|
||||
@@ -26,6 +29,11 @@ import { mooringLetterTone } from './mooring-letter-tone';
|
||||
export function BerthList() {
|
||||
const router = useRouter();
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
// F13: bulk-add wizard had no UI entry point. Gate the CTA on
|
||||
// `berths.import` (the existing permission used for adding berths)
|
||||
// so non-admins don't see a button that 403s on click.
|
||||
const { can } = usePermissions();
|
||||
const canBulkAdd = can('berths', 'import');
|
||||
|
||||
const {
|
||||
data,
|
||||
@@ -97,6 +105,14 @@ export function BerthList() {
|
||||
}}
|
||||
/>
|
||||
<ColumnPicker columns={BERTH_COLUMN_OPTIONS} hidden={hidden} onChange={setHidden} />
|
||||
{canBulkAdd && (
|
||||
<Button asChild size="sm" variant="default">
|
||||
<Link href={`/${params.portSlug}/admin/berths/bulk-add`}>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Bulk add</span>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
import { DetailLayout } from '@/components/shared/detail-layout';
|
||||
import { DetailNotFound } from '@/components/shared/detail-not-found';
|
||||
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||
import { ClientDetailHeader } from '@/components/clients/client-detail-header';
|
||||
import { getClientTabs } from '@/components/clients/client-tabs';
|
||||
@@ -79,10 +81,18 @@ interface ClientDetailProps {
|
||||
}
|
||||
|
||||
export function ClientDetail({ clientId, currentUserId }: ClientDetailProps) {
|
||||
const { data, isLoading } = useQuery<ClientData>({
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
|
||||
const { data, isLoading, error } = useQuery<ClientData>({
|
||||
queryKey: ['clients', clientId],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: ClientData }>(`/api/v1/clients/${clientId}`).then((r) => r.data),
|
||||
retry: (failureCount, err) => {
|
||||
const status = (err as { status?: number } | null | undefined)?.status;
|
||||
if (status === 404 || status === 403) return false;
|
||||
return failureCount < 2;
|
||||
},
|
||||
});
|
||||
|
||||
const { setChrome } = useMobileChrome();
|
||||
@@ -108,6 +118,18 @@ export function ClientDetail({ clientId, currentUserId }: ClientDetailProps) {
|
||||
'berth_reservation:cancelled': [['clients', clientId]],
|
||||
});
|
||||
|
||||
if (error && !isLoading) {
|
||||
const status = (error as { status?: number } | null | undefined)?.status;
|
||||
return (
|
||||
<DetailNotFound
|
||||
entity="client"
|
||||
backHref={`/${portSlug}/clients`}
|
||||
backLabel="Back to clients"
|
||||
status={status}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs = data ? getClientTabs({ clientId, currentUserId, client: data }) : [];
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
import { DetailLayout } from '@/components/shared/detail-layout';
|
||||
import { DetailNotFound } from '@/components/shared/detail-not-found';
|
||||
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||
import { CompanyDetailHeader } from '@/components/companies/company-detail-header';
|
||||
import { getCompanyTabs } from '@/components/companies/company-tabs';
|
||||
@@ -42,10 +43,15 @@ export function CompanyDetail({ companyId, currentUserId }: CompanyDetailProps)
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
|
||||
const { data, isLoading } = useQuery<CompanyData>({
|
||||
const { data, isLoading, error } = useQuery<CompanyData>({
|
||||
queryKey: ['companies', companyId],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: CompanyData }>(`/api/v1/companies/${companyId}`).then((r) => r.data),
|
||||
retry: (failureCount, err) => {
|
||||
const status = (err as { status?: number } | null | undefined)?.status;
|
||||
if (status === 404 || status === 403) return false;
|
||||
return failureCount < 2;
|
||||
},
|
||||
});
|
||||
|
||||
const { setChrome } = useMobileChrome();
|
||||
@@ -65,6 +71,18 @@ export function CompanyDetail({ companyId, currentUserId }: CompanyDetailProps)
|
||||
'company_membership:ended': [['companies', companyId, 'members']],
|
||||
});
|
||||
|
||||
if (error && !isLoading) {
|
||||
const status = (error as { status?: number } | null | undefined)?.status;
|
||||
return (
|
||||
<DetailNotFound
|
||||
entity="company"
|
||||
backHref={`/${portSlug}/companies`}
|
||||
backLabel="Back to companies"
|
||||
status={status}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs = data ? getCompanyTabs({ companyId, portSlug, currentUserId, company: data }) : [];
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
import { DetailLayout } from '@/components/shared/detail-layout';
|
||||
import { DetailNotFound } from '@/components/shared/detail-not-found';
|
||||
import { InterestDetailHeader } from '@/components/interests/interest-detail-header';
|
||||
import { getInterestTabs } from '@/components/interests/interest-tabs';
|
||||
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||
@@ -82,10 +83,17 @@ export function InterestDetail({ interestId, currentUserId }: InterestDetailProp
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
|
||||
const { data, isLoading } = useQuery<InterestData>({
|
||||
const { data, isLoading, error } = useQuery<InterestData>({
|
||||
queryKey: ['interests', interestId],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`).then((r) => r.data),
|
||||
// F17: don't retry 404s — they're intentional (wrong port, archived,
|
||||
// deleted). Let the error state render the EmptyState below.
|
||||
retry: (failureCount, err) => {
|
||||
const status = (err as { status?: number } | null | undefined)?.status;
|
||||
if (status === 404 || status === 403) return false;
|
||||
return failureCount < 2;
|
||||
},
|
||||
});
|
||||
|
||||
useRealtimeInvalidation({
|
||||
@@ -119,6 +127,19 @@ export function InterestDetail({ interestId, currentUserId }: InterestDetailProp
|
||||
: null,
|
||||
);
|
||||
|
||||
// F17: explicit "not found" state when the API 404'd or 403'd.
|
||||
if (error && !isLoading) {
|
||||
const status = (error as { status?: number } | null | undefined)?.status;
|
||||
return (
|
||||
<DetailNotFound
|
||||
entity="interest"
|
||||
backHref={`/${portSlug}/interests`}
|
||||
backLabel="Back to interests"
|
||||
status={status}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs = data
|
||||
? getInterestTabs({
|
||||
interestId,
|
||||
|
||||
@@ -126,8 +126,17 @@ export function InterestOutcomeDialog({ interestId, open, onOpenChange, mode }:
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This will move the interest to <strong>Completed</strong> and stamp the outcome. You can
|
||||
reopen it later.
|
||||
{mode === 'won' ? (
|
||||
<>
|
||||
This will mark the interest as <strong>Won</strong>. The pipeline stage stays where
|
||||
it is; the outcome flag is set. You can clear the outcome later to reopen.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
This will close the interest with a <strong>Lost</strong> outcome. The pipeline
|
||||
stage stays where it is. You can clear the outcome later to reopen.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Globe,
|
||||
Settings,
|
||||
Shield,
|
||||
ScrollText,
|
||||
Home,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
@@ -159,6 +160,8 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
||||
items: [
|
||||
{ href: `${base}/settings`, label: 'Settings', icon: Settings },
|
||||
{ href: `${base}/admin`, label: 'Administration', icon: Shield },
|
||||
// F14: audit log page existed but had no nav link.
|
||||
{ href: `${base}/admin/audit`, label: 'Audit Log', icon: ScrollText },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
48
src/components/shared/detail-not-found.tsx
Normal file
48
src/components/shared/detail-not-found.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { SearchX } from 'lucide-react';
|
||||
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
|
||||
interface DetailNotFoundProps {
|
||||
/** "interest", "client", "yacht", etc. — used to build the copy. */
|
||||
entity: string;
|
||||
/** Plural list path back-link. e.g. "/port-x/clients". */
|
||||
backHref: string;
|
||||
/** Plural label for the back button, e.g. "Back to clients". */
|
||||
backLabel: string;
|
||||
/** HTTP status code from the failed fetch (404 vs 403 changes copy). */
|
||||
status?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an explicit "not found" / "no access" panel for entity detail
|
||||
* pages whose data fetch failed. Replaces the prior silent-list-shell
|
||||
* behaviour where a wrong-port URL paste rendered the navigation chrome
|
||||
* with empty tabs and no error message. Post-audit F17.
|
||||
*/
|
||||
export function DetailNotFound({ entity, backHref, backLabel, status }: DetailNotFoundProps) {
|
||||
const denied = status === 403;
|
||||
return (
|
||||
<EmptyState
|
||||
icon={SearchX}
|
||||
title={denied ? `No access to this ${entity}` : `${capitalize(entity)} not found`}
|
||||
description={
|
||||
denied
|
||||
? `You do not have permission to view this ${entity} in this port.`
|
||||
: `It may have been removed, archived, or it belongs to a different port. Use the back button or pick a different ${entity}.`
|
||||
}
|
||||
className="mt-16"
|
||||
action={{
|
||||
label: backLabel,
|
||||
onClick: () => {
|
||||
window.location.assign(backHref);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function capitalize(s: string) {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, X, Check } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
@@ -43,6 +44,7 @@ export function InlineTagEditor({
|
||||
}: InlineTagEditorProps) {
|
||||
const qc = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
// Always fetch so we can hide the editor entirely when no tags are
|
||||
// configured AND the entity has no tags already applied — keeps the
|
||||
@@ -60,6 +62,14 @@ export function InlineTagEditor({
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
|
||||
// F16: inline "Create new tag: X" — was missing entirely. Reps had to
|
||||
// context-switch to Admin → Tags to create the tag, then come back.
|
||||
const createTag = useMutation({
|
||||
mutationFn: (name: string) =>
|
||||
apiFetch<{ data: Tag }>('/api/v1/tags', { method: 'POST', body: { name } }),
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
|
||||
function toggleTag(tagId: string) {
|
||||
const has = currentTags.some((t) => t.id === tagId);
|
||||
const nextIds = has
|
||||
@@ -72,6 +82,26 @@ export function InlineTagEditor({
|
||||
setTags.mutate(currentTags.filter((t) => t.id !== tagId).map((t) => t.id));
|
||||
}
|
||||
|
||||
async function handleCreateAndAttach(name: string) {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
const res = await createTag.mutateAsync(trimmed);
|
||||
const newTag = res.data;
|
||||
// Optimistically extend `allTags` so the new chip appears immediately.
|
||||
qc.invalidateQueries({ queryKey: ['tags'] });
|
||||
setTags.mutate([...currentTags.map((t) => t.id), newTag.id]);
|
||||
setSearch('');
|
||||
}
|
||||
|
||||
const lowerSearch = search.trim().toLowerCase();
|
||||
const filtered = lowerSearch
|
||||
? (allTags?.data ?? []).filter((t) => t.name.toLowerCase().includes(lowerSearch))
|
||||
: (allTags?.data ?? []);
|
||||
// Suggest "Create new tag: X" when the user typed something but no
|
||||
// exact-case-insensitive match exists.
|
||||
const exactMatch = (allTags?.data ?? []).some((t) => t.name.toLowerCase() === lowerSearch);
|
||||
const canCreate = !!lowerSearch && !exactMatch;
|
||||
|
||||
// Hide the whole editor when the port has no tags configured AND this
|
||||
// entity has none applied. Once an admin adds the first tag in
|
||||
// Admin → Tags, the editor reappears on next mount/refetch.
|
||||
@@ -116,14 +146,26 @@ export function InlineTagEditor({
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-0" align="start">
|
||||
<div className="border-b p-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Enter on a non-empty no-match → create and attach.
|
||||
if (e.key === 'Enter' && canCreate && !createTag.isPending) {
|
||||
e.preventDefault();
|
||||
void handleCreateAndAttach(search);
|
||||
}
|
||||
}}
|
||||
placeholder="Search or create…"
|
||||
className="h-7 text-xs"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto py-1">
|
||||
{!allTags && <div className="px-3 py-2 text-xs text-muted-foreground">Loading…</div>}
|
||||
{allTags?.data.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">
|
||||
No tags defined yet. Create some in Admin → Tags.
|
||||
</div>
|
||||
)}
|
||||
{allTags?.data.map((t) => {
|
||||
{filtered.map((t) => {
|
||||
const checked = currentTags.some((c) => c.id === t.id);
|
||||
return (
|
||||
<button
|
||||
@@ -143,6 +185,26 @@ export function InlineTagEditor({
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{canCreate && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={createTag.isPending}
|
||||
onClick={() => void handleCreateAndAttach(search)}
|
||||
className="flex w-full items-center gap-2 border-t px-3 py-1.5 text-sm text-left hover:bg-muted/60"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<span className="flex-1 truncate">
|
||||
Create new tag: <strong>{search.trim()}</strong>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{filtered.length === 0 && !canCreate && !!allTags && (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{allTags.data.length === 0
|
||||
? 'No tags defined yet. Type a name to create one.'
|
||||
: 'No matching tags.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
import { DetailLayout } from '@/components/shared/detail-layout';
|
||||
import { DetailNotFound } from '@/components/shared/detail-not-found';
|
||||
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||
import { YachtDetailHeader } from '@/components/yachts/yacht-detail-header';
|
||||
import { getYachtTabs } from '@/components/yachts/yacht-tabs';
|
||||
@@ -43,9 +45,17 @@ interface YachtDetailProps {
|
||||
}
|
||||
|
||||
export function YachtDetail({ yachtId, currentUserId }: YachtDetailProps) {
|
||||
const { data, isLoading } = useQuery<YachtData>({
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
|
||||
const { data, isLoading, error } = useQuery<YachtData>({
|
||||
queryKey: ['yachts', yachtId],
|
||||
queryFn: () => apiFetch<{ data: YachtData }>(`/api/v1/yachts/${yachtId}`).then((r) => r.data),
|
||||
retry: (failureCount, err) => {
|
||||
const status = (err as { status?: number } | null | undefined)?.status;
|
||||
if (status === 404 || status === 403) return false;
|
||||
return failureCount < 2;
|
||||
},
|
||||
});
|
||||
|
||||
const { setChrome } = useMobileChrome();
|
||||
@@ -66,6 +76,18 @@ export function YachtDetail({ yachtId, currentUserId }: YachtDetailProps) {
|
||||
],
|
||||
});
|
||||
|
||||
if (error && !isLoading) {
|
||||
const status = (error as { status?: number } | null | undefined)?.status;
|
||||
return (
|
||||
<DetailNotFound
|
||||
entity="yacht"
|
||||
backHref={`/${portSlug}/yachts`}
|
||||
backLabel="Back to yachts"
|
||||
status={status}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs = data ? getYachtTabs({ yachtId, currentUserId, yacht: data }) : [];
|
||||
|
||||
return (
|
||||
|
||||
@@ -109,6 +109,31 @@ function buildAuth() {
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict' as const,
|
||||
},
|
||||
// Trust the X-Forwarded-For chain when running behind Caddy/Nginx in
|
||||
// production so the IP that rate-limit + audit logging see is the
|
||||
// real client, not the proxy. Skipped in dev (no proxy in front).
|
||||
ipAddress: {
|
||||
ipAddressHeaders: isProd ? ['x-forwarded-for', 'x-real-ip'] : [],
|
||||
},
|
||||
},
|
||||
|
||||
// Rate limiting (post-audit F7) — without this, brute-force is wide
|
||||
// open. Tight caps on the credential-eating endpoints; loose default
|
||||
// for everything else so legitimate fan-out (multi-widget dashboards
|
||||
// that hit /get-session repeatedly) isn't hampered.
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
window: 60,
|
||||
max: 120,
|
||||
// Memory storage resets on restart. For multi-replica prod, swap
|
||||
// to `storage: 'database'` once the rateLimit migration is run.
|
||||
storage: 'memory',
|
||||
customRules: {
|
||||
'/sign-in/email': { window: 60, max: 5 },
|
||||
'/sign-up/email': { window: 60, max: 3 },
|
||||
'/forget-password': { window: 60, max: 3 },
|
||||
'/reset-password': { window: 60, max: 5 },
|
||||
},
|
||||
},
|
||||
|
||||
logger: {
|
||||
|
||||
@@ -29,11 +29,31 @@ const connectionString = process.env.DATABASE_URL!;
|
||||
// during clients-page fanout without log-storm.
|
||||
const POOL_MAX = process.env.NODE_ENV === 'development' ? 30 : 20;
|
||||
|
||||
// Pool reliability hardening (post-audit F8):
|
||||
// During the audit the dev server twice entered a stuck state where every
|
||||
// query 500'd with `write CONNECT_TIMEOUT` while the DB was healthy
|
||||
// (1 of 100 connections used, queryable from psql immediately).
|
||||
// The Docker bridge can silently drop TCP sockets and postgres-js's pool
|
||||
// holds onto the stale handles until max_lifetime expires.
|
||||
// - connect_timeout: 5s so failures surface fast instead of stalling
|
||||
// requests for 10s before erroring.
|
||||
// - max_lifetime: 10min so connections recycle before stale sockets
|
||||
// accumulate. Was 30min — too long for the Docker socket-drop pattern.
|
||||
// - onnotice: surfaces postgres NOTICE/WARNING messages that we'd
|
||||
// otherwise miss (extension warnings, deprecation hints).
|
||||
const queryClient = postgres(connectionString, {
|
||||
max: POOL_MAX,
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
max_lifetime: 60 * 30,
|
||||
connect_timeout: 5,
|
||||
max_lifetime: 60 * 10,
|
||||
onnotice: (notice) => {
|
||||
// postgres-js types `notice` as `unknown`; the runtime shape is
|
||||
// { severity, code, message, ... }. Only surface WARNING+.
|
||||
const n = notice as { severity?: string; message?: string };
|
||||
if (n.severity && n.severity !== 'NOTICE') {
|
||||
console.warn(`[postgres ${n.severity}] ${n.message ?? ''}`);
|
||||
}
|
||||
},
|
||||
connection: {
|
||||
// ms values per postgres.js types; these become Postgres GUC settings
|
||||
// applied at session start.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq, gte, lte, inArray, sql } from 'drizzle-orm';
|
||||
import { and, eq, gte, lte, inArray, isNull, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { berths, berthTags, berthWaitingList, berthMaintenanceLog } from '@/lib/db/schema/berths';
|
||||
@@ -9,12 +9,11 @@ import { PIPELINE_STAGES } from '@/lib/constants';
|
||||
import { createAuditLog, toAuditJson, type AuditMeta } from '@/lib/audit';
|
||||
import { activeInterestsWhere } from '@/lib/services/active-interest';
|
||||
import { diffEntity } from '@/lib/entity-diff';
|
||||
import { NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import { buildListQuery } from '@/lib/db/query-builder';
|
||||
import { emitToRoom } from '@/lib/socket/server';
|
||||
import { setEntityTags } from '@/lib/services/entity-tags.helper';
|
||||
import { getPortBerthsDefaultCurrency } from '@/lib/services/port-config';
|
||||
import { ConflictError } from '@/lib/errors';
|
||||
import { sortByMooring } from '@/lib/utils/mooring-sort';
|
||||
import type {
|
||||
CreateBerthInput,
|
||||
@@ -668,34 +667,128 @@ export async function bulkAddBerths(
|
||||
return { inserted: inserted.length, ids: inserted.map((r) => r.id) };
|
||||
}
|
||||
|
||||
// ─── Delete ─────────────────────────────────────────────────────────────────
|
||||
// ─── Archive / Restore ─────────────────────────────────────────────────────
|
||||
|
||||
export async function deleteBerth(id: string, portId: string, meta: AuditMeta) {
|
||||
/**
|
||||
* Post-audit F5: soft-archive replaces hard-delete. The previous
|
||||
* `db.delete()` permanently dropped the berth row + cascade-vanished
|
||||
* interest_berths links + broke historical audit references. Now the
|
||||
* row stays; `archived_at` shields it from default queries.
|
||||
*
|
||||
* Reasoning chain:
|
||||
* 1. Block if there's an active (non-archived, no-outcome) interest
|
||||
* still linked — archiving with deals in flight breaks reports.
|
||||
* 2. Stamp archived_at + archived_by + archive_reason in a single update.
|
||||
* 3. Audit log captures the reason so /admin/audit shows the why.
|
||||
* 4. Emit a socket alert so any open berth-detail page bounces.
|
||||
*/
|
||||
export async function archiveBerth(
|
||||
id: string,
|
||||
portId: string,
|
||||
input: { reason: string },
|
||||
meta: AuditMeta,
|
||||
) {
|
||||
const berth = await db.query.berths.findFirst({
|
||||
where: and(eq(berths.id, id), eq(berths.portId, portId)),
|
||||
});
|
||||
if (!berth) throw new NotFoundError('Berth');
|
||||
if (berth.archivedAt) {
|
||||
throw new ConflictError('Berth is already archived');
|
||||
}
|
||||
|
||||
await db.delete(berths).where(and(eq(berths.id, id), eq(berths.portId, portId)));
|
||||
// Block archive when an active interest still depends on the berth —
|
||||
// forces the rep to resolve the deal first instead of orphaning it.
|
||||
const activeLink = await db
|
||||
.select({ interestId: interestBerths.interestId })
|
||||
.from(interestBerths)
|
||||
.innerJoin(interests, eq(interests.id, interestBerths.interestId))
|
||||
.where(
|
||||
and(eq(interestBerths.berthId, id), isNull(interests.archivedAt), isNull(interests.outcome)),
|
||||
)
|
||||
.limit(1);
|
||||
if (activeLink.length > 0) {
|
||||
throw new ConflictError(
|
||||
'Cannot archive a berth with an active interest. Resolve or archive the interest first.',
|
||||
);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(berths)
|
||||
.set({
|
||||
archivedAt: new Date(),
|
||||
archivedBy: meta.userId,
|
||||
archiveReason: input.reason,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(berths.id, id), eq(berths.portId, portId)));
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'delete',
|
||||
action: 'archive',
|
||||
entityType: 'berth',
|
||||
entityId: id,
|
||||
oldValue: { mooringNumber: berth.mooringNumber, area: berth.area },
|
||||
oldValue: { mooringNumber: berth.mooringNumber, area: berth.area, status: berth.status },
|
||||
newValue: { reason: input.reason },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'system:alert', {
|
||||
alertType: 'berth:deleted',
|
||||
message: `Berth "${berth.mooringNumber}" deleted`,
|
||||
alertType: 'berth:archived',
|
||||
message: `Berth "${berth.mooringNumber}" archived: ${input.reason}`,
|
||||
severity: 'info',
|
||||
});
|
||||
}
|
||||
|
||||
/** Un-archive. Available to anyone with `berths:edit`. Audit-logged. */
|
||||
export async function restoreBerth(id: string, portId: string, meta: AuditMeta) {
|
||||
const berth = await db.query.berths.findFirst({
|
||||
where: and(eq(berths.id, id), eq(berths.portId, portId)),
|
||||
});
|
||||
if (!berth) throw new NotFoundError('Berth');
|
||||
if (!berth.archivedAt) {
|
||||
throw new ConflictError('Berth is not archived');
|
||||
}
|
||||
|
||||
await db
|
||||
.update(berths)
|
||||
.set({
|
||||
archivedAt: null,
|
||||
archivedBy: null,
|
||||
archiveReason: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(berths.id, id), eq(berths.portId, portId)));
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'restore',
|
||||
entityType: 'berth',
|
||||
entityId: id,
|
||||
oldValue: { archivedAt: berth.archivedAt, archiveReason: berth.archiveReason },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'system:alert', {
|
||||
alertType: 'berth:restored',
|
||||
message: `Berth "${berth.mooringNumber}" restored`,
|
||||
severity: 'info',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use `archiveBerth` instead. Kept temporarily for callers
|
||||
* that haven't migrated. Calls archiveBerth under the hood — the
|
||||
* "hard delete" name is now a lie but we don't break the import sites
|
||||
* in a single PR.
|
||||
*/
|
||||
export async function deleteBerth(id: string, portId: string, meta: AuditMeta) {
|
||||
return archiveBerth(id, portId, { reason: 'Deleted via legacy delete path' }, meta);
|
||||
}
|
||||
|
||||
// ─── Options ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getBerthOptions(portId: string) {
|
||||
@@ -710,6 +803,8 @@ export async function getBerthOptions(portId: string) {
|
||||
status: berths.status,
|
||||
})
|
||||
.from(berths)
|
||||
.where(eq(berths.portId, portId));
|
||||
// F5: hide archived berths from option pickers; otherwise a dead berth
|
||||
// appears in the New Interest combobox and re-links itself to a deal.
|
||||
.where(and(eq(berths.portId, portId), isNull(berths.archivedAt)));
|
||||
return sortByMooring(rows, (r) => r.mooringNumber);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import { setEntityTags } from '@/lib/services/entity-tags.helper';
|
||||
import { emitToRoom } from '@/lib/socket/server';
|
||||
import { buildListQuery } from '@/lib/db/query-builder';
|
||||
import { diffEntity } from '@/lib/entity-diff';
|
||||
import { softDelete, restore, withTransaction } from '@/lib/db/utils';
|
||||
import { restore, withTransaction } from '@/lib/db/utils';
|
||||
import { logger } from '@/lib/logger';
|
||||
import {
|
||||
syncEntityFolderName,
|
||||
@@ -555,17 +555,37 @@ export async function archiveClient(id: string, portId: string, meta: AuditMeta)
|
||||
throw new NotFoundError('Client');
|
||||
}
|
||||
|
||||
await softDelete(clients, clients.id, id);
|
||||
// F10: cascade-archive the client's open interests so they don't
|
||||
// dangle in active queries with a shadowed client. Won/lost interests
|
||||
// (outcome IS NOT NULL) are kept as historical records — only IN-FLIGHT
|
||||
// deals get archived. Wrapped in a single transaction so a partial
|
||||
// archive can't leave the system half-cascaded.
|
||||
const archivedInterestIds: string[] = await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(clients)
|
||||
.set({ archivedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(clients.id, id));
|
||||
const cascaded = await tx
|
||||
.update(interests)
|
||||
.set({ archivedAt: new Date(), updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(interests.clientId, id),
|
||||
eq(interests.portId, portId),
|
||||
isNull(interests.archivedAt),
|
||||
isNull(interests.outcome),
|
||||
),
|
||||
)
|
||||
.returning({ id: interests.id });
|
||||
return cascaded.map((r) => r.id);
|
||||
});
|
||||
|
||||
// fire-and-forget: archive UI does not depend on the folder suffix
|
||||
// being stamped before the HTTP response returns. Task 5 (rename
|
||||
// hook) uses await because the rename should be visible to the
|
||||
// next read; archive does not.
|
||||
void applyEntityArchivedSuffix(portId, 'client', id, meta.userId).catch((err) => {
|
||||
logger.warn(
|
||||
{ err, clientId: id, portId },
|
||||
'Failed to apply archived suffix to client folder',
|
||||
);
|
||||
logger.warn({ err, clientId: id, portId }, 'Failed to apply archived suffix to client folder');
|
||||
});
|
||||
|
||||
void createAuditLog({
|
||||
@@ -574,11 +594,18 @@ export async function archiveClient(id: string, portId: string, meta: AuditMeta)
|
||||
action: 'archive',
|
||||
entityType: 'client',
|
||||
entityId: id,
|
||||
// Surface the cascade in the audit trail so /admin/audit shows
|
||||
// exactly which interests got swept up.
|
||||
newValue:
|
||||
archivedInterestIds.length > 0 ? { cascadedInterestIds: archivedInterestIds } : undefined,
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'client:archived', { clientId: id });
|
||||
for (const interestId of archivedInterestIds) {
|
||||
emitToRoom(`port:${portId}`, 'interest:archived', { interestId });
|
||||
}
|
||||
|
||||
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
|
||||
dispatchWebhookEvent(portId, 'client:archived', { clientId: id }),
|
||||
@@ -597,10 +624,7 @@ export async function restoreClient(id: string, portId: string, meta: AuditMeta)
|
||||
await restore(clients, clients.id, id);
|
||||
|
||||
void applyEntityRestoredSuffix(portId, 'client', id, meta.userId).catch((err) => {
|
||||
logger.warn(
|
||||
{ err, clientId: id, portId },
|
||||
'Failed to clear archived suffix on client folder',
|
||||
);
|
||||
logger.warn({ err, clientId: id, portId }, 'Failed to clear archived suffix on client folder');
|
||||
});
|
||||
|
||||
void createAuditLog({
|
||||
|
||||
@@ -89,7 +89,8 @@ export async function getKpis(portId: string) {
|
||||
const allBerthsRows = await db
|
||||
.select({ status: berths.status })
|
||||
.from(berths)
|
||||
.where(eq(berths.portId, portId));
|
||||
// F5: archived berths excluded so retired moorings don't dilute denominator.
|
||||
.where(and(eq(berths.portId, portId), isNull(berths.archivedAt)));
|
||||
|
||||
const totalBerths = allBerthsRows.length;
|
||||
const occupiedBerths = allBerthsRows.filter((b) => b.status === 'sold').length;
|
||||
@@ -205,7 +206,7 @@ export async function getBerthStatusDistribution(portId: string) {
|
||||
const rows = await db
|
||||
.select({ status: berths.status, c: sql<number>`count(*)::int` })
|
||||
.from(berths)
|
||||
.where(eq(berths.portId, portId))
|
||||
.where(and(eq(berths.portId, portId), isNull(berths.archivedAt)))
|
||||
.groupBy(berths.status);
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
@@ -110,7 +110,9 @@ export async function requestGdprExport(input: RequestExportInput): Promise<Requ
|
||||
emailToClient: input.emailToClient,
|
||||
emailOverride: input.emailOverride ?? null,
|
||||
},
|
||||
{ jobId: `gdpr-export:${row.id}` },
|
||||
// BullMQ 5.x rejects custom job IDs containing ':' (Redis-key collision).
|
||||
// Dash-separator keeps the namespace + the UUID round-trip unambiguous.
|
||||
{ jobId: `gdpr-export-${row.id}` },
|
||||
);
|
||||
|
||||
return { export: row };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, desc, eq, exists, inArray, isNull, ne, sql } from 'drizzle-orm';
|
||||
import { and, desc, eq, exists, gte, inArray, isNull, ne, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { interests, interestBerths, interestTags, interestNotes } from '@/lib/db/schema/interests';
|
||||
@@ -561,10 +561,7 @@ export async function getInterestById(id: string, portId: string) {
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(interestContactLog)
|
||||
.where(
|
||||
and(
|
||||
eq(interestContactLog.interestId, id),
|
||||
sql`${interestContactLog.occurredAt} >= ${sevenDaysAgo}`,
|
||||
),
|
||||
and(eq(interestContactLog.interestId, id), gte(interestContactLog.occurredAt, sevenDaysAgo)),
|
||||
);
|
||||
|
||||
// Resolve the assignee's display name for the header chip — falling back
|
||||
|
||||
@@ -91,6 +91,17 @@ export const updateBerthStatusSchema = z.object({
|
||||
|
||||
export type UpdateBerthStatusInput = z.infer<typeof updateBerthStatusSchema>;
|
||||
|
||||
// ─── Archive Berth ────────────────────────────────────────────────────────────
|
||||
|
||||
// Post-audit F5: archive replaces hard-delete. A `reason` is required so
|
||||
// the audit trail captures intent — "decommissioned 2026", "duplicate of
|
||||
// A3", etc. min(5) blocks one-letter throwaways.
|
||||
export const archiveBerthSchema = z.object({
|
||||
reason: z.string().trim().min(5, 'Reason must be at least 5 characters'),
|
||||
});
|
||||
|
||||
export type ArchiveBerthInput = z.infer<typeof archiveBerthSchema>;
|
||||
|
||||
// ─── List Berths ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const listBerthsSchema = baseListQuerySchema.extend({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { baseListQuerySchema } from '@/lib/api/list-query';
|
||||
import { humanTextSchema } from '@/lib/validators/text';
|
||||
import {
|
||||
optionalCountryIsoSchema,
|
||||
optionalIanaTimezoneSchema,
|
||||
@@ -9,22 +10,35 @@ import {
|
||||
|
||||
// ─── Contact sub-schema ──────────────────────────────────────────────────────
|
||||
|
||||
export const contactSchema = z.object({
|
||||
channel: z.enum(['email', 'phone', 'whatsapp', 'other']),
|
||||
value: z.string().min(1),
|
||||
/** E.164-normalized number; required when channel is phone/whatsapp. */
|
||||
valueE164: optionalPhoneE164Schema.optional(),
|
||||
/** ISO-3166-1 alpha-2 country the number was parsed against. */
|
||||
valueCountry: optionalCountryIsoSchema.optional(),
|
||||
label: z.string().optional(),
|
||||
isPrimary: z.boolean().optional().default(false),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
export const contactSchema = z
|
||||
.object({
|
||||
channel: z.enum(['email', 'phone', 'whatsapp', 'other']),
|
||||
value: z.string().min(1),
|
||||
/** E.164-normalized number; required when channel is phone/whatsapp. */
|
||||
valueE164: optionalPhoneE164Schema.optional(),
|
||||
/** ISO-3166-1 alpha-2 country the number was parsed against. */
|
||||
valueCountry: optionalCountryIsoSchema.optional(),
|
||||
label: z.string().optional(),
|
||||
isPrimary: z.boolean().optional().default(false),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
// Post-audit F6: email channel must carry a syntactically valid
|
||||
// address. Otherwise sales send-outs bounce silently and the bounce
|
||||
// monitor can't classify.
|
||||
if (data.channel === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.value)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['value'],
|
||||
message: 'Must be a valid email address.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Create ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const createClientSchema = z.object({
|
||||
fullName: z.string().min(1).max(200),
|
||||
fullName: humanTextSchema({ min: 1, max: 200 }),
|
||||
contacts: z.array(contactSchema).min(1, 'At least one contact is required'),
|
||||
/** ISO-3166-1 alpha-2 nationality code. */
|
||||
nationalityIso: optionalCountryIsoSchema.optional(),
|
||||
|
||||
BIN
src/lib/validators/text.ts
Normal file
BIN
src/lib/validators/text.ts
Normal file
Binary file not shown.
@@ -57,6 +57,10 @@ const PUBLIC_PATHS: string[] = [
|
||||
'/api/public/',
|
||||
'/api/health',
|
||||
'/api/webhooks/',
|
||||
// First-run / cold-start: the unauthenticated /setup and /login pages
|
||||
// call /api/v1/bootstrap/status to decide whether to render the setup
|
||||
// form. The route handlers self-protect via hasAnySuperAdmin().
|
||||
'/api/v1/bootstrap/',
|
||||
'/scan',
|
||||
'/portal/',
|
||||
'/api/portal/',
|
||||
|
||||
@@ -166,7 +166,8 @@ describe('requestGdprExport', () => {
|
||||
expect(add).toHaveBeenCalledWith(
|
||||
'gdpr-export',
|
||||
expect.objectContaining({ exportId: row.id, emailToClient: true }),
|
||||
expect.objectContaining({ jobId: `gdpr-export:${row.id}` }),
|
||||
// F3: BullMQ 5.x rejects colons in custom job IDs — switched to dash.
|
||||
expect.objectContaining({ jobId: `gdpr-export-${row.id}` }),
|
||||
);
|
||||
|
||||
// Cleanup the mock so other tests don't see a stubbed queue.
|
||||
|
||||
Reference in New Issue
Block a user