chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged: - Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances) - country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk after the per-subpath dynamic-import approach silently failed in webpack) - Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index, redirects (ocr to ai, reports to dashboard, invitations to users), docs/admin-ia-proposal.md - Per-template email tester (registry + endpoint + UI on Email admin page) - Cancel-document mode picker (delete-from-Documenso vs keep-for-audit) - Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers - Customize-widgets per-region sortables at xl+ (charts/rails/feed); single flat sortable below xl when the layout stacks; per-viewport saved orders - Audit doc updates capturing each shipped item - Lint fixes: react-compiler immutability in DonutChart (reduce instead of let-reassign), set-state-in-effect disables in CountryFlag and UploadForSigning preview-bytes effect, unused 'confirm' destructures in interest contract + reservation tabs, unescaped apostrophe in test-template card copy
This commit is contained in:
415
docs/admin-ia-proposal.md
Normal file
415
docs/admin-ia-proposal.md
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
# Admin IA — Audit + Proposed Regrouping
|
||||||
|
|
||||||
|
**Status:** Phase 1 (proposal + decisions) — captured 2026-05-22 from B3 #10. Open questions resolved in section 7; final IA reflected in section 8. Phase 2 (execution) is mechanical from here.
|
||||||
|
|
||||||
|
## Resolved decisions (2026-05-22)
|
||||||
|
|
||||||
|
User answered the 5 open questions from section 7:
|
||||||
|
|
||||||
|
1. **Forms + Document Templates** → moved to **Sales workflow** (not "Content"). Both are workflow inputs, not abstract content.
|
||||||
|
2. **Webhooks** → keep as its own thing; **new "Integrations" domain** is the right home (Webhooks + Documenso + Website analytics + AI all belong together as "external system + provider config").
|
||||||
|
3. **AI configuration** → keep a dedicated `/admin/ai` panel that consolidates every AI feature in one place; lives under the new **Integrations** domain.
|
||||||
|
4. **`/admin/reports`** → **DELETE entirely** (confirmed duplicative — the dashboard already renders Pipeline funnel + Berth occupancy + KPI cards via widgets). Redirect to `/[portSlug]/dashboard`.
|
||||||
|
5. **`/admin/settings`** (generic KV editor) → keep visible to all admins under System & observability.
|
||||||
|
|
||||||
|
**Net effect:** 7 domains instead of 6; 3 pages deleted (ocr, invitations, reports) instead of 2. Final IA in section 8.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Goal:** today's 41 admin pages are organically grown and discoverability is poor (test-email lives on Branding, an SMTP test on Email, an OCR-settings duplicate exists on both `/admin/ai` and `/admin/ocr`, etc.). Below: page-by-page inventory + a recommended IA in 6 domains.
|
||||||
|
|
||||||
|
**Out of scope here:** the actual file moves, route redirects, and nav updates. That's Phase 2 (~4–6h once the IA below is locked).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Page-by-page inventory (current state, 41 pages)
|
||||||
|
|
||||||
|
Sorted alphabetically. Each row: what the page renders today + its current admin-sections-browser group.
|
||||||
|
|
||||||
|
| Route | What it renders | Current group |
|
||||||
|
| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
|
||||||
|
| `/admin` | `<AdminSectionsBrowser>` — landing tile grid grouped into 5 categories | — |
|
||||||
|
| `/admin/ai` | `<RegistryDrivenForm>` (ai master controls + provider credentials) + `<OcrSettingsForm>` + AI-suggestions card | Operations |
|
||||||
|
| `/admin/audit` | `<AuditLogList>` — full mutation log search | Data Quality |
|
||||||
|
| `/admin/backup` | `<BackupAdminPanel>` — backup posture (read-only) | Operations |
|
||||||
|
| `/admin/berths/bulk-add` | `<BulkAddBerthsWizard>` — generate berth rows in bulk | (not in landing browser) |
|
||||||
|
| `/admin/berths/reconcile` | `<ReconcileQueue>` — review berths missing required fields | (not in landing browser) |
|
||||||
|
| `/admin/branding` | `<RegistryDrivenForm sections={['branding']}>` (identity) + email branding form + `<PdfLogoUploader>` + `<EmailPreviewCard>` | Configuration |
|
||||||
|
| `/admin/brochures` | `<BrochuresAdminPanel>` — upload/version port brochures | (not in landing browser) |
|
||||||
|
| `/admin/custom-fields` | `<CustomFieldsManager>` — per-entity custom-field definitions | Content |
|
||||||
|
| `/admin/documenso` | `<RegistryDrivenForm>` (api creds, signers, templates, behavior) + `<DocumensoTestButton>` + `<TemplateSyncButton>` + `<EmbeddedSigningCard>` | Configuration ("EOI signing service") |
|
||||||
|
| `/admin/duplicates` | `<DuplicatesReviewQueue>` — suspected-duplicate clients | Data Quality |
|
||||||
|
| `/admin/email` | `<RegistryDrivenForm>` (from address + smtp) + `<SmtpTestSendCard>` + `<TestTemplateCard>` (new) + `<SalesEmailConfigCard>` + `<EmailRoutingCard>` | Configuration |
|
||||||
|
| `/admin/email-templates` | `<EmailTemplatesAdmin>` — subject-line overrides per transactional template | Content |
|
||||||
|
| `/admin/errors` | error-event list (system errors) | (not in landing browser) |
|
||||||
|
| `/admin/errors/codes` | error-code catalog reference | (not in landing browser) |
|
||||||
|
| `/admin/errors/[requestId]` | single error-event detail | (not in landing browser) |
|
||||||
|
| `/admin/forms` | `<FormTemplateList>` — public inquiry/intake form schemas | Content |
|
||||||
|
| `/admin/import` | CSV import wizard | Data Quality ("Bulk Import") |
|
||||||
|
| `/admin/inquiries` | `<InquiryInbox>` — public-site submissions awaiting triage | Data Quality |
|
||||||
|
| `/admin/invitations` | (empty body — comment says merged into `/admin/users` 2026-05-21) | (not in landing browser) |
|
||||||
|
| `/admin/monitoring` | `<SystemMonitoringDashboard>` — BullMQ queue health | Operations |
|
||||||
|
| `/admin/monitoring/[queueName]` | `<QueueDetailTable>` — single-queue drill-down | (not in landing browser) |
|
||||||
|
| `/admin/ocr` | `<OcrSettingsForm>` — **DUPLICATES the same form on `/admin/ai`** | (not in landing browser) |
|
||||||
|
| `/admin/onboarding` | `<OnboardingChecklist>` — cross-page setup checklist for new ports | Operations |
|
||||||
|
| `/admin/pipeline-rules` | per-trigger berth-rules editor + `<RegistryDrivenForm sections={['pipeline.auto-advance']}>` | Configuration ("Pipeline auto-advance") |
|
||||||
|
| `/admin/ports` | `<PortList>` — manage marinas (super-admin only) | Operations |
|
||||||
|
| `/admin/pulse` | `<RegistryDrivenForm sections={['pulse']}>` — pulse chip tuning | Configuration |
|
||||||
|
| `/admin/qualification-criteria` | `<QualificationCriteriaAdmin>` — lead-qualification rubric | Operations |
|
||||||
|
| `/admin/reminders` | `<RegistryDrivenForm sections={['reminders.defaults','reminders.digest']}>` | Configuration |
|
||||||
|
| `/admin/reports` | `<ReportsDashboard>` — saved analytics + ad-hoc queries | Operations |
|
||||||
|
| `/admin/residential-stages` | `<ResidentialStagesAdmin>` + stage-template registry form | Operations |
|
||||||
|
| `/admin/roles` | `<RoleList>` — role/permission matrix | Access |
|
||||||
|
| `/admin/sends` | `<SendsLog>` — brochure + per-berth PDF send retries | Data Quality |
|
||||||
|
| `/admin/settings` | `<SettingsManager>` — generic system_settings KV editor (escape hatch) | Configuration ("System Settings") |
|
||||||
|
| `/admin/storage` | `<StorageAdminPanel>` — storage backend selector + migration | Operations |
|
||||||
|
| `/admin/tags` | `<TagList>` — color-coded tags per entity | Content |
|
||||||
|
| `/admin/templates` | `<TemplateList>` — PDF + email document templates (merge-field-driven) | Content |
|
||||||
|
| `/admin/templates/[id]/editor` | per-template editor (PDF + email body) | (not in landing browser) |
|
||||||
|
| `/admin/users` | `<UserList>` + `<InvitationsManager>` (tabs, merged 2026-05-21) | Access |
|
||||||
|
| `/admin/vocabularies` | `<VocabulariesManager>` — admin-editable enum lists | Content |
|
||||||
|
| `/admin/webhooks` | `<WebhookForm>` + `<WebhookDeliveryLog>` + `<WebhookSecretDisplay>` | Configuration |
|
||||||
|
| `/admin/website-analytics` | Umami creds form + `<UmamiTestButton>` | Operations |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Issues identified
|
||||||
|
|
||||||
|
### 2.1 Duplicates
|
||||||
|
|
||||||
|
1. **`/admin/ocr` duplicates `/admin/ai`** — same `<OcrSettingsForm>` is mounted on both. The AI page is the source of truth (it also has the master AI switch + provider creds + AI-suggestions config). **Recommendation: delete `/admin/ocr`** + add a redirect.
|
||||||
|
2. **`/admin/invitations` is dead** — the page body is empty (per the comment, merged into `/admin/users` 2026-05-21). **Recommendation: delete the route** + add a redirect to `/admin/users?tab=invitations`.
|
||||||
|
|
||||||
|
### 2.2 Misplaced cards
|
||||||
|
|
||||||
|
1. **`<EmailPreviewCard>` is on Branding but tests email rendering** — overlap with the new per-template tester on `/admin/email`. **Recommendation: KEEP on Branding** (it's a one-click "does the email LOOK right with current logo/colors?" affordance — that's a branding-validation concern, not a delivery test). Add a sibling link "→ Test individual templates" pointing at `/admin/email`.
|
||||||
|
2. **`<SalesEmailConfigCard>` is on `/admin/email`** — correct home, but it's structurally identical to the noreply SMTP card above it (just a second mailbox). **Recommendation: keep but reformat** so both mailboxes are in matching cards stacked, with a shared "Test send" footer per mailbox.
|
||||||
|
3. **`<EmailRoutingCard>` is on `/admin/email`** — actually it's a routing-rule editor (when X event fires, route through Y mailbox). Conceptually closer to a workflow rule than a credentials setting. **Recommendation: keep on Email** for now (the routing IS about email plumbing) but cross-link from Workflows since changing the rule changes behaviour.
|
||||||
|
|
||||||
|
### 2.3 Inconsistent naming
|
||||||
|
|
||||||
|
1. **"Documenso & EOI"** page title implies EOI lives separately — but EOI generation is one of multiple Documenso flows. **Recommendation: rename to "Signing service (Documenso)"**.
|
||||||
|
2. **"Bulk Import"** vs `/admin/import` — fine, but the page should explicitly say "Data import" (matches the page title `<PageHeader title="Data import">`).
|
||||||
|
3. **"Send Log"** vs `/admin/sends` — fine; consider renaming the route slug to `/admin/send-log` for clarity, but that costs cross-references.
|
||||||
|
|
||||||
|
### 2.4 Pages not in the admin-sections-browser tile grid
|
||||||
|
|
||||||
|
A bunch of pages exist as routes but aren't surfaced on `/admin`:
|
||||||
|
|
||||||
|
- `/admin/berths/bulk-add`, `/admin/berths/reconcile` — only reachable from deep links inside the Berths page
|
||||||
|
- `/admin/brochures`
|
||||||
|
- `/admin/email-templates`, `/admin/tags`, `/admin/vocabularies`, `/admin/custom-fields`, `/admin/forms` — actually these ARE in the browser under "Content", verified
|
||||||
|
- `/admin/qualification-criteria`, `/admin/residential-stages` — under Operations
|
||||||
|
- `/admin/errors`, `/admin/errors/codes`, `/admin/errors/[requestId]`
|
||||||
|
- `/admin/ocr` (duplicate, recommended for deletion)
|
||||||
|
- `/admin/invitations` (dead, recommended for deletion)
|
||||||
|
|
||||||
|
**Recommendation:** surface every active page on `/admin` (no hidden surfaces — discoverability matters for admins). Move `/admin/berths/bulk-add` + `/admin/berths/reconcile` to a new "Berths admin" landing card.
|
||||||
|
|
||||||
|
### 2.5 Categories that don't quite fit
|
||||||
|
|
||||||
|
- **"Content"** is doing too much heavy lifting — it lumps tag color picker (visual), vocab enum lists (config), form templates (workflow), and document templates (mail merge). These are all things admins _tune_ but their cognitive shape is different.
|
||||||
|
- **"Data Quality"** mixes inbound queues (Inquiry Inbox) with cleanup utilities (Duplicates, Bulk Import) — those serve different daily-workflows.
|
||||||
|
- **"Operations"** is the catch-all for "anything observability or infra-shaped" but also has things that are pure setup (AI configuration, residential pipeline stages).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Proposed IA — 6 domains, 38 pages
|
||||||
|
|
||||||
|
Two pages deleted (`/admin/ocr`, `/admin/invitations`), one moved out of admin entirely (`/admin/reports` — see below), one new sub-area (`/admin/berths`). Net page count: 41 → 38.
|
||||||
|
|
||||||
|
### Domain 1. **Brand & Communication** (5 pages)
|
||||||
|
|
||||||
|
_Everything about how outbound looks + which channel it ships on._
|
||||||
|
|
||||||
|
| Page | Action | Notes |
|
||||||
|
| ------------------------ | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `/admin/branding` | KEEP | Logo, colors, app name, email header/footer HTML, the visual "does it look right?" tester. |
|
||||||
|
| `/admin/email` | KEEP | SMTP creds (noreply + sales), routing, per-template tester, SMTP connectivity probe. |
|
||||||
|
| `/admin/email-templates` | KEEP | Subject-line overrides per transactional template. Stays separate from `/admin/email` because the audience is "copywriter" vs "ops". |
|
||||||
|
| `/admin/documenso` | RENAME → "Signing service" | API creds, signer identities, templates, behaviour. Page title currently says "Documenso & EOI" — drop "& EOI" (EOI is one of many doc types). |
|
||||||
|
| `/admin/webhooks` | KEEP | Outbound webhook subscriptions + delivery log. Sits here because webhooks are an outbound-comms channel, same conceptual bucket as email. |
|
||||||
|
|
||||||
|
### Domain 2. **Sales workflow** (7 pages)
|
||||||
|
|
||||||
|
_How the pipeline behaves end-to-end — triggers, scoring, templates._
|
||||||
|
|
||||||
|
| Page | Action | Notes |
|
||||||
|
| ------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `/admin/pipeline-rules` | KEEP | Berth-rules engine triggers + auto-advance. |
|
||||||
|
| `/admin/pulse` | KEEP | Deal Pulse chip tuning. |
|
||||||
|
| `/admin/reminders` | KEEP | Default reminder behaviour + digest window. |
|
||||||
|
| `/admin/qualification-criteria` | MOVE FROM "Operations" → here | Lead-qualification rubric — clearly a sales-workflow concern. |
|
||||||
|
| `/admin/residential-stages` | MOVE FROM "Operations" → here | Residential pipeline shape. Same domain as the standard pipeline rules. |
|
||||||
|
| `/admin/forms` | MOVE FROM "Content" → here | Form templates drive lead intake — workflow input, not "content". |
|
||||||
|
| `/admin/templates` | MOVE FROM "Content" → here | Document templates carry merge fields tied to the pipeline (EOI, reservation, contract). These ARE pipeline artefacts. |
|
||||||
|
|
||||||
|
### Domain 3. **Catalog** (4 pages)
|
||||||
|
|
||||||
|
_Tenant-defined data shapes — values that get attached to records._
|
||||||
|
|
||||||
|
| Page | Action | Notes |
|
||||||
|
| ---------------------- | -------------------------- | ---------------------------------------------------------------------------- |
|
||||||
|
| `/admin/vocabularies` | KEEP | Admin-editable enum lists (berth_side_pontoon_options, lead_category, etc.). |
|
||||||
|
| `/admin/tags` | KEEP | Color tags. |
|
||||||
|
| `/admin/custom-fields` | KEEP | Per-entity field definitions. |
|
||||||
|
| `/admin/brochures` | MOVE FROM ungrouped → here | Brochure assets are catalog artefacts (per-port versioned PDFs). |
|
||||||
|
|
||||||
|
### Domain 4. **Identity & access** (3 pages)
|
||||||
|
|
||||||
|
_Who can use the system and what they can do._
|
||||||
|
|
||||||
|
| Page | Action | Notes |
|
||||||
|
| -------------- | ------ | -------------------------------------------- |
|
||||||
|
| `/admin/users` | KEEP | Active users + invitations (already merged). |
|
||||||
|
| `/admin/roles` | KEEP | Role/permission matrix. |
|
||||||
|
| `/admin/ports` | KEEP | Super-admin only; per-port management. |
|
||||||
|
|
||||||
|
### Domain 5. **Inbox & data quality** (6 pages)
|
||||||
|
|
||||||
|
_Stuff that lands in admin queues + tools to clean up data._
|
||||||
|
|
||||||
|
| Page | Action | Notes |
|
||||||
|
| ------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------- |
|
||||||
|
| `/admin/inquiries` | KEEP | Public-site form submissions. |
|
||||||
|
| `/admin/sends` | KEEP | Brochure + per-berth-PDF send retries. |
|
||||||
|
| `/admin/duplicates` | KEEP | Suspected-duplicate review queue. |
|
||||||
|
| `/admin/import` | KEEP | CSV imports. |
|
||||||
|
| `/admin/berths` | NEW INDEX | Landing page that surfaces the two berth-admin tools below. |
|
||||||
|
| `/admin/berths/bulk-add` | MOVE FROM ungrouped → keep route, surface via /admin/berths | Bulk berth row generator. |
|
||||||
|
| `/admin/berths/reconcile` | MOVE FROM ungrouped → keep route, surface via /admin/berths | Berth-pdf reconciliation queue. |
|
||||||
|
|
||||||
|
(Counted as one Berths entry on the landing tile + the two existing routes as sub-pages.)
|
||||||
|
|
||||||
|
### Domain 6. **System & observability** (10 pages)
|
||||||
|
|
||||||
|
_Infra, observability, escape hatches._
|
||||||
|
|
||||||
|
| Page | Action | Notes |
|
||||||
|
| ------------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `/admin/audit` | KEEP | Mutation audit log. |
|
||||||
|
| `/admin/monitoring` | KEEP | BullMQ queue health. |
|
||||||
|
| `/admin/monitoring/[queueName]` | KEEP | Single-queue detail. |
|
||||||
|
| `/admin/errors` | SURFACE on landing | Error-event list (currently hidden from `/admin` tile grid). |
|
||||||
|
| `/admin/errors/codes` | KEEP as sub-page | Linked from `/admin/errors`. |
|
||||||
|
| `/admin/errors/[requestId]` | KEEP as sub-page | Linked from `/admin/errors`. |
|
||||||
|
| `/admin/backup` | KEEP | Backup posture. |
|
||||||
|
| `/admin/storage` | KEEP | Storage backend selector + migration. |
|
||||||
|
| `/admin/website-analytics` | KEEP | Umami creds. |
|
||||||
|
| `/admin/ai` | KEEP | AI config (master switch, providers, OCR settings, suggestions). |
|
||||||
|
| `/admin/settings` | KEEP | Generic KV editor (escape hatch for advanced flags). Stays in this domain because it's an admin-debug surface, not a normal-day setting. |
|
||||||
|
| `/admin/onboarding` | KEEP, FLOATS | Cross-cutting setup checklist. Stays accessible from `/admin` landing but doesn't belong in any single domain — it links to many. |
|
||||||
|
|
||||||
|
### Out of admin entirely
|
||||||
|
|
||||||
|
| Page | Action | Rationale |
|
||||||
|
| ---------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `/admin/reports` | MOVE OUT → `/[portSlug]/reports` | Reports are an end-user feature, not admin config. Today it lives in admin only because it's permission-gated; should be a top-level nav item with the same permission gate. Defer to a follow-up; for the IA pass, just stop surfacing it on `/admin`. |
|
||||||
|
|
||||||
|
### Deleted
|
||||||
|
|
||||||
|
| Page | Action | Rationale |
|
||||||
|
| -------------------- | ----------------------------- | -------------------------------------------- |
|
||||||
|
| `/admin/ocr` | DELETE + 301 → `/admin/ai` | Duplicate of `/admin/ai`. |
|
||||||
|
| `/admin/invitations` | DELETE + 301 → `/admin/users` | Empty page; functionality merged 2026-05-21. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Misplaced cards / sub-section moves
|
||||||
|
|
||||||
|
These are smaller-grained moves _within_ the new IA — cards that should change page even though the routes stay put.
|
||||||
|
|
||||||
|
1. **`<EmailPreviewCard>` (currently on `/admin/branding`)** → KEEP on Branding (visual brand check); add a "→ Test individual templates" link pointing at `/admin/email#test-template`.
|
||||||
|
2. **`<EmailRoutingCard>` (currently on `/admin/email`)** → KEEP on Email; cross-link from a "Routing rules" subsection of the new Workflow domain.
|
||||||
|
3. **`<TemplateSyncButton>` (currently on `/admin/documenso`)** → KEEP; consider surfacing duplicate on `/admin/templates` (since "Sync from Documenso" populates template IDs there).
|
||||||
|
4. **`<OnboardingChecklist>`** → consider exposing a slim version as a banner on `/admin` landing for ports that haven't completed setup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Proposed `/admin` landing tile groups
|
||||||
|
|
||||||
|
The `admin-sections-browser.tsx` array should be rebuilt to match the 6 domains above. Sketch:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const SECTIONS: AdminSection[] = [
|
||||||
|
{
|
||||||
|
title: 'Brand & Communication',
|
||||||
|
description: 'How outbound looks and which channels it ships on.',
|
||||||
|
items: ['branding', 'email', 'email-templates', 'documenso', 'webhooks'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Sales workflow',
|
||||||
|
description: 'Pipeline behaviour, scoring, document + form templates.',
|
||||||
|
items: [
|
||||||
|
'pipeline-rules',
|
||||||
|
'pulse',
|
||||||
|
'reminders',
|
||||||
|
'qualification-criteria',
|
||||||
|
'residential-stages',
|
||||||
|
'forms',
|
||||||
|
'templates',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Catalog',
|
||||||
|
description: 'Tenant-defined enums, tags, custom fields, and brochures.',
|
||||||
|
items: ['vocabularies', 'tags', 'custom-fields', 'brochures'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Identity & access',
|
||||||
|
description: 'Who can use the system and what they can do.',
|
||||||
|
items: ['users', 'roles', 'ports'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Inbox & data quality',
|
||||||
|
description: 'Admin queues + cleanup tools.',
|
||||||
|
items: ['inquiries', 'sends', 'duplicates', 'import', 'berths'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'System & observability',
|
||||||
|
description: 'Infra, observability, escape hatches.',
|
||||||
|
items: [
|
||||||
|
'audit',
|
||||||
|
'monitoring',
|
||||||
|
'errors',
|
||||||
|
'backup',
|
||||||
|
'storage',
|
||||||
|
'website-analytics',
|
||||||
|
'ai',
|
||||||
|
'settings',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
Onboarding checklist surfaces above the grid (or as a banner on incomplete ports), not as a tile.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Phase 2 execution plan (~4–6h)
|
||||||
|
|
||||||
|
Once the above IA is approved (or amended), the migration is mechanical:
|
||||||
|
|
||||||
|
1. **Update `admin-sections-browser.tsx`** to the 6-domain shape above. (~30 min)
|
||||||
|
2. **Delete `/admin/ocr`** + add `redirect()` to `/admin/ai`. (~10 min)
|
||||||
|
3. **Delete `/admin/invitations`** + add `redirect()` to `/admin/users`. (~10 min)
|
||||||
|
4. **Rename "Documenso & EOI"** → "Signing service" (page title + landing label). (~5 min)
|
||||||
|
5. **Create `/admin/berths/page.tsx`** index that surfaces bulk-add + reconcile. (~30 min)
|
||||||
|
6. **Move `/admin/reports` out of admin** — touches sidebar nav + landing browser + permission docs. Defer to its own task if scope creeps. (~1h)
|
||||||
|
7. **Cross-link cards** per section 4 (EmailPreviewCard → /admin/email link, etc.). (~30 min)
|
||||||
|
8. **Smoke pass** — click every tile, confirm every page loads, every redirect lands. (~30 min)
|
||||||
|
9. **Audit doc update** — mark B3 #10 SHIPPED in `alpha-uat-master.md`. (~10 min)
|
||||||
|
|
||||||
|
Total: ~4 h plus ~1 h for the Reports move if we include it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Open questions (resolved)
|
||||||
|
|
||||||
|
| # | Question | Decision |
|
||||||
|
| --- | ---------------------------------------- | ------------------------------------------------------------------------------- |
|
||||||
|
| 1 | Forms + Document Templates placement | Moved to **Sales workflow** (not Content) |
|
||||||
|
| 2 | Webhooks placement | **New "Integrations" domain** (webhooks + documenso + website-analytics + ai) |
|
||||||
|
| 3 | AI configuration placement | Keep dedicated `/admin/ai` panel; lives under **Integrations** |
|
||||||
|
| 4 | `/admin/reports` | **DELETE entirely** (duplicates dashboard); redirect to `/[portSlug]/dashboard` |
|
||||||
|
| 5 | `/admin/settings` (KV editor) visibility | Keep visible to all admins under **System & observability** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Final IA — 7 domains, 38 pages
|
||||||
|
|
||||||
|
After resolutions. Three pages deleted (`/admin/ocr`, `/admin/invitations`, `/admin/reports`); one new sub-area (`/admin/berths` index); one new domain (Integrations) split out from Brand & Communication.
|
||||||
|
|
||||||
|
### Domain 1. **Brand & Communication** (3 pages)
|
||||||
|
|
||||||
|
_How outbound LOOKS — visual and copy._
|
||||||
|
|
||||||
|
- `/admin/branding` — logo, colors, app name, email shell HTML, EmailPreviewCard (visual check)
|
||||||
|
- `/admin/email` — SMTP creds (noreply + sales), routing, per-template tester, SMTP probe
|
||||||
|
- `/admin/email-templates` — subject-line + copy overrides per transactional template
|
||||||
|
|
||||||
|
### Domain 2. **Sales workflow** (7 pages)
|
||||||
|
|
||||||
|
_How the pipeline BEHAVES — triggers, scoring, templates._
|
||||||
|
|
||||||
|
- `/admin/pipeline-rules` — berth-rules engine + auto-advance
|
||||||
|
- `/admin/pulse` — Deal Pulse chip tuning
|
||||||
|
- `/admin/reminders` — default behaviour + digest
|
||||||
|
- `/admin/qualification-criteria` — lead-scoring rubric
|
||||||
|
- `/admin/residential-stages` — residential pipeline shape
|
||||||
|
- `/admin/forms` — lead intake form templates (moved from Content)
|
||||||
|
- `/admin/templates` — document templates with merge fields (moved from Content)
|
||||||
|
|
||||||
|
### Domain 3. **Catalog** (4 pages)
|
||||||
|
|
||||||
|
_Tenant-defined data shapes that attach to records._
|
||||||
|
|
||||||
|
- `/admin/vocabularies` — admin-editable enum lists
|
||||||
|
- `/admin/tags` — color tags
|
||||||
|
- `/admin/custom-fields` — per-entity field definitions
|
||||||
|
- `/admin/brochures` — per-port versioned PDF assets
|
||||||
|
|
||||||
|
### Domain 4. **Identity & access** (3 pages)
|
||||||
|
|
||||||
|
- `/admin/users` — active users + invitations (merged)
|
||||||
|
- `/admin/roles` — role/permission matrix
|
||||||
|
- `/admin/ports` — super-admin only, per-port management
|
||||||
|
|
||||||
|
### Domain 5. **Inbox & data quality** (5 pages, 1 sub-index)
|
||||||
|
|
||||||
|
_Admin queues + cleanup tools._
|
||||||
|
|
||||||
|
- `/admin/inquiries` — public-site submissions
|
||||||
|
- `/admin/sends` — outbound send retry log
|
||||||
|
- `/admin/duplicates` — duplicate-client review queue
|
||||||
|
- `/admin/import` — CSV imports
|
||||||
|
- `/admin/berths` — **NEW** index page surfacing the two existing sub-tools:
|
||||||
|
- `/admin/berths/bulk-add` (bulk row generator)
|
||||||
|
- `/admin/berths/reconcile` (berth-pdf reconciliation queue)
|
||||||
|
|
||||||
|
### Domain 6. **Integrations** (4 pages) — NEW DOMAIN
|
||||||
|
|
||||||
|
_External-system + provider configuration._
|
||||||
|
|
||||||
|
- `/admin/documenso` — signing service (rename from "Documenso & EOI" → "Signing service")
|
||||||
|
- `/admin/webhooks` — outbound subscriptions + delivery log
|
||||||
|
- `/admin/website-analytics` — Umami creds
|
||||||
|
- `/admin/ai` — dedicated AI panel consolidating master switch + provider creds + OCR settings + AI-suggestions config
|
||||||
|
|
||||||
|
### Domain 7. **System & observability** (7 pages + 1 floating)
|
||||||
|
|
||||||
|
_Infra, observability, escape hatches._
|
||||||
|
|
||||||
|
- `/admin/audit` — mutation audit log
|
||||||
|
- `/admin/monitoring` — BullMQ queue health (+ `/admin/monitoring/[queueName]` sub-page)
|
||||||
|
- `/admin/errors` — error-event list (+ `/admin/errors/codes` + `/admin/errors/[requestId]`)
|
||||||
|
- `/admin/backup` — backup posture
|
||||||
|
- `/admin/storage` — storage backend selector + migration
|
||||||
|
- `/admin/settings` — generic KV editor (escape hatch)
|
||||||
|
- `/admin/onboarding` — cross-cutting setup checklist (floats above the grid for incomplete ports)
|
||||||
|
|
||||||
|
### Deleted
|
||||||
|
|
||||||
|
| Page | Action | Rationale |
|
||||||
|
| -------------------- | -------------------------------------- | ---------------------------------------------------- |
|
||||||
|
| `/admin/ocr` | DELETE + 301 → `/admin/ai` | Duplicate of `/admin/ai`'s OcrSettingsForm |
|
||||||
|
| `/admin/invitations` | DELETE + 301 → `/admin/users` | Empty page; merged into `/admin/users` on 2026-05-21 |
|
||||||
|
| `/admin/reports` | DELETE + 301 → `/[portSlug]/dashboard` | Three widgets all already on the dashboard |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Phase 2 execution plan (~4-5 h)
|
||||||
|
|
||||||
|
Updated to reflect the resolved decisions. Reports move-out becomes a delete (simpler).
|
||||||
|
|
||||||
|
1. **Update `admin-sections-browser.tsx`** to the 7-domain shape above. (~45 min — 7 groups, ~30 tiles)
|
||||||
|
2. **Delete `/admin/ocr`** + add `redirect()` to `/admin/ai`. (~10 min)
|
||||||
|
3. **Delete `/admin/invitations`** + add `redirect()` to `/admin/users`. (~10 min)
|
||||||
|
4. **Delete `/admin/reports`** + add `redirect()` to `/[portSlug]/dashboard`. (~10 min) + remove from sidebar nav + landing browser. (~15 min)
|
||||||
|
5. **Rename `/admin/documenso`** page title → "Signing service" (page title + landing tile label). (~5 min)
|
||||||
|
6. **Create `/admin/berths/page.tsx`** index page surfacing bulk-add + reconcile sub-tools. (~30 min)
|
||||||
|
7. **Cross-link `<EmailPreviewCard>`** on Branding to add a "→ Test individual templates" link pointing at `/admin/email#test-template`. (~10 min)
|
||||||
|
8. **Smoke pass** — click every tile on the new `/admin` landing, confirm every page loads, every redirect lands. (~30 min)
|
||||||
|
9. **Update `alpha-uat-master.md`** Bucket 3 #10 → SHIPPED with this proposal's commit hash. (~5 min)
|
||||||
|
|
||||||
|
Total: ~3.5-4 h.
|
||||||
@@ -146,6 +146,9 @@ _Copy tweaks, alignment, single-prop edits, obvious typos._
|
|||||||
- Service docstring updated to cite the verified v3 endpoint behaviour + the flat-shape rationale so the next reader doesn't repeat the v1-nested mistake.
|
- Service docstring updated to cite the verified v3 endpoint behaviour + the flat-shape rationale so the next reader doesn't repeat the v1-nested mistake.
|
||||||
- `tsc --noEmit` clean. Verified live: dashboard tile + website-analytics page both render 2,081 pageviews / 726 visitors / 872 visits / 457 bounces over 30d (the real numbers from analytics.portnimara.com). Fixed in this session.
|
- `tsc --noEmit` clean. Verified live: dashboard tile + website-analytics page both render 2,081 pageviews / 726 visitors / 872 visits / 457 bounces over 30d (the real numbers from analytics.portnimara.com). Fixed in this session.
|
||||||
16. **Revenue Breakdown widget removed end-to-end** — _src/components/dashboard/{revenue-breakdown-chart.tsx (deleted), widget-registry.tsx, use-analytics.ts}_, _src/app/api/v1/analytics/route.ts_, _src/lib/services/analytics.service.ts_, _tests/integration/analytics-service.test.ts_ — the "Revenue Breakdown" tile (bar chart of invoice totals by status × currency) wasn't aligned with how the org uses invoicing (no client-facing invoicing through the system — only employee expense-sheet PDFs for trip reimbursement) and was redundant once the Pipeline Value tile shipped with a weighted forecast + per-stage breakdown. Removed: widget file, dynamic import, registry entry, `useRevenue` hook, `RevenueBreakdownData` type, `MetricBase` union member, `ALL_METRICS` entry, `SnapshotData` union member, `getRevenueBreakdown` + `computeRevenueBreakdown` service functions, `refreshSnapshotsForPort` revenue branch, route dictionary entry, integration test. `RevenueReportPdf` (separate code path for the reports module) intentionally kept. `tsc --noEmit` clean. Fixed in this session.
|
16. **Revenue Breakdown widget removed end-to-end** — _src/components/dashboard/{revenue-breakdown-chart.tsx (deleted), widget-registry.tsx, use-analytics.ts}_, _src/app/api/v1/analytics/route.ts_, _src/lib/services/analytics.service.ts_, _tests/integration/analytics-service.test.ts_ — the "Revenue Breakdown" tile (bar chart of invoice totals by status × currency) wasn't aligned with how the org uses invoicing (no client-facing invoicing through the system — only employee expense-sheet PDFs for trip reimbursement) and was redundant once the Pipeline Value tile shipped with a weighted forecast + per-stage breakdown. Removed: widget file, dynamic import, registry entry, `useRevenue` hook, `RevenueBreakdownData` type, `MetricBase` union member, `ALL_METRICS` entry, `SnapshotData` union member, `getRevenueBreakdown` + `computeRevenueBreakdown` service functions, `refreshSnapshotsForPort` revenue branch, route dictionary entry, integration test. `RevenueReportPdf` (separate code path for the reports module) intentionally kept. `tsc --noEmit` clean. Fixed in this session.
|
||||||
|
17. **Finish CountryFlag rollout — table + filter surfaces** — _src/components/shared/country-flag.tsx (shipped this session)_ + _src/components/clients/client-columns.tsx:173_ (nationality column cell — currently renders bare ISO code; should prefix with `<CountryFlag>`) + _src/components/clients/client-filters.tsx_ (nationality filter pill — render flag next to selected country name) + _src/components/yachts/yacht-form.tsx_ (flag the yacht's country if surfaced anywhere outside the CountryCombobox transitive path) + audit any remaining `flagEmoji` or `0x1f1e6` codepoint references with `rg -n "0x1f1e6\|flagEmoji"` → expected 0 hits. Shipped this session: country-combobox + inline-country-field + addresses-editor (replaced existing emoji glyphs, which never rendered on Windows) and added flags to clients-by-country-widget / client-card / client-detail-header / website-analytics realtime + sessions + session-detail. Library: `country-flag-icons` (MIT, ~1-2 KB per flag, dynamically imported on first render, cached). Effort: ~30 min for the remaining surfaces. Captured 2026-05-22 from UAT.
|
||||||
|
- **SHIPPED in this session:** client-columns nationality cell now renders flag + name. client-filters is a free-text input (no rendered chip surface to flag). No yacht country rendering exists outside CountryCombobox. Final `rg "0x1f1e6\|flagEmoji"` returns 0 hits.
|
||||||
|
- **Follow-up fix in this session:** the original `CountryFlag` used a template-string dynamic import (`import('country-flag-icons/string/3x2/${code}')`), which silently fails in Next.js's webpack because the package's `exports` field gates each subpath. Symptom: every flag rendered as the muted placeholder box. Replaced with a single lazy `import('country-flag-icons/string/3x2')` that loads the whole index once (~1.6 MB raw / ~400 KB gzip, single chunk shared across the app), caches on a module-level promise, and lookups become synchronous after first render.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -521,6 +524,11 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._
|
|||||||
3. **Pipeline Value tile expanded with per-stage breakdown** — _src/components/dashboard/pipeline-value-tile.tsx_, _src/lib/services/dashboard.service.ts_ — replaced the single-number KPI with a richer card: gross headline + weighted forecast on top, per-stage rows below (label · mini bar · gross value · count + close-probability), and a footnote when default stage weights are in use. Service `getRevenueForecast` extended to return `grossValue`, `weight`, `totalGrossValue`, and `dealsMissingPrice` alongside the existing weighted shape; the tile pulls from `/kpis` (for gross + currency + activeInterests) and `/forecast` (for breakdown). Per-stage warning chip surfaces when berths are missing a `price` so a silently undercounted gross is visible (full coverage → "berth price missing", partial → "N of M missing price"). Leadership can now see how much of the headline is near-close vs speculative. Fixed in this session.
|
3. **Pipeline Value tile expanded with per-stage breakdown** — _src/components/dashboard/pipeline-value-tile.tsx_, _src/lib/services/dashboard.service.ts_ — replaced the single-number KPI with a richer card: gross headline + weighted forecast on top, per-stage rows below (label · mini bar · gross value · count + close-probability), and a footnote when default stage weights are in use. Service `getRevenueForecast` extended to return `grossValue`, `weight`, `totalGrossValue`, and `dealsMissingPrice` alongside the existing weighted shape; the tile pulls from `/kpis` (for gross + currency + activeInterests) and `/forecast` (for breakdown). Per-stage warning chip surfaces when berths are missing a `price` so a silently undercounted gross is visible (full coverage → "berth price missing", partial → "N of M missing price"). Leadership can now see how much of the headline is near-close vs speculative. Fixed in this session.
|
||||||
4. **"How weighted forecast works" info popover on the Pipeline Value tile** — _src/components/dashboard/pipeline-value-tile.tsx_ — added an `Info` icon next to the description that opens a `Popover` (click or hover) explaining the close-probability model + showing the per-stage weight table (live from `/forecast`, fallback to `STAGE_WEIGHTS` constant) + a note about whether default or per-port weights are in use. Fixed in this session.
|
4. **"How weighted forecast works" info popover on the Pipeline Value tile** — _src/components/dashboard/pipeline-value-tile.tsx_ — added an `Info` icon next to the description that opens a `Popover` (click or hover) explaining the close-probability model + showing the per-stage weight table (live from `/forecast`, fallback to `STAGE_WEIGHTS` constant) + a note about whether default or per-port weights are in use. Fixed in this session.
|
||||||
5. **Bulk + inline berth price editing — backend complete** — _src/lib/db/schema/users.ts_, _src/lib/db/seed-permissions.ts_, _src/components/admin/roles/role-form.tsx_, _src/components/admin/users/user-permission-matrix.tsx_, _src/app/api/v1/admin/users/[id]/permission-overrides/route.ts_, _src/lib/validators/berths.ts_, _src/lib/services/berths.service.ts_, _src/app/api/v1/berths/[id]/price/route.ts_, _src/app/api/v1/berths/bulk-update-prices/route.ts_, _tests/helpers/factories.ts_ — new `berths.update_prices` permission carved out from generic `berths.edit` so sales reps can update prices without exposing the full edit surface. Permission seeded on for super_admin/director/sales_manager/sales_agent, off for viewer/residential_partner. New validators (`updateBerthPriceSchema`, `bulkUpdateBerthPricesSchema` capped at 500/batch), services (`updateBerthPrice`, `bulkUpdateBerthPrices`, both transactional + per-row audited with `fieldChanged='price'` + realtime `berth:updated` + webhook fan-out), and routes (`PATCH /api/v1/berths/[id]/price`, `POST /api/v1/berths/bulk-update-prices`). UI shipping in a follow-up — see Features bucket #1. Fixed in this session.
|
5. **Bulk + inline berth price editing — backend complete** — _src/lib/db/schema/users.ts_, _src/lib/db/seed-permissions.ts_, _src/components/admin/roles/role-form.tsx_, _src/components/admin/users/user-permission-matrix.tsx_, _src/app/api/v1/admin/users/[id]/permission-overrides/route.ts_, _src/lib/validators/berths.ts_, _src/lib/services/berths.service.ts_, _src/app/api/v1/berths/[id]/price/route.ts_, _src/app/api/v1/berths/bulk-update-prices/route.ts_, _tests/helpers/factories.ts_ — new `berths.update_prices` permission carved out from generic `berths.edit` so sales reps can update prices without exposing the full edit surface. Permission seeded on for super_admin/director/sales_manager/sales_agent, off for viewer/residential_partner. New validators (`updateBerthPriceSchema`, `bulkUpdateBerthPricesSchema` capped at 500/batch), services (`updateBerthPrice`, `bulkUpdateBerthPrices`, both transactional + per-row audited with `fieldChanged='price'` + realtime `berth:updated` + webhook fan-out), and routes (`PATCH /api/v1/berths/[id]/price`, `POST /api/v1/berths/bulk-update-prices`). UI shipping in a follow-up — see Features bucket #1. Fixed in this session.
|
||||||
|
6. **Cancel-document: choose delete-from-Documenso vs keep-for-audit** — _src/lib/services/documents.service.ts (cancelDocument)_ + _src/lib/services/documenso-client.ts (voidDocument)_ + every cancel-document UI surface (interest reservation tab, contract tab, EOI cancel dialog, send-document dialog admin actions, etc.) — today cancel always fires `DELETE /api/v2/envelope/{id}` (or v1 equivalent), which unclogs the Documenso instance but loses the upstream audit trail. UX ask: present the rep with an explicit choice on cancel: (a) **Delete upstream** (current behaviour — frees the Documenso slot, history rendered from CRM `documents` row only) or (b) **Keep for audit** (local row → `status='cancelled'`, no DELETE call; rep can later reopen on Documenso for forensics). Default to (a). Plumb a `cancelMode: 'delete' | 'keep_remote'` param through `cancelDocument` + the route handler; only call `documensoVoid` when mode === 'delete'. ~1-1.5h: service param + UI radio in the existing confirm-cancel dialog + audit-doc-status reflection in the cancelled-doc badge ("Cancelled, kept on Documenso" when keep_remote). Captured 2026-05-22.
|
||||||
|
7. **Document signature reminders: drop rate-limit when automatic** — _src/components/interests/interest-reservation-tab.tsx:740_ ("Reminders are rate-limited (max once per 7 days per signer)") + the underlying remind-signer service. Today both manual and scheduled-auto reminders share the same 7-days-per-signer throttle. The cap is right for manual clicks (avoids harassment) but breaks the auto-cadence cron: if the rules engine wants to nudge a stale signer every 3 days, it gets swallowed. Plumb a `triggeredBy: 'manual' | 'auto'` flag from the caller and skip the rate-limit when `auto` (the cron's own cadence is the throttle). Manual UI keeps the 7-day cap. ~30-45 min: service param + cron caller + UI copy update ("Reminders are rate-limited for manual sends — automatic follow-ups run on the configured cadence"). Captured 2026-05-22.
|
||||||
|
8. **EOI tab: add upload-draft-then-place-fields option (parity with Contract / Reservation)** — _src/components/interests/interest-eoi-tab.tsx_ (no `UploadForSigningDialog` mount yet) + _src/app/api/v1/interests/[id]/upload-for-signing/route.ts_ (`documentTypeSchema` is locked to `'contract' | 'reservation_agreement'`) + _src/lib/services/custom-document-upload.service.ts_ (`CustomDocumentType` union, `targetStage` switch, `dateContractSent` / `reservationDocStatus` branch). EOI currently has two paths — template-generated (`EoiGenerateDialog`, `/template/{id}/generate-document`) and external paper-signed upload (`ExternalEoiUploadDialog`, `markExternallySigned`) — but no upload-draft-then-drag-fields flow like Contract/Reservation. Reps with a bespoke EOI PDF have to either generate from template (loses custom layout) or mark-as-external (no signing). Fix shape: extend the union to `'eoi'`, add an EOI branch to the stage-advance + doc-status switch (`pipelineStage='eoi_sent'`, `eoiDocStatus='sent'`, `dateEoiSent`), wire `<UploadForSigningDialog documentType="eoi">` into the EOI tab next to the existing "Generate EOI" CTA. Effort: ~2-3h including the route validator bump, service branch, UI mount, and a smoke playwright run. Captured 2026-05-22.
|
||||||
|
9. **Surface per-signer copyable signing URLs on every Documenso-driven doc** — _src/components/interests/interest-eoi-tab.tsx_, _src/components/interests/interest-reservation-tab.tsx_, _src/components/interests/interest-contract-tab.tsx_, _src/components/documents/signing-details-dialog.tsx_, _src/components/shared/send-document-dialog.tsx_ — once a document has been created in Documenso, each signer's `signingUrl` is already stored on the `document_signers` row (returned by `/api/v1/documents/{id}/signers`). Today the rep sees Pending / Invited badges but no way to grab a specific signer's signing URL for QA or manual delivery. Add a "Copy signing link" button next to each signer row across every signing-doc tab (EOI / Reservation / Contract / SigningDetails / SendDocument admin panel). Behaviour: button is disabled when `signingUrl` is null (Documenso hadn't returned a URL yet — e.g. send-mode failure); on click, copy to clipboard via `navigator.clipboard.writeText`, toast "Signing link copied" + the truncated URL. Useful for: smoke-testing the signing flow without spamming the rep's inbox, manually pasting a link into a custom email or Slack DM when the auto-send mode failed, and for sales reps who want to QA the look of the page before the customer touches it. ~30-45 min, all UI surface work — backend already exposes the data. Captured 2026-05-22.
|
||||||
|
10. **Per-template "Send a test" tester for every transactional email the system emits** — _src/components/admin/branding/email-preview-card.tsx_ (current sample-only tester), _src/lib/email/templates/_ (all template files), _src/app/api/v1/admin/branding/email-preview/route.ts_ (current endpoint), new endpoint `/api/v1/admin/email/test-template`. Today the admin can send ONE generic branded shell from Branding, plus an SMTP-connectivity ping from Email — but no way to fire a specific template (password reset, EOI invitation, signing reminder, GDPR export ready, portal activation, reminder digest, bounce-back notice, …) to a designated address. Add a per-template tester card: dropdown of every registered template (read from a central template registry exposing `id, label, sampleProps`), recipient email input, "Send test" button. Backend route renders the selected template with realistic sample props (port branding, fake but plausible client/yacht/EOI), pipes through the same sender helper as the real flow, returns delivery status. Goes on the Email admin page next to the existing SMTP test card. Effort: ~2-3h (registry + endpoint + card + sample-prop fixtures for each template). Captured 2026-05-22.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -676,6 +684,12 @@ _New UI surfaces, new endpoints, schema migrations, multi-step flows._
|
|||||||
- **(a) Pre-flight config-shape errors at known integration boundaries** — _src/lib/services/documenso-client.ts_, _src/lib/services/storage/\*_, _src/lib/email/_, _src/lib/services/imap-bounce-poller.ts_, IMAP, SMS providers, payment gateways, etc. — when a call would fail because admin/env config is empty or unparseable, raise a typed `CodedError` _before_ the network call with an operator-facing message like `"Documenso is not configured for {portName}. Open Admin → Documenso settings to enter the API key, or set DOCUMENSO_API_KEY in env."` Include the offending setting key + port name. The `documenso-client` `resolveCreds()` is the canonical example to template from — others (IMAP, S3, SMTP, Stripe etc.) should follow the same pattern.
|
- **(a) Pre-flight config-shape errors at known integration boundaries** — _src/lib/services/documenso-client.ts_, _src/lib/services/storage/\*_, _src/lib/email/_, _src/lib/services/imap-bounce-poller.ts_, IMAP, SMS providers, payment gateways, etc. — when a call would fail because admin/env config is empty or unparseable, raise a typed `CodedError` _before_ the network call with an operator-facing message like `"Documenso is not configured for {portName}. Open Admin → Documenso settings to enter the API key, or set DOCUMENSO_API_KEY in env."` Include the offending setting key + port name. The `documenso-client` `resolveCreds()` is the canonical example to template from — others (IMAP, S3, SMTP, Stripe etc.) should follow the same pattern.
|
||||||
- **(b) User-facing error-message audit** — _src/lib/errors.ts_, all `try/catch` blocks in `src/app/api/*`, all `toastError` consumers in `src/components/*` — scan for `errorResponse(err)` paths that return generic "Something went wrong" / status codes only, and enrich with: (i) the operation that failed ("EOI generation", "Send invoice", "Upload file"), (ii) the likely cause (config missing, permission denied, conflict, etc.), (iii) the next step (where to fix it). Especially important for setting-driven features (email send accounts, storage backends, Documenso config, webhook secrets) where the real cause is one config field off-screen. The error catalog in `src/lib/errors.ts` already supports `CodedError` with operator-friendly `userMessage` — most call sites just need to populate it.
|
- **(b) User-facing error-message audit** — _src/lib/errors.ts_, all `try/catch` blocks in `src/app/api/*`, all `toastError` consumers in `src/components/*` — scan for `errorResponse(err)` paths that return generic "Something went wrong" / status codes only, and enrich with: (i) the operation that failed ("EOI generation", "Send invoice", "Upload file"), (ii) the likely cause (config missing, permission denied, conflict, etc.), (iii) the next step (where to fix it). Especially important for setting-driven features (email send accounts, storage backends, Documenso config, webhook secrets) where the real cause is one config field off-screen. The error catalog in `src/lib/errors.ts` already supports `CodedError` with operator-friendly `userMessage` — most call sites just need to populate it.
|
||||||
- Total scope: probably a 1-2 day audit + remediation pass. Out-of-scope items to consider during the pass: a per-port "Integrations health" admin page that probes each external integration and shows green/red with the same diagnostic copy.
|
- Total scope: probably a 1-2 day audit + remediation pass. Out-of-scope items to consider during the pass: a per-port "Integrations health" admin page that probes each external integration and shows green/red with the same diagnostic copy.
|
||||||
|
9. **Universal "upload file → optionally place signing fields"** — _src/components/documents/upload-for-signing-dialog.tsx_ (the existing place-fields step) + _src/components/documents/new-document-menu.tsx_ (Documents Hub upload), _src/components/documents/documents-hub.tsx_ (root + folder upload), _src/components/files/file-upload-zone.tsx_ (the shared dropzone), _src/components/clients/_ + _yachts/_ + _companies/_ document-tab upload surfaces — every modal where a PDF can land should expose an optional "Send for signature?" toggle that swaps the regular file-upload for the field-placement wizard. Avoids the re-upload friction the user currently hits when an arbitrary doc needs signatures. Shape: extract a `<DocumensoFieldPlacementStep>` from `UploadForSigningDialog`, mount it conditionally after the dropzone in every upload modal, and route through `/upload-for-signing` when fields are placed (skip it when only a plain file is uploaded). Backend: extend `CustomDocumentType` to accept `'generic'` (no pipeline-stage advance, no doc-status flip — just files + documents row in `sent` status). Effort: ~8-12h. Captured 2026-05-22.
|
||||||
|
10. **Comprehensive admin-settings IA audit + regroup** — _src/app/(dashboard)/[portSlug]/admin/_ — 41 admin pages today, organically grown: `ai`, `audit`, `backup`, `berths/bulk-add`, `berths/reconcile`, `branding`, `brochures`, `custom-fields`, `documenso`, `duplicates`, `email-templates`, `email`, `errors`, `forms`, `import`, `inquiries`, `invitations`, `monitoring`, `ocr`, `onboarding`, `pipeline-rules`, `ports`, `pulse`, `qualification-criteria`, `reminders`, `reports`, `residential-stages`, `roles`, `sends`, `settings`, `storage`, `tags`, `templates`, `users`, `vocabularies`, `webhooks`, `website-analytics`. Settings are scattered — e.g. test-email lives on Branding, SMTP test on Email, password-reset copy probably in `email-templates`, but the rep has to guess. Audit each page for: (a) what settings live there now, (b) which settings logically belong elsewhere ("right home" test — Documenso send mode currently lives on Documenso, makes sense; per-port email signature would make more sense under Branding than Email), (c) duplicates (vocabularies vs custom-fields vs qualification-criteria overlap on enum tuning). Then propose a regrouped IA — likely fewer top-level pages with clear domain headers (Configuration → Branding, Email, Documenso, Storage, Webhooks; Workflows → Pipeline rules, Reminders, Auto-stage advancement; Catalog → Vocabularies, Tags, Custom fields, Qualification criteria; Operations → Monitoring, Pulse, Audit log, Errors, Backup; Data → Import, Duplicates, Bulk berth tools; Identity → Users, Roles, Invitations, Onboarding). Pair with a new admin index page that groups by domain instead of a flat alphabetical list. Effort: ~1.5-2 days — audit pass + IA proposal review + actual file moves + nav updates + redirect shims for old URLs. Captured 2026-05-22.
|
||||||
|
- **SHIPPED in this session (Phase 1 + Phase 2):** Full audit + proposal at `docs/admin-ia-proposal.md`. Final IA = 7 domains, 38 pages (down from 41 via three deletes). `admin-sections-browser.tsx` rewritten to the new domain shape (Brand & Communication, Sales workflow, Catalog, Identity & access, Inbox & data quality, Integrations, System & observability). Deleted with redirects: `/admin/ocr` → `/admin/ai`, `/admin/reports` → `/[portSlug]/dashboard`, `/admin/invitations` → `/admin/users` (this last one was already a redirect). Renamed: "Documenso & EOI" → "Signing service (Documenso)". New: `/admin/berths` index page surfacing bulk-add + reconcile sub-tools (which were previously discoverable only via deep links). `<EmailPreviewCard>` on Branding cross-links to `/admin/email` per-template tester. Search-nav-catalog updated (ocr entry removed, berths entry added). tsc clean.
|
||||||
|
11. **B3 #9 follow-up — UI wiring for universal upload-with-fields** — _src/components/documents/upload-for-signing-dialog.tsx_ (`<FieldPlacementStep>` lives inside this monolith — needs extraction into a standalone component the other upload modals can mount conditionally), _src/components/documents/new-document-menu.tsx_ + _src/components/documents/documents-hub.tsx_ + _src/components/files/file-upload-zone.tsx_ + entity-tab upload sites (client/yacht/company doc tabs). **Backend foundations SHIPPED 2026-05-22**: `CustomDocumentType` union now includes `'generic'`; `uploadDocumentForSigning` skips pipeline-stage advance + doc-status flip when generic; route validator accepts the new value; storage path category routes to `signed-source/`. **UI half deferred** to a paired session — needs careful surgery to each upload modal to add the "Send for signature?" toggle + mount the extracted field-placement step. Effort for UI wiring: ~5-7h. Captured 2026-05-22.
|
||||||
|
12. **Time-period PDF report + chart rendering + deeper data** — _src/lib/pdf/reports/dashboard-report.tsx_, _src/lib/services/dashboard-report-data.service.ts_, _src/lib/pdf/reports/types.ts_, new _src/lib/pdf/reports/charts.tsx_, _src/components/reports/export-dashboard-pdf-button.tsx_ (date-range picker). Today's PDF report ignores dateFrom/dateTo for most sections and renders every chart-style widget as a table. User wants: (a) **time-range filter** that scopes EVERY section to a chosen window — new clients in the window, new interests in the window, active interests touching the window, in-progress berths (sold/under-offer transitions in the window), pipeline counts at the start vs end of window, etc.; (b) **chart rendering** — react-pdf supports SVG, so build small SVG generators (`<PipelineFunnel data>`, `<OccupancyTimeline data>`, `<SourceMixDonut data>`) inline OR pre-render via vega-lite/d3-node to PNG and embed; (c) **deeper data per section** — add berths-in-flight (status changes within window), client+interest cohort tables, contact-cadence histogram, document-signing throughput. Shape: extend `DashboardReportData` with `window: {from, to}` and new sub-sections; extend the export-PDF dialog to take a date-range; route handler propagates the window to every per-section resolver. Effort: ~8-12h depending on chart-rendering approach (inline SVG is ~6h, vega-lite pre-render is ~10h with a worker round-trip). Captured 2026-05-22.
|
||||||
|
- **SHIPPED in this session:** Catalog expanded from 5 ids to 25 — chart variants (pipeline funnel bar, berth status donut, source conversion bar, lead source donut, occupancy timeline line) + period cohorts (new clients/interests, berths sold, deposits received, documents/contracts signed) + value views (pipeline value breakdown, revenue forecast, avg sales cycle, berth demand, country distribution, deal pulse distribution, recent activity). Hand-rolled SVG chart primitives in `src/lib/pdf/reports/charts.tsx` (HorizontalBarChart, DonutChart, LineChart) using @react-pdf/renderer's native Svg/Path/Rect support. Export-dialog grew a date-range picker with Last-30/90-days quick presets, defaults to last 30 days. Route + service plumbing carries dateFrom/dateTo. 11 of 16 pending resolvers landed (new_clients_period, new_interests_period, berths_sold_period via audit log, deposits_received_period, signed_documents_period, contracts_signed_period, berth_demand_ranking, lead_source_donut, client_country_distribution, recent_activity, pipeline_value_breakdown, revenue_forecast, avg_sales_cycle). Still pending (in this session's PENDING_RESOLVER_IDS set): stage_conversion_rates, occupancy_timeline_chart (needs daily buckets), inquiry_inbox_summary, reminders_summary, deal_pulse_distribution (requires the pulse service's dynamic computation, not a simple column query — left as follow-up). Also shipped: PDF logo absolutize for server-side fetch (was empty because @react-pdf/renderer can't fetch path-only URLs server-side), "Dashboard report" → "Report" default name, section-orphan fix (`wrap={false}` + `minPresenceAhead`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -825,6 +839,13 @@ _Functional defects. Tag each with `[critical|high|medium|low]` prefix._
|
|||||||
- **Effort:** ~10 min for the move + verify (no code change, just file relocation + manual click-through). Captured 2026-05-21 from UAT.
|
- **Effort:** ~10 min for the move + verify (no code change, just file relocation + manual click-through). Captured 2026-05-21 from UAT.
|
||||||
- **SHIPPED in 2d57417:** route relocated via `git mv` to `src/app/public/supplemental-info/[token]/page.tsx`. URL `/public/supplemental-info/<token>` unchanged (route groups don't affect URLs). Sweep of `src/app/(portal)/` confirmed no other public token routes were similarly nested.
|
- **SHIPPED in 2d57417:** route relocated via `git mv` to `src/app/public/supplemental-info/[token]/page.tsx`. URL `/public/supplemental-info/<token>` unchanged (route groups don't affect URLs). Sweep of `src/app/(portal)/` confirmed no other public token routes were similarly nested.
|
||||||
12. **[high] Command-search quick-create buttons routed to dead `/new` pages** — _src/components/search/command-search.tsx_ — ZeroState "New client/yacht/company" buttons pushed `/<entity>/new?name=…` which matched the `[id]` dynamic segment and rendered the entity-not-found page. Fixed by switching to `/<entity>?create=1&prefill_name=…` (the existing `useCreateFromUrl` convention) + adding `prefill` prop support to `YachtForm` + `CompanyForm` and wiring `prefill_name` reads in their list components. Now correctly pops the create sheet pre-filled. Fixed in this session.
|
12. **[high] Command-search quick-create buttons routed to dead `/new` pages** — _src/components/search/command-search.tsx_ — ZeroState "New client/yacht/company" buttons pushed `/<entity>/new?name=…` which matched the `[id]` dynamic segment and rendered the entity-not-found page. Fixed by switching to `/<entity>?create=1&prefill_name=…` (the existing `useCreateFromUrl` convention) + adding `prefill` prop support to `YachtForm` + `CompanyForm` and wiring `prefill_name` reads in their list components. Now correctly pops the create sheet pre-filled. Fixed in this session.
|
||||||
|
13. **[high] Dashboard widget cross-group reorder silently ignored by the Customize modal** — _src/components/dashboard/customize-widgets-menu.tsx:113-136_ vs _src/components/dashboard/dashboard-shell.tsx:88-90_ — the Customize modal exposes a single flat `SortableContext` over ALL visible widgets, so a rep can drag (e.g.) "My Reminders" (rail) above "Pipeline Funnel" (chart). The new order persists correctly (`setOrder(...)` → `dashboardWidgetOrder` PATCH → optimistic cache update), and `visibleWidgets` recomputes sorted by rank. BUT the shell then re-buckets `visibleWidgets.filter(w => w.group === 'chart' | 'rail' | 'feed')` into three independent slots before rendering — so any cross-group reorder leaves the dashboard visually unchanged. Intra-group reorders DO work (within charts column, within rails aside, within feed). User-perceived bug: "rearranging apps in the customize modal still does not change the order of them."
|
||||||
|
- **Decision needed** before fixing — two viable directions:
|
||||||
|
- **(a) Flatten the dashboard layout** to a single ordered grid (drop the chart/rail/feed bucketing). Honour the rep's exact order across the whole page. Implementation: replace the three-block layout in DashboardShell with one auto-fit grid + per-widget span hints on the registry (`{ colSpan: 1 | 2 | 'full' }`); rails would naturally widen to their hinted column count, feed becomes a `col-span-full` row. Bigger UI surgery, but most honest semantics.
|
||||||
|
- **(b) Scope the Customize modal sortable to per-group sub-lists.** Render three SortableContexts ("Charts", "Rails", "Feed") inside the modal, each with its own drag handles. Cross-group moves disallowed (or shown as a toggle to move a widget between groups). Smaller code change but loses the flexibility the current UI implies.
|
||||||
|
- **Recommended:** (b) for the short-term fix (matches the actual rendering reality), with (a) parked as a v2 follow-up after we see whether reps actually want the flat layout.
|
||||||
|
- **Effort:** ~30-45 min for (b); ~3-4 h for (a) including registry schema bump + responsive layout audit. Captured 2026-05-22 from UAT.
|
||||||
|
- **SHIPPED in this session:** combined approach. At xl viewports the Customize modal renders three region-scoped sortables (Charts / Side rail / Activity) — matches the actual side-by-side dashboard layout. Below xl where the dashboard stacks all three regions into one visual column, the modal renders a single flat sortable so the rep can drag across regions freely. Plus per-viewport saved orders: `userPreferences.dashboardWidgetOrder` (xl/desktop) + new `dashboardWidgetOrderMobile` (stacked), so reps can customize each layout independently. The hook auto-picks the right field based on viewport.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"country-flag-icons": "^1.6.17",
|
||||||
"cron-parser": "^5.5.0",
|
"cron-parser": "^5.5.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
|
|||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -145,6 +145,9 @@ importers:
|
|||||||
cmdk:
|
cmdk:
|
||||||
specifier: ^1.1.1
|
specifier: ^1.1.1
|
||||||
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
|
country-flag-icons:
|
||||||
|
specifier: ^1.6.17
|
||||||
|
version: 1.6.17
|
||||||
cron-parser:
|
cron-parser:
|
||||||
specifier: ^5.5.0
|
specifier: ^5.5.0
|
||||||
version: 5.5.0
|
version: 5.5.0
|
||||||
@@ -4155,6 +4158,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==}
|
resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
country-flag-icons@1.6.17:
|
||||||
|
resolution: {integrity: sha512-Nmik0289ZVZSI3c7mJR/amg6DyY7Z59b0sTFSKayeX72mHfPzCPJygwJs2pYgQULzuAyWeCUgwAJ+Dq8OR+JFw==}
|
||||||
|
|
||||||
crc-32@1.2.2:
|
crc-32@1.2.2:
|
||||||
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
|
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
|
||||||
engines: {node: '>=0.8'}
|
engines: {node: '>=0.8'}
|
||||||
@@ -11049,6 +11055,8 @@ snapshots:
|
|||||||
path-type: 4.0.0
|
path-type: 4.0.0
|
||||||
yaml: 1.10.3
|
yaml: 1.10.3
|
||||||
|
|
||||||
|
country-flag-icons@1.6.17: {}
|
||||||
|
|
||||||
crc-32@1.2.2: {}
|
crc-32@1.2.2: {}
|
||||||
|
|
||||||
crc32-stream@6.0.0:
|
crc32-stream@6.0.0:
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { useAuthBranding } from '@/components/shared/auth-branding-provider';
|
|||||||
// `identifier` accepts either an email address or a username (3–30 lowercase
|
// `identifier` accepts either an email address or a username (3–30 lowercase
|
||||||
// letters / digits / dot / underscore / hyphen). The server endpoint
|
// letters / digits / dot / underscore / hyphen). The server endpoint
|
||||||
// /api/auth/sign-in-by-identifier resolves the username server-side and
|
// /api/auth/sign-in-by-identifier resolves the username server-side and
|
||||||
// forwards to better-auth in one round-trip — the canonical email is never
|
// forwards to better-auth in one round-trip - the canonical email is never
|
||||||
// returned to the browser, which closes the username-enumeration vector.
|
// returned to the browser, which closes the username-enumeration vector.
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
identifier: z.string().min(1, 'Email or username is required'),
|
identifier: z.string().min(1, 'Email or username is required'),
|
||||||
@@ -61,7 +61,7 @@ export default function LoginPage() {
|
|||||||
if (payload.data?.needsBootstrap) router.replace('/setup');
|
if (payload.data?.needsBootstrap) router.replace('/setup');
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
/* silent — login UX must still work even if status check fails */
|
/* silent - login UX must still work even if status check fails */
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export default function ResetPasswordPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Treat 400 "user not found" as success so we don't leak whether the
|
// Treat 400 "user not found" as success so we don't leak whether the
|
||||||
// account exists — the success copy says "if an account exists…".
|
// account exists - the success copy says "if an account exists…".
|
||||||
// Anything else (5xx, network) surfaces as a real error.
|
// Anything else (5xx, network) surfaces as a real error.
|
||||||
if (!response.ok && response.status !== 400) {
|
if (!response.ok && response.status !== 400) {
|
||||||
toast.error('Something went wrong. Please try again.');
|
toast.error('Something went wrong. Please try again.');
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ type SetPasswordFormData = z.infer<typeof passwordSchema>;
|
|||||||
* H-03: tokens travel in the URL fragment (`#token=…`) so they never land
|
* H-03: tokens travel in the URL fragment (`#token=…`) so they never land
|
||||||
* in HTTP access logs or HTTP-Referer headers. Pre-fragment links still
|
* in HTTP access logs or HTTP-Referer headers. Pre-fragment links still
|
||||||
* carry `?token=…` and stay functional until every outstanding invite
|
* carry `?token=…` and stay functional until every outstanding invite
|
||||||
* expires — drop the `?token=` fallback after that grace period.
|
* expires - drop the `?token=` fallback after that grace period.
|
||||||
*/
|
*/
|
||||||
function readTokenFromUrl(): string {
|
function readTokenFromUrl(): string {
|
||||||
if (typeof window === 'undefined') return '';
|
if (typeof window === 'undefined') return '';
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ interface StatusResp {
|
|||||||
/**
|
/**
|
||||||
* First-run setup. On a fresh DB the very first visitor can claim the
|
* First-run setup. On a fresh DB the very first visitor can claim the
|
||||||
* super-admin account here. Once anyone claims it, future visits to
|
* super-admin account here. Once anyone claims it, future visits to
|
||||||
* /setup redirect back to /login — the precondition is verified both
|
* /setup redirect back to /login - the precondition is verified both
|
||||||
* server-side (`/api/v1/bootstrap/status` + `/api/v1/bootstrap/super-admin`'s
|
* server-side (`/api/v1/bootstrap/status` + `/api/v1/bootstrap/super-admin`'s
|
||||||
* internal recheck) and client-side here.
|
* internal recheck) and client-side here.
|
||||||
*/
|
*/
|
||||||
@@ -58,13 +58,13 @@ export default function SetupPage() {
|
|||||||
const res = await apiFetch<StatusResp>('/api/v1/bootstrap/status');
|
const res = await apiFetch<StatusResp>('/api/v1/bootstrap/status');
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (!res.data.needsBootstrap) {
|
if (!res.data.needsBootstrap) {
|
||||||
// Already initialized — bounce to login. Replace, not push,
|
// Already initialized - bounce to login. Replace, not push,
|
||||||
// so back-button doesn't trap the user here.
|
// so back-button doesn't trap the user here.
|
||||||
router.replace('/login');
|
router.replace('/login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Status endpoint failed — let the user try anyway; the POST
|
// Status endpoint failed - let the user try anyway; the POST
|
||||||
// does its own check and will surface a 409 if the window closed.
|
// does its own check and will surface a 409 if the window closed.
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setChecking(false);
|
if (!cancelled) setChecking(false);
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export default function AiAdminPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
Berth-PDF parser AI fallback — currently configured via the
|
Berth-PDF parser AI fallback - currently configured via the
|
||||||
BERTH_PDF_PARSER_* env vars. No per-port override surface today;
|
BERTH_PDF_PARSER_* env vars. No per-port override surface today;
|
||||||
when one is added, it lands here so admins don't have to hunt.
|
when one is added, it lands here so admins don't have to hunt.
|
||||||
*/}
|
*/}
|
||||||
@@ -63,10 +63,10 @@ export default function AiAdminPage() {
|
|||||||
{/*
|
{/*
|
||||||
Future AI surfaces. Each gets a section here once it ships:
|
Future AI surfaces. Each gets a section here once it ships:
|
||||||
- Recommender embeddings (currently rule-based, not LLM-based)
|
- Recommender embeddings (currently rule-based, not LLM-based)
|
||||||
- Contact-log action extraction (deferred — needs user demand)
|
- Contact-log action extraction (deferred - needs user demand)
|
||||||
- Inquiry-form auto-classification (deferred)
|
- Inquiry-form auto-classification (deferred)
|
||||||
Listing them inert here closes the "where do I configure AI?"
|
Listing them inert here closes the "where do I configure AI?"
|
||||||
loop — admins land on /admin/ai and see the full landscape.
|
loop - admins land on /admin/ai and see the full landscape.
|
||||||
*/}
|
*/}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
88
src/app/(dashboard)/[portSlug]/admin/berths/page.tsx
Normal file
88
src/app/(dashboard)/[portSlug]/admin/berths/page.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import type { Route } from 'next';
|
||||||
|
import { AlertCircle, Anchor, FileSearch } from 'lucide-react';
|
||||||
|
|
||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Berths admin index. Both sub-pages (`bulk-add`, `reconcile`) existed
|
||||||
|
* pre-2026-05-22 but were only reachable via deep links from inside the
|
||||||
|
* Berths list. Surfacing them on a dedicated admin landing tile so the
|
||||||
|
* tools are discoverable without prior knowledge of the URL - part of
|
||||||
|
* the admin IA regroup (B3 #10 Phase 2).
|
||||||
|
*/
|
||||||
|
export default async function BerthsAdminIndex({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ portSlug: string }>;
|
||||||
|
}) {
|
||||||
|
const { portSlug } = await params;
|
||||||
|
const tools = [
|
||||||
|
{
|
||||||
|
href: `/${portSlug}/admin/berths/bulk-add` as Route,
|
||||||
|
label: 'Bulk add berths',
|
||||||
|
description:
|
||||||
|
'Generate many berth rows in one wizard - set pier, prefix, mooring number range, and per-berth defaults; preview before commit.',
|
||||||
|
icon: Anchor,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: `/${portSlug}/admin/berths/reconcile` as Route,
|
||||||
|
label: 'Reconciliation queue',
|
||||||
|
description:
|
||||||
|
"Berths missing required fields after import / PDF parse. Surface what's missing per row and link straight to the edit sheet.",
|
||||||
|
icon: FileSearch,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Berths admin"
|
||||||
|
eyebrow="ADMIN"
|
||||||
|
description="Tools for bulk berth creation and post-import reconciliation. Single-berth edits stay on the Berths list - these surfaces are for batch operations."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
{tools.map((t) => {
|
||||||
|
const Icon = t.icon;
|
||||||
|
return (
|
||||||
|
<Link key={t.href} href={t.href} className="block group">
|
||||||
|
<Card className="h-full transition-colors group-hover:border-primary/50 group-hover:bg-muted/30">
|
||||||
|
<CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2">
|
||||||
|
<Icon
|
||||||
|
className="h-5 w-5 mt-0.5 text-muted-foreground group-hover:text-primary"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<CardTitle className="text-base">{t.label}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CardDescription>{t.description}</CardDescription>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border-amber-200 bg-amber-50/50">
|
||||||
|
<CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2">
|
||||||
|
<AlertCircle className="h-5 w-5 mt-0.5 text-amber-600" aria-hidden />
|
||||||
|
<CardTitle className="text-sm">Not what you're looking for?</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CardDescription className="text-xs">
|
||||||
|
For single-berth edits, browse to the{' '}
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/berths` as Route}
|
||||||
|
className="font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Berths list
|
||||||
|
</Link>{' '}
|
||||||
|
and click any row. Per-berth PDF uploads + brochure assignment also live there.
|
||||||
|
</CardDescription>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -52,7 +52,7 @@ const FIELDS: SettingFieldDef[] = [
|
|||||||
description:
|
description:
|
||||||
'Blurred photo shown behind the white email card and the auth-shell (login / reset password) pages. Leave blank to render a plain off-white backdrop. Recommended: 1920x1080 JPG, pre-blurred to ~20px gaussian so it reads as a soft background even on small clients.',
|
'Blurred photo shown behind the white email card and the auth-shell (login / reset password) pages. Leave blank to render a plain off-white backdrop. Recommended: 1920x1080 JPG, pre-blurred to ~20px gaussian so it reads as a soft background even on small clients.',
|
||||||
type: 'image-upload',
|
type: 'image-upload',
|
||||||
// 16:9 — landscape. Without an explicit aspect, the cropper falls
|
// 16:9 - landscape. Without an explicit aspect, the cropper falls
|
||||||
// back to 1:1 and renders a circular mask (intended for avatars),
|
// back to 1:1 and renders a circular mask (intended for avatars),
|
||||||
// which is the wrong UX for a viewport-cover background.
|
// which is the wrong UX for a viewport-cover background.
|
||||||
imageAspect: 16 / 9,
|
imageAspect: 16 / 9,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { BrochuresAdminPanel } from '@/components/admin/brochures-admin-panel';
|
|||||||
*
|
*
|
||||||
* Lists brochures, lets per-port admins upload new versions via direct-to-
|
* Lists brochures, lets per-port admins upload new versions via direct-to-
|
||||||
* storage presigned URLs (so the 20MB+ file never traverses Next.js's
|
* storage presigned URLs (so the 20MB+ file never traverses Next.js's
|
||||||
* body-size limit — see §11.1), and toggle the default flag.
|
* body-size limit - see §11.1), and toggle the default flag.
|
||||||
*/
|
*/
|
||||||
export default function BrochuresAdminPage() {
|
export default function BrochuresAdminPage() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { TemplateSyncButton } from '@/components/admin/documenso/template-sync-b
|
|||||||
import { PageHeader } from '@/components/shared/page-header';
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
// All field arrays removed — every Documenso setting now flows through
|
// All field arrays removed - every Documenso setting now flows through
|
||||||
// `RegistryDrivenForm`, which surfaces the env-fallback / port / global
|
// `RegistryDrivenForm`, which surfaces the env-fallback / port / global
|
||||||
// source badge on each field. The settings themselves live in
|
// source badge on each field. The settings themselves live in
|
||||||
// `src/lib/settings/registry.ts` under sections `documenso.api` /
|
// `src/lib/settings/registry.ts` under sections `documenso.api` /
|
||||||
@@ -17,8 +17,8 @@ export default function DocumensoSettingsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Documenso & EOI"
|
title="Signing service (Documenso)"
|
||||||
description="API credentials, signer identities, and document generation behaviour. Use the test-connection button to verify a saved configuration before relying on it."
|
description="API credentials, signer identities, templates, and signing behaviour for every document the CRM puts out for signature (EOI, reservation, contract, custom uploads). Use the test-connection button to verify a saved configuration before relying on it."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
@@ -200,7 +200,7 @@ export default function DocumensoSettingsPage() {
|
|||||||
<RegistryDrivenForm
|
<RegistryDrivenForm
|
||||||
sections={['documenso.templates']}
|
sections={['documenso.templates']}
|
||||||
title="Templates & signing pathway"
|
title="Templates & signing pathway"
|
||||||
description="Default pathway, template IDs, and email behaviour for EOIs, reservations, and contracts. Recipient + field discovery happens via 'Sync from Documenso' below — that also populates the EOI template ID for you. Most ports leave the reservation/contract template IDs blank because those are typically drafted per interest and uploaded for signing; set them only if you maintain standardised Documenso templates for them."
|
description="Default pathway, template IDs, and email behaviour for EOIs, reservations, and contracts. Recipient + field discovery happens via 'Sync from Documenso' below - that also populates the EOI template ID for you. Most ports leave the reservation/contract template IDs blank because those are typically drafted per interest and uploaded for signing; set them only if you maintain standardised Documenso templates for them."
|
||||||
extra={<TemplateSyncButton />}
|
extra={<TemplateSyncButton />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-fo
|
|||||||
import { SalesEmailConfigCard } from '@/components/admin/sales-email-config-card';
|
import { SalesEmailConfigCard } from '@/components/admin/sales-email-config-card';
|
||||||
import { EmailRoutingCard } from '@/components/admin/email-routing-card';
|
import { EmailRoutingCard } from '@/components/admin/email-routing-card';
|
||||||
import { SmtpTestSendCard } from '@/components/admin/email/smtp-test-send-card';
|
import { SmtpTestSendCard } from '@/components/admin/email/smtp-test-send-card';
|
||||||
|
import { TestTemplateCard } from '@/components/admin/email/test-template-card';
|
||||||
|
|
||||||
export default function EmailSettingsPage() {
|
export default function EmailSettingsPage() {
|
||||||
return (
|
return (
|
||||||
@@ -14,7 +15,7 @@ export default function EmailSettingsPage() {
|
|||||||
description="Per-port outgoing email configuration. SMTP credentials and the From address default to environment variables when these fields are blank. Header/footer HTML lives under Branding."
|
description="Per-port outgoing email configuration. SMTP credentials and the From address default to environment variables when these fields are blank. Header/footer HTML lives under Branding."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Explainer for the "two accounts" model — addresses the recurring
|
{/* Explainer for the "two accounts" model - addresses the recurring
|
||||||
UAT question "why are there separate SMTP credentials for sales
|
UAT question "why are there separate SMTP credentials for sales
|
||||||
and noreply?". Keeps the answer in front of the admin before
|
and noreply?". Keeps the answer in front of the admin before
|
||||||
they reach the per-card form below. */}
|
they reach the per-card form below. */}
|
||||||
@@ -39,7 +40,7 @@ export default function EmailSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Registry-driven so each field shows the "Using env fallback /
|
{/* Registry-driven so each field shows the "Using env fallback /
|
||||||
port / global / default" badge inline — admins can tell at a
|
port / global / default" badge inline - admins can tell at a
|
||||||
glance which fields are coming from .env vs. UI overrides. */}
|
glance which fields are coming from .env vs. UI overrides. */}
|
||||||
<RegistryDrivenForm
|
<RegistryDrivenForm
|
||||||
sections={['email.from']}
|
sections={['email.from']}
|
||||||
@@ -52,6 +53,7 @@ export default function EmailSettingsPage() {
|
|||||||
description="Optional per-port SMTP credentials for the noreply mailbox. Leave blank to use the global env defaults. Each field shows its current source (env / port / default) so you can tell what's active without checking the deploy."
|
description="Optional per-port SMTP credentials for the noreply mailbox. Leave blank to use the global env defaults. Each field shows its current source (env / port / default) so you can tell what's active without checking the deploy."
|
||||||
/>
|
/>
|
||||||
<SmtpTestSendCard />
|
<SmtpTestSendCard />
|
||||||
|
<TestTemplateCard />
|
||||||
<SalesEmailConfigCard />
|
<SalesEmailConfigCard />
|
||||||
<EmailRoutingCard />
|
<EmailRoutingCard />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -163,11 +163,11 @@ export default function ErrorEventDetailPage() {
|
|||||||
<KV label="Method" value={event.method} />
|
<KV label="Method" value={event.method} />
|
||||||
<KV label="Path" value={event.path} mono />
|
<KV label="Path" value={event.path} mono />
|
||||||
<KV label="When" value={format(new Date(event.createdAt), 'PPpp')} />
|
<KV label="When" value={format(new Date(event.createdAt), 'PPpp')} />
|
||||||
<KV label="Duration" value={event.durationMs ? `${event.durationMs} ms` : '—'} />
|
<KV label="Duration" value={event.durationMs ? `${event.durationMs} ms` : '-'} />
|
||||||
<KV label="Port" value={event.portId ?? '(none)'} mono />
|
<KV label="Port" value={event.portId ?? '(none)'} mono />
|
||||||
<KV label="User" value={event.userId ?? '(none)'} mono />
|
<KV label="User" value={event.userId ?? '(none)'} mono />
|
||||||
<KV label="IP" value={event.ipAddress ?? '—'} mono />
|
<KV label="IP" value={event.ipAddress ?? '-'} mono />
|
||||||
<KV label="User agent" value={event.userAgent ?? '—'} />
|
<KV label="User agent" value={event.userAgent ?? '-'} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -176,11 +176,11 @@ export default function ErrorEventDetailPage() {
|
|||||||
<CardTitle className="text-sm font-medium">Error</CardTitle>
|
<CardTitle className="text-sm font-medium">Error</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 text-sm">
|
<CardContent className="space-y-3 text-sm">
|
||||||
<KV label="Name" value={event.errorName ?? '—'} mono />
|
<KV label="Name" value={event.errorName ?? '-'} mono />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">Message</p>
|
<p className="text-xs text-muted-foreground">Message</p>
|
||||||
<p className="mt-0.5 font-mono whitespace-pre-wrap wrap-break-word">
|
<p className="mt-0.5 font-mono whitespace-pre-wrap wrap-break-word">
|
||||||
{event.errorMessage ?? '—'}
|
{event.errorMessage ?? '-'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{event.errorStack && (
|
{event.errorStack && (
|
||||||
@@ -240,7 +240,7 @@ function KV({ label, value, mono }: { label: string; value: string | null; mono?
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">{label}</p>
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
<p className={`mt-0.5 ${mono ? 'font-mono text-xs' : ''}`}>{value ?? '—'}</p>
|
<p className={`mt-0.5 ${mono ? 'font-mono text-xs' : ''}`}>{value ?? '-'}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { ERROR_CODES } from '@/lib/error-codes';
|
|||||||
* plain-language meaning + status code without leaving the app.
|
* plain-language meaning + status code without leaving the app.
|
||||||
*
|
*
|
||||||
* Pulls directly from `src/lib/error-codes.ts` so it stays in sync
|
* Pulls directly from `src/lib/error-codes.ts` so it stays in sync
|
||||||
* automatically — adding an entry to the registry adds a row here.
|
* automatically - adding an entry to the registry adds a row here.
|
||||||
*/
|
*/
|
||||||
export default function ErrorCodeReferencePage() {
|
export default function ErrorCodeReferencePage() {
|
||||||
const params = useParams<{ portSlug: string }>();
|
const params = useParams<{ portSlug: string }>();
|
||||||
@@ -39,7 +39,7 @@ export default function ErrorCodeReferencePage() {
|
|||||||
}, [search]);
|
}, [search]);
|
||||||
|
|
||||||
// Group by domain prefix (the part before the first underscore) so
|
// Group by domain prefix (the part before the first underscore) so
|
||||||
// the table reads naturally — Expenses, Berths, Storage, etc.
|
// the table reads naturally - Expenses, Berths, Storage, etc.
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
const groups = new Map<string, typeof entries>();
|
const groups = new Map<string, typeof entries>();
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
import { OcrSettingsForm } from '@/components/admin/ocr-settings-form';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
export default function OcrSettingsPage() {
|
/**
|
||||||
return <OcrSettingsForm />;
|
* Legacy route. OCR settings now live on the consolidated AI panel at
|
||||||
|
* `/admin/ai` (the same `<OcrSettingsForm>` is mounted there alongside
|
||||||
|
* the master AI switch + provider credentials). Kept as a redirect-only
|
||||||
|
* page so any bookmarks / docs / deep links land on the right surface.
|
||||||
|
*
|
||||||
|
* Slated for full removal once the 2026-05-22 admin IA migration has
|
||||||
|
* had a quarter to bed in.
|
||||||
|
*/
|
||||||
|
export default async function OcrLegacyRedirectPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ portSlug: string }>;
|
||||||
|
}) {
|
||||||
|
const { portSlug } = await params;
|
||||||
|
redirect(`/${portSlug}/admin/ai`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export default function PipelineRulesPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Hydrate the local form once the server-side state arrives. We treat
|
// Hydrate the local form once the server-side state arrives. We treat
|
||||||
// missing keys as the registered default — the page's persisted JSON
|
// missing keys as the registered default - the page's persisted JSON
|
||||||
// doesn't have to enumerate every trigger, just the overrides.
|
// doesn't have to enumerate every trigger, just the overrides.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const persisted = data?.data?.values?.stage_advance_rules?.value;
|
const persisted = data?.data?.values?.stage_advance_rules?.value;
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
import { ReportsDashboard } from '@/components/admin/reports-dashboard';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
export default function AdminReportsPage() {
|
/**
|
||||||
return <ReportsDashboard />;
|
* 2026-05-22: `/admin/reports` deleted. The page rendered three cards
|
||||||
|
* - Pipeline funnel, Berth occupancy, and a KPI grid - all of which
|
||||||
|
* are already covered by the main Dashboard widgets (`pipeline_funnel`,
|
||||||
|
* `occupancy_timeline`, `kpi_*`). Redirecting to the dashboard so any
|
||||||
|
* lingering bookmarks land somewhere coherent.
|
||||||
|
*
|
||||||
|
* The `<ReportsDashboard>` component file lives on in the repo for now
|
||||||
|
* pending a follow-up sweep - once we confirm no other surface mounts
|
||||||
|
* it, the component + its data hook can be removed too.
|
||||||
|
*/
|
||||||
|
export default async function ReportsLegacyRedirectPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ portSlug: string }>;
|
||||||
|
}) {
|
||||||
|
const { portSlug } = await params;
|
||||||
|
redirect(`/${portSlug}/dashboard`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export default function ResidentialStagesPage() {
|
|||||||
/>
|
/>
|
||||||
<ResidentialStagesAdmin />
|
<ResidentialStagesAdmin />
|
||||||
|
|
||||||
{/* Partner forwarding — sits on the same admin page so all
|
{/* Partner forwarding - sits on the same admin page so all
|
||||||
residential-only port settings live in one place. Reps still
|
residential-only port settings live in one place. Reps still
|
||||||
see every inquiry in the CRM; this is an outbound courtesy
|
see every inquiry in the CRM; this is an outbound courtesy
|
||||||
notification for the partner who handles residential leads. */}
|
notification for the partner who handles residential leads. */}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { TemplateEditor } from '@/components/admin/templates/template-editor';
|
import { TemplateEditor } from '@/components/admin/templates/template-editor';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase 7.1 — PDF template editor (read + place markers).
|
* Phase 7.1 - PDF template editor (read + place markers).
|
||||||
*
|
*
|
||||||
* Renders the source PDF for the selected template and lets the admin
|
* Renders the source PDF for the selected template and lets the admin
|
||||||
* drop merge-field markers by clicking on the page. Persists the marker
|
* drop merge-field markers by clicking on the page. Persists the marker
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { PageHeader } from '@/components/shared/page-header';
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "People with access" surface — covers BOTH currently-active CRM users
|
* "People with access" surface - covers BOTH currently-active CRM users
|
||||||
* and pending invitations. Previously these lived on separate routes
|
* and pending invitations. Previously these lived on separate routes
|
||||||
* (/admin/users + /admin/invitations); merged 2026-05-21 so admins land
|
* (/admin/users + /admin/invitations); merged 2026-05-21 so admins land
|
||||||
* on one page and tab between states. The standalone /admin/invitations
|
* on one page and tab between states. The standalone /admin/invitations
|
||||||
|
|||||||
@@ -50,15 +50,15 @@ const FIELDS: SettingFieldDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'umami_api_token',
|
key: 'umami_api_token',
|
||||||
label: 'API key (Umami Cloud only — optional)',
|
label: 'API key (Umami Cloud only - optional)',
|
||||||
description:
|
description:
|
||||||
'Only fill this if you use Umami Cloud, which uses a long-lived API key instead of username/password. Leave blank for self-hosted installs — the username + password above are used instead. Stored AES-256-GCM at rest.',
|
'Only fill this if you use Umami Cloud, which uses a long-lived API key instead of username/password. Leave blank for self-hosted installs - the username + password above are used instead. Stored AES-256-GCM at rest.',
|
||||||
type: 'password',
|
type: 'password',
|
||||||
defaultValue: '',
|
defaultValue: '',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Tracking-pixel kill switch — opt-in per port. When enabled, outbound
|
// Tracking-pixel kill switch - opt-in per port. When enabled, outbound
|
||||||
// sales sends embed a 1×1 pixel pointing at /api/public/email-pixel that
|
// sales sends embed a 1×1 pixel pointing at /api/public/email-pixel that
|
||||||
// records opens to `document_send_opens` and cross-posts to Umami.
|
// records opens to `document_send_opens` and cross-posts to Umami.
|
||||||
const TRACKING_FIELDS: SettingFieldDef[] = [
|
const TRACKING_FIELDS: SettingFieldDef[] = [
|
||||||
@@ -66,7 +66,7 @@ const TRACKING_FIELDS: SettingFieldDef[] = [
|
|||||||
key: 'email_open_tracking_enabled',
|
key: 'email_open_tracking_enabled',
|
||||||
label: 'Track email opens',
|
label: 'Track email opens',
|
||||||
description:
|
description:
|
||||||
'Embeds an invisible 1×1 tracking pixel in outbound sales emails. Each open is recorded in the CRM and cross-posted to Umami as an "email-opened" event. Apple Mail privacy proxy will over-count; clients that block images will under-count — standard email-tracking caveats apply.',
|
'Embeds an invisible 1×1 tracking pixel in outbound sales emails. Each open is recorded in the CRM and cross-posted to Umami as an "email-opened" event. Apple Mail privacy proxy will over-count; clients that block images will under-count - standard email-tracking caveats apply.',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
// Legacy /alerts route — merged into /inbox in 2026-05-11. The hash
|
// Legacy /alerts route - merged into /inbox in 2026-05-11. The hash
|
||||||
// scrolls + expands the Alerts section on the merged page, so old
|
// scrolls + expands the Alerts section on the merged page, so old
|
||||||
// bookmarks land in the right spot.
|
// bookmarks land in the right spot.
|
||||||
export default async function AlertsRedirect({
|
export default async function AlertsRedirect({
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export default function ScanReceiptPage() {
|
|||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
// After OCR succeeds we also upload the receipt to /api/v1/files/upload
|
// After OCR succeeds we also upload the receipt to /api/v1/files/upload
|
||||||
// so the expense links to the actual image. The legacy scanner skipped
|
// so the expense links to the actual image. The legacy scanner skipped
|
||||||
// this step and saved expenses without their receipt — which silently
|
// this step and saved expenses without their receipt - which silently
|
||||||
// disqualified them from parent-company reimbursement (the warning the
|
// disqualified them from parent-company reimbursement (the warning the
|
||||||
// PDF export now surfaces).
|
// PDF export now surfaces).
|
||||||
const [uploadedFile, setUploadedFile] = useState<UploadedFileMeta | null>(null);
|
const [uploadedFile, setUploadedFile] = useState<UploadedFileMeta | null>(null);
|
||||||
@@ -365,7 +365,7 @@ export default function ScanReceiptPage() {
|
|||||||
disabled={
|
disabled={
|
||||||
saveMutation.isPending ||
|
saveMutation.isPending ||
|
||||||
!amount ||
|
!amount ||
|
||||||
// Block save while the receipt upload is still in flight —
|
// Block save while the receipt upload is still in flight -
|
||||||
// otherwise the rep can hit Save before the storage round
|
// otherwise the rep can hit Save before the storage round
|
||||||
// trip finishes and the expense lands without `receiptFileIds`,
|
// trip finishes and the expense lands without `receiptFileIds`,
|
||||||
// silently re-creating the legacy receipt-loss bug.
|
// silently re-creating the legacy receipt-loss bug.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
// Legacy /reminders route — merged into /inbox in 2026-05-11. The hash
|
// Legacy /reminders route - merged into /inbox in 2026-05-11. The hash
|
||||||
// scrolls + expands the Reminders section on the merged page.
|
// scrolls + expands the Reminders section on the merged page.
|
||||||
export default async function RemindersRedirect({
|
export default async function RemindersRedirect({
|
||||||
params,
|
params,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* /<port>/residential is a namespace segment — the actual landing is
|
* /<port>/residential is a namespace segment - the actual landing is
|
||||||
* /residential/clients. Without a page.tsx here, the breadcrumb's
|
* /residential/clients. Without a page.tsx here, the breadcrumb's
|
||||||
* "Residential" link 404s. Server-redirect to the Clients sub-page so
|
* "Residential" link 404s. Server-redirect to the Clients sub-page so
|
||||||
* the link works as a useful shortcut.
|
* the link works as a useful shortcut.
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
|||||||
: portRoles.map((pr) => pr.port);
|
: portRoles.map((pr) => pr.port);
|
||||||
|
|
||||||
// Prefer a previously-resolved tier from the client's cookie so the
|
// Prefer a previously-resolved tier from the client's cookie so the
|
||||||
// server renders the matching shell on first paint — eliminates the
|
// server renders the matching shell on first paint - eliminates the
|
||||||
// mobile↔desktop chrome flicker that happens when UA-based classification
|
// mobile↔desktop chrome flicker that happens when UA-based classification
|
||||||
// disagrees with the actual viewport (e.g. macOS Safari with the
|
// disagrees with the actual viewport (e.g. macOS Safari with the
|
||||||
// window dragged below 1024). AppShell writes the cookie after the
|
// window dragged below 1024). AppShell writes the cookie after the
|
||||||
@@ -58,7 +58,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
|||||||
|
|
||||||
// Per-port logo map for the sidebar. Resolved server-side so the
|
// Per-port logo map for the sidebar. Resolved server-side so the
|
||||||
// sidebar can swap brand on port switch without an extra round-trip.
|
// sidebar can swap brand on port switch without an extra round-trip.
|
||||||
// Falls back to null per port when no logo is configured — the
|
// Falls back to null per port when no logo is configured - the
|
||||||
// sidebar surfaces nothing rather than leaking a generic placeholder.
|
// sidebar surfaces nothing rather than leaking a generic placeholder.
|
||||||
const portBrandingEntries = await Promise.all(
|
const portBrandingEntries = await Promise.all(
|
||||||
ports.map(async (p) => {
|
ports.map(async (p) => {
|
||||||
@@ -85,7 +85,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
|||||||
flag in prod) so the banner is dev/staging-only. */}
|
flag in prod) so the banner is dev/staging-only. */}
|
||||||
<DevModeBanner />
|
<DevModeBanner />
|
||||||
{/* #26: AppShell mounts ONE responsive tree (desktop OR
|
{/* #26: AppShell mounts ONE responsive tree (desktop OR
|
||||||
* mobile) per render — never both — so pages don't pay the
|
* mobile) per render - never both - so pages don't pay the
|
||||||
* double-state, double-fetch, double-Tabs-provider tax. */}
|
* double-state, double-fetch, double-Tabs-provider tax. */}
|
||||||
<AppShell
|
<AppShell
|
||||||
portRoles={portRoles}
|
portRoles={portRoles}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default async function PortalLayout({ children }: { children: React.React
|
|||||||
// Branding for the auth-shell pages (login, forgot-password, reset).
|
// Branding for the auth-shell pages (login, forgot-password, reset).
|
||||||
// When the visitor has a session, use that port's branding so they
|
// When the visitor has a session, use that port's branding so they
|
||||||
// stay inside one tenant's look. Otherwise pick up the first-port
|
// stay inside one tenant's look. Otherwise pick up the first-port
|
||||||
// default — the same path the CRM auth pages take.
|
// default - the same path the CRM auth pages take.
|
||||||
const branding = session
|
const branding = session
|
||||||
? await getPortBrandingConfig(session.portId)
|
? await getPortBrandingConfig(session.portId)
|
||||||
.then((cfg) => ({
|
.then((cfg) => ({
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export default async function PortalInterestsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* leadCategory ("hot_lead" / "qualified_lead" / etc.)
|
{/* leadCategory ("hot_lead" / "qualified_lead" / etc.)
|
||||||
is a staff classification — never render to clients.
|
is a staff classification - never render to clients.
|
||||||
Privacy + optics: we shouldn't be telling the
|
Privacy + optics: we shouldn't be telling the
|
||||||
prospect they're a "hot lead". */}
|
prospect they're a "hot lead". */}
|
||||||
<div className="flex flex-wrap gap-2 mt-2 text-xs text-gray-400">
|
<div className="flex flex-wrap gap-2 mt-2 text-xs text-gray-400">
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
|||||||
* Validate the `?next=` post-login redirect target. auth-flow-auditor M10:
|
* Validate the `?next=` post-login redirect target. auth-flow-auditor M10:
|
||||||
* an unvalidated `next` lets `/portal/login?next=https://evil.example`
|
* an unvalidated `next` lets `/portal/login?next=https://evil.example`
|
||||||
* navigate cross-site after sign-in. Only allow same-origin paths
|
* navigate cross-site after sign-in. Only allow same-origin paths
|
||||||
* scoped to the portal surface — anything else falls back to the
|
* scoped to the portal surface - anything else falls back to the
|
||||||
* dashboard.
|
* dashboard.
|
||||||
*/
|
*/
|
||||||
function safeNextPath(raw: string | null): string {
|
function safeNextPath(raw: string | null): string {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const upstream = toNextJsHandler(auth);
|
|||||||
/**
|
/**
|
||||||
* Wrap better-auth's `[...all]` handler so we can stamp the audit log on
|
* Wrap better-auth's `[...all]` handler so we can stamp the audit log on
|
||||||
* authentication events. Better-auth itself doesn't fire any callback we
|
* authentication events. Better-auth itself doesn't fire any callback we
|
||||||
* can hook on sign-in / sign-out / failed-login — we inspect the route
|
* can hook on sign-in / sign-out / failed-login - we inspect the route
|
||||||
* + response status after the upstream handler finishes.
|
* + response status after the upstream handler finishes.
|
||||||
*
|
*
|
||||||
* Successful sign-in → action 'login' (severity info)
|
* Successful sign-in → action 'login' (severity info)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const bodySchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||||
// 10/hour/IP — bounds brute-force against either token store.
|
// 10/hour/IP - bounds brute-force against either token store.
|
||||||
const limited = await enforcePublicRateLimit(req, 'portalToken');
|
const limited = await enforcePublicRateLimit(req, 'portalToken');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
|||||||
// `auth.api.resetPassword` (rotates the password on an existing
|
// `auth.api.resetPassword` (rotates the password on an existing
|
||||||
// user).
|
// user).
|
||||||
// Try the CRM-invite path first. If the token isn't in that table
|
// Try the CRM-invite path first. If the token isn't in that table
|
||||||
// (NotFoundError), fall through to better-auth — these are mutually
|
// (NotFoundError), fall through to better-auth - these are mutually
|
||||||
// exclusive token spaces, so at most one will accept it.
|
// exclusive token spaces, so at most one will accept it.
|
||||||
try {
|
try {
|
||||||
const result = await consumeCrmInvite({ token, password });
|
const result = await consumeCrmInvite({ token, password });
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ async function resolveToEmail(identifier: string): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
// Rate-limit on IP — same 5/15min bucket the sign-in endpoint uses.
|
// Rate-limit on IP - same 5/15min bucket the sign-in endpoint uses.
|
||||||
const ip = clientIp(req);
|
const ip = clientIp(req);
|
||||||
const rl = await checkRateLimit(ip, rateLimiters.auth);
|
const rl = await checkRateLimit(ip, rateLimiters.auth);
|
||||||
if (!rl.allowed) {
|
if (!rl.allowed) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const bodySchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||||
// 10/hour/IP — bounds brute-force against the 32-byte activation token.
|
// 10/hour/IP - bounds brute-force against the 32-byte activation token.
|
||||||
const limited = await enforcePublicRateLimit(req, 'portalToken');
|
const limited = await enforcePublicRateLimit(req, 'portalToken');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { requestPasswordReset } from '@/lib/services/portal-auth.service';
|
|||||||
const bodySchema = z.object({ email: z.string().email() });
|
const bodySchema = z.object({ email: z.string().email() });
|
||||||
|
|
||||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||||
// 3/hour/IP — tightest of the portal limiters because each successful
|
// 3/hour/IP - tightest of the portal limiters because each successful
|
||||||
// call sends an outbound email and timing differences here are the
|
// call sends an outbound email and timing differences here are the
|
||||||
// primary email-enumeration vector.
|
// primary email-enumeration vector.
|
||||||
const limited = await enforcePublicRateLimit(req, 'portalForgot');
|
const limited = await enforcePublicRateLimit(req, 'portalForgot');
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const bodySchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||||
// 10/hour/IP — bounds brute-force against the 32-byte reset token.
|
// 10/hour/IP - bounds brute-force against the 32-byte reset token.
|
||||||
const limited = await enforcePublicRateLimit(req, 'portalToken');
|
const limited = await enforcePublicRateLimit(req, 'portalToken');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Per-(ip,email) bucket: 5 attempts / 15min. Keyed on email-lowercase so
|
// Per-(ip,email) bucket: 5 attempts / 15min. Keyed on email-lowercase so
|
||||||
// the limiter is per-account-per-IP, not just per-IP — a NATed network
|
// the limiter is per-account-per-IP, not just per-IP - a NATed network
|
||||||
// shouldn't be able to lock a single victim by burning their bucket.
|
// shouldn't be able to lock a single victim by burning their bucket.
|
||||||
const limited = await enforcePublicRateLimit(
|
const limited = await enforcePublicRateLimit(
|
||||||
req,
|
req,
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export async function GET(request: Request): Promise<Response> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Active berths for the port — retired moorings are hidden via
|
// 1. Active berths for the port - retired moorings are hidden via
|
||||||
// the archived_at soft-delete column (migration 0065).
|
// the archived_at soft-delete column (migration 0065).
|
||||||
const berthRows = await db
|
const berthRows = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ function gifResponse(): NextResponse {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'image/gif',
|
'Content-Type': 'image/gif',
|
||||||
'Content-Length': String(TRANSPARENT_GIF.length),
|
'Content-Length': String(TRANSPARENT_GIF.length),
|
||||||
// Tell every upstream cache to keep its hands off — we count opens
|
// Tell every upstream cache to keep its hands off - we count opens
|
||||||
// on the FETCH itself, so any cached response is a missed open.
|
// on the FETCH itself, so any cached response is a missed open.
|
||||||
'Cache-Control': 'no-store, no-cache, must-revalidate, private',
|
'Cache-Control': 'no-store, no-cache, must-revalidate, private',
|
||||||
Pragma: 'no-cache',
|
Pragma: 'no-cache',
|
||||||
@@ -62,7 +62,7 @@ export async function GET(
|
|||||||
const userAgent = req.headers.get('user-agent');
|
const userAgent = req.headers.get('user-agent');
|
||||||
const referer = req.headers.get('referer');
|
const referer = req.headers.get('referer');
|
||||||
|
|
||||||
// Best-effort write — never block the pixel response on a slow DB.
|
// Best-effort write - never block the pixel response on a slow DB.
|
||||||
// The pixel must return promptly so email clients render normally.
|
// The pixel must return promptly so email clients render normally.
|
||||||
db.insert(documentSendOpens)
|
db.insert(documentSendOpens)
|
||||||
.values({
|
.values({
|
||||||
@@ -85,7 +85,7 @@ export async function GET(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Cross-post to Umami so the marketing funnel includes opens. Don't
|
// Cross-post to Umami so the marketing funnel includes opens. Don't
|
||||||
// await — fire-and-forget so the pixel response stays fast.
|
// await - fire-and-forget so the pixel response stays fast.
|
||||||
trackEvent(
|
trackEvent(
|
||||||
sendRow.portId,
|
sendRow.portId,
|
||||||
'email-opened',
|
'email-opened',
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { errorResponse, NotFoundError } from '@/lib/errors';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Public, unauthenticated stream-by-id for branding assets only. Used by
|
* Public, unauthenticated stream-by-id for branding assets only. Used by
|
||||||
* outbound email templates and the branded auth shell — surfaces where
|
* outbound email templates and the branded auth shell - surfaces where
|
||||||
* the consumer can't authenticate (an inbox image fetch has no session
|
* the consumer can't authenticate (an inbox image fetch has no session
|
||||||
* cookie). The `category = 'branding'` gate ensures only assets the
|
* cookie). The `category = 'branding'` gate ensures only assets the
|
||||||
* admin explicitly uploaded as port branding leak through this surface;
|
* admin explicitly uploaded as port branding leak through this surface;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { logger } from '@/lib/logger';
|
|||||||
* the marketing site uses on startup AND what k8s readiness
|
* the marketing site uses on startup AND what k8s readiness
|
||||||
* probes should hit, because it returns 503 on hard dep failures.
|
* probes should hit, because it returns 503 on hard dep failures.
|
||||||
*
|
*
|
||||||
* The dep checks (DB SELECT 1, Redis PING) run on every request — they
|
* The dep checks (DB SELECT 1, Redis PING) run on every request - they
|
||||||
* are <5ms each. If either fails, the response is 503 so a load balancer
|
* are <5ms each. If either fails, the response is 503 so a load balancer
|
||||||
* stops routing to this instance.
|
* stops routing to this instance.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ async function gateRateLimit(ip: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/public/interests — unauthenticated public interest registration.
|
// POST /api/public/interests - unauthenticated public interest registration.
|
||||||
// The transactional trio creation (client + yacht + interest, plus optional
|
// The transactional trio creation (client + yacht + interest, plus optional
|
||||||
// company + membership) lives in `createPublicInterest()` so it's testable
|
// company + membership) lives in `createPublicInterest()` so it's testable
|
||||||
// without an HTTP fixture. This handler is the thin HTTP shell: rate-limit,
|
// without an HTTP fixture. This handler is the thin HTTP shell: rate-limit,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { loadByToken, applySubmission } from '@/lib/services/supplemental-forms.
|
|||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public — no auth. Loads the prefill data for the form. The token in
|
* Public - no auth. Loads the prefill data for the form. The token in
|
||||||
* the URL is the only credential; rejects expired / unknown tokens with
|
* the URL is the only credential; rejects expired / unknown tokens with
|
||||||
* 404 (deliberately conflated to avoid leaking which tokens exist).
|
* 404 (deliberately conflated to avoid leaking which tokens exist).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export async function POST(req: NextRequest) {
|
|||||||
return errorResponse(new RateLimitError(retryAfter));
|
return errorResponse(new RateLimitError(retryAfter));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse + validate body. Reject anything that doesn't conform — the
|
// Parse + validate body. Reject anything that doesn't conform - the
|
||||||
// website is a known caller; a malformed payload signals tampering.
|
// website is a known caller; a malformed payload signals tampering.
|
||||||
let parsed;
|
let parsed;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ interface ReadyResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Readiness probe — verifies that every backing service this process
|
* Readiness probe - verifies that every backing service this process
|
||||||
* needs to serve traffic is reachable. A 503 should drop the pod from the
|
* needs to serve traffic is reachable. A 503 should drop the pod from the
|
||||||
* load balancer until the next probe succeeds; it should not trigger a
|
* load balancer until the next probe succeeds; it should not trigger a
|
||||||
* pod restart (that's what `/api/health` is for).
|
* pod restart (that's what `/api/health` is for).
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export async function GET(
|
|||||||
// Single-use enforcement. SET NX with a TTL pinned to the token's own
|
// Single-use enforcement. SET NX with a TTL pinned to the token's own
|
||||||
// expiry so the dedup window never closes before the token does. Using
|
// expiry so the dedup window never closes before the token does. Using
|
||||||
// the body half of the token as the dedup key (signature included
|
// the body half of the token as the dedup key (signature included
|
||||||
// would also work but body is enough — a reused token has the same body).
|
// would also work but body is enough - a reused token has the same body).
|
||||||
const replayKey = `storage:proxy:seen:${token.split('.')[0]}`;
|
const replayKey = `storage:proxy:seen:${token.split('.')[0]}`;
|
||||||
const remainingSeconds = Math.max(
|
const remainingSeconds = Math.max(
|
||||||
REPLAY_TTL_FLOOR_SECONDS,
|
REPLAY_TTL_FLOOR_SECONDS,
|
||||||
@@ -109,7 +109,7 @@ export async function GET(
|
|||||||
headers.set('Content-Type', payload.c ?? 'application/octet-stream');
|
headers.set('Content-Type', payload.c ?? 'application/octet-stream');
|
||||||
headers.set('Content-Length', String(size));
|
headers.set('Content-Length', String(size));
|
||||||
if (payload.f) {
|
if (payload.f) {
|
||||||
// RFC 5987 — quote the filename and provide a UTF-8 fallback.
|
// RFC 5987 - quote the filename and provide a UTF-8 fallback.
|
||||||
const safe = payload.f.replace(/"/g, '');
|
const safe = payload.f.replace(/"/g, '');
|
||||||
headers.set(
|
headers.set(
|
||||||
'Content-Disposition',
|
'Content-Disposition',
|
||||||
@@ -126,7 +126,7 @@ export async function GET(
|
|||||||
* Filesystem-backend upload proxy. The presigned URL minted by
|
* Filesystem-backend upload proxy. The presigned URL minted by
|
||||||
* `FilesystemBackend.presignUpload` points here. Without this handler the
|
* `FilesystemBackend.presignUpload` points here. Without this handler the
|
||||||
* browser-driven berth-PDF / brochure uploads would 405 in filesystem
|
* browser-driven berth-PDF / brochure uploads would 405 in filesystem
|
||||||
* deployments — the entire pluggable-storage abstraction relied on the
|
* deployments - the entire pluggable-storage abstraction relied on the
|
||||||
* GET-only counterpart for downloads.
|
* GET-only counterpart for downloads.
|
||||||
*
|
*
|
||||||
* Same token-verify + single-use replay protection as GET, plus:
|
* Same token-verify + single-use replay protection as GET, plus:
|
||||||
@@ -186,7 +186,7 @@ export async function PUT(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Read the body into a buffer with a hard cap. Filesystem deployments are
|
// Read the body into a buffer with a hard cap. Filesystem deployments are
|
||||||
// small-tenant (single-node only — see FilesystemBackend boot guard) so
|
// small-tenant (single-node only - see FilesystemBackend boot guard) so
|
||||||
// 50 MB ceiling fits comfortably in heap; no streaming needed.
|
// 50 MB ceiling fits comfortably in heap; no streaming needed.
|
||||||
let buffer: Buffer;
|
let buffer: Buffer;
|
||||||
try {
|
try {
|
||||||
@@ -216,7 +216,7 @@ export async function PUT(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Magic-byte gate: when the token was minted with `c=application/pdf`
|
// Magic-byte gate: when the token was minted with `c=application/pdf`
|
||||||
// (the only consumer today — berth PDFs + brochures), refuse anything
|
// (the only consumer today - berth PDFs + brochures), refuse anything
|
||||||
// that isn't actually a PDF. Mirrors the post-upload check in
|
// that isn't actually a PDF. Mirrors the post-upload check in
|
||||||
// berth-pdf.service.ts so the two paths behave identically.
|
// berth-pdf.service.ts so the two paths behave identically.
|
||||||
if (payload.c === 'application/pdf' && !isPdfMagic(buffer)) {
|
if (payload.c === 'application/pdf' && !isPdfMagic(buffer)) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { errorResponse } from '@/lib/errors';
|
|||||||
import { searchAuditLogs } from '@/lib/services/audit-search.service';
|
import { searchAuditLogs } from '@/lib/services/audit-search.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* M-AU03 — CSV export of audit log search results.
|
* M-AU03 - CSV export of audit log search results.
|
||||||
*
|
*
|
||||||
* Accepts the same query-string filters as `GET /api/v1/admin/audit`
|
* Accepts the same query-string filters as `GET /api/v1/admin/audit`
|
||||||
* (q, userId, action, entityType, entityId, severity, source, from, to)
|
* (q, userId, action, entityType, entityId, severity, source, from, to)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { getPortBrandingConfig } from '@/lib/services/port-config';
|
|||||||
import { renderShell } from '@/lib/email/shell';
|
import { renderShell } from '@/lib/email/shell';
|
||||||
import { sendEmail } from '@/lib/email';
|
import { sendEmail } from '@/lib/email';
|
||||||
|
|
||||||
const SAMPLE_SUBJECT_SUFFIX = ' — branding preview';
|
const SAMPLE_SUBJECT_SUFFIX = ' - branding preview';
|
||||||
|
|
||||||
function buildSampleEmail(branding: {
|
function buildSampleEmail(branding: {
|
||||||
logoUrl: string | null;
|
logoUrl: string | null;
|
||||||
@@ -51,7 +51,7 @@ function buildSampleEmail(branding: {
|
|||||||
return { subject, html };
|
return { subject, html };
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET — return the sample email rendered with the current port's branding.
|
// GET - return the sample email rendered with the current port's branding.
|
||||||
export const GET = withAuth(
|
export const GET = withAuth(
|
||||||
withPermission('admin', 'manage_settings', async (_req, ctx) => {
|
withPermission('admin', 'manage_settings', async (_req, ctx) => {
|
||||||
try {
|
try {
|
||||||
@@ -69,7 +69,7 @@ const sendTestSchema = z.object({
|
|||||||
recipient: z.string().email('Enter a valid email address'),
|
recipient: z.string().email('Enter a valid email address'),
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST — actually send the sample email to a single recipient.
|
// POST - actually send the sample email to a single recipient.
|
||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export const GET = withAuth(
|
|||||||
if (!file) {
|
if (!file) {
|
||||||
return NextResponse.json({ data: null });
|
return NextResponse.json({ data: null });
|
||||||
}
|
}
|
||||||
// Path-only — the admin UI renders this as `<img src>` and the
|
// Path-only - the admin UI renders this as `<img src>` and the
|
||||||
// browser resolves against the current origin. Stays valid whether
|
// browser resolves against the current origin. Stays valid whether
|
||||||
// the admin opens the page from localhost or a LAN IP.
|
// the admin opens the page from localhost or a LAN IP.
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import { registerBrochureVersionSchema } from '@/lib/validators/brochures';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Two-step upload (per §11.1):
|
* Two-step upload (per §11.1):
|
||||||
* 1. GET (no body) — server returns a fresh storage key + presigned URL.
|
* 1. GET (no body) - server returns a fresh storage key + presigned URL.
|
||||||
* 2. POST (metadata) — after the browser PUTs to the URL, register the
|
* 2. POST (metadata) - after the browser PUTs to the URL, register the
|
||||||
* version row server-side.
|
* version row server-side.
|
||||||
*
|
*
|
||||||
* Direct-to-storage uploads bypass Next.js's body-size limit; the server
|
* Direct-to-storage uploads bypass Next.js's body-size limit; the server
|
||||||
@@ -47,7 +47,7 @@ export const GET = withAuth(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Storage keys generated by `generateBrochureStorageKey` look like
|
// Storage keys generated by `generateBrochureStorageKey` look like
|
||||||
// `<portSlug>/brochures/<brochureId>/<uuid>.pdf`. Reject anything else —
|
// `<portSlug>/brochures/<brochureId>/<uuid>.pdf`. Reject anything else -
|
||||||
// without this, an admin holding manage_settings on port A could ship a
|
// without this, an admin holding manage_settings on port A could ship a
|
||||||
// foreign port's storage key (signed EOI bytes, another port's brochure)
|
// foreign port's storage key (signed EOI bytes, another port's brochure)
|
||||||
// and have registerBrochureVersion repoint THIS port's brochure version
|
// and have registerBrochureVersion repoint THIS port's brochure version
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const PATCH = withAuth(
|
|||||||
|
|
||||||
// Read raw body before parsing so we can inspect `fieldType`
|
// Read raw body before parsing so we can inspect `fieldType`
|
||||||
// (the schema strips it; the service rejects any change). Using
|
// (the schema strips it; the service rejects any change). Using
|
||||||
// req.json() directly here is intentional — parseBody would lose
|
// req.json() directly here is intentional - parseBody would lose
|
||||||
// the raw view we need for the mutation-attempt detection below.
|
// the raw view we need for the mutation-attempt detection below.
|
||||||
const body = (await req.json()) as Record<string, unknown>;
|
const body = (await req.json()) as Record<string, unknown>;
|
||||||
const data = updateFieldSchema.parse(body);
|
const data = updateFieldSchema.parse(body);
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export const GET = withAuth(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
// "completed30d" = interests that hit a terminal outcome in
|
// "completed30d" = interests that hit a terminal outcome in
|
||||||
// the last 30 days (any outcome — won, lost, or cancelled).
|
// the last 30 days (any outcome - won, lost, or cancelled).
|
||||||
// Use `outcome_at` not `updated_at` so unrelated edits to a
|
// Use `outcome_at` not `updated_at` so unrelated edits to a
|
||||||
// long-closed deal don't drag it back into the window.
|
// long-closed deal don't drag it back into the window.
|
||||||
db
|
db
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { syncDocumensoTemplate } from '@/lib/services/documenso-template-sync.se
|
|||||||
* field name→ID map at documenso_eoi_field_map for v2 prefillFields usage.
|
* field name→ID map at documenso_eoi_field_map for v2 prefillFields usage.
|
||||||
*
|
*
|
||||||
* Accepts either a numeric template ID (`123`) or a Documenso 2.x envelope
|
* Accepts either a numeric template ID (`123`) or a Documenso 2.x envelope
|
||||||
* ID (`envelope_xxxxxxxx`) — the latter is what the Documenso UI URL shows,
|
* ID (`envelope_xxxxxxxx`) - the latter is what the Documenso UI URL shows,
|
||||||
* so paste-from-URL works out of the box on v2 instances. Envelope IDs get
|
* so paste-from-URL works out of the box on v2 instances. Envelope IDs get
|
||||||
* resolved to their numeric template id via `findTemplateIdByEnvelopeId`
|
* resolved to their numeric template id via `findTemplateIdByEnvelopeId`
|
||||||
* before the sync runs.
|
* before the sync runs.
|
||||||
@@ -30,7 +30,7 @@ export const POST = withAuth(
|
|||||||
if (/^envelope_/.test(raw)) {
|
if (/^envelope_/.test(raw)) {
|
||||||
const resolved = await findTemplateIdByEnvelopeId(raw, ctx.portId);
|
const resolved = await findTemplateIdByEnvelopeId(raw, ctx.portId);
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
throw new NotFoundError(`Template "${raw}" — no matching envelopeId found`);
|
throw new NotFoundError(`Template "${raw}" - no matching envelopeId found`);
|
||||||
}
|
}
|
||||||
templateId = resolved;
|
templateId = resolved;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { getEoiTemplateSyncReport } from '@/lib/services/documenso-template-sync
|
|||||||
* so the admin panel's status box survives a page reload without re-hitting
|
* so the admin panel's status box survives a page reload without re-hitting
|
||||||
* Documenso. Returns `{ data: null }` when no sync has run for this port.
|
* Documenso. Returns `{ data: null }` when no sync has run for this port.
|
||||||
*
|
*
|
||||||
* Admin-only via `admin.manage_settings` — same gate as the sync write
|
* Admin-only via `admin.manage_settings` - same gate as the sync write
|
||||||
* endpoint, since the report contains template recipient identities and
|
* endpoint, since the report contains template recipient identities and
|
||||||
* AcroForm field names that aren't OK to leak outside the admin surface.
|
* AcroForm field names that aren't OK to leak outside the admin surface.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { listTemplates } from '@/lib/services/documenso-client';
|
|||||||
*
|
*
|
||||||
* Lists every Documenso template visible to the configured API key
|
* Lists every Documenso template visible to the configured API key
|
||||||
* for the calling port. Drives the "Documenso-first templates" admin
|
* for the calling port. Drives the "Documenso-first templates" admin
|
||||||
* picker (R62) — reps see real template names instead of having to
|
* picker (R62) - reps see real template names instead of having to
|
||||||
* type numeric IDs.
|
* type numeric IDs.
|
||||||
*
|
*
|
||||||
* Gated on `admin.manage_settings` since the data exposed is essentially
|
* Gated on `admin.manage_settings` since the data exposed is essentially
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export const PUT = withAuth(
|
|||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
};
|
};
|
||||||
if (body.subject === null || body.subject === '') {
|
if (body.subject === null || body.subject === '') {
|
||||||
// Clear the override (and only at the per-port level — never touch global).
|
// Clear the override (and only at the per-port level - never touch global).
|
||||||
await deleteSetting(settingKey, ctx.portId, meta);
|
await deleteSetting(settingKey, ctx.portId, meta);
|
||||||
} else {
|
} else {
|
||||||
await upsertSetting(settingKey, body.subject, ctx.portId, meta);
|
await upsertSetting(settingKey, body.subject, ctx.portId, meta);
|
||||||
|
|||||||
@@ -16,14 +16,14 @@ import { updateSalesEmailConfigSchema } from '@/lib/validators/sales-email-confi
|
|||||||
* GET /api/v1/admin/email/sales-config
|
* GET /api/v1/admin/email/sales-config
|
||||||
*
|
*
|
||||||
* Returns the redacted view of the sales-email config. Per §14.10
|
* Returns the redacted view of the sales-email config. Per §14.10
|
||||||
* reps can't see the decrypted password — the response only carries
|
* reps can't see the decrypted password - the response only carries
|
||||||
* `*PassIsSet` boolean markers via `redactSalesConfigForResponse`.
|
* `*PassIsSet` boolean markers via `redactSalesConfigForResponse`.
|
||||||
*
|
*
|
||||||
* Today this endpoint is admin-only because it's consumed only by the
|
* Today this endpoint is admin-only because it's consumed only by the
|
||||||
* admin UI panel (`src/components/admin/sales-email-config-card.tsx`).
|
* admin UI panel (`src/components/admin/sales-email-config-card.tsx`).
|
||||||
* A future rep-facing surface that needs the from-address or body
|
* A future rep-facing surface that needs the from-address or body
|
||||||
* templates can split into a separate `/email/sales-config/preview`
|
* templates can split into a separate `/email/sales-config/preview`
|
||||||
* endpoint scoped to `email.view` — keeping the admin endpoint locked
|
* endpoint scoped to `email.view` - keeping the admin endpoint locked
|
||||||
* to `manage_settings` avoids accidentally widening secret-adjacent
|
* to `manage_settings` avoids accidentally widening secret-adjacent
|
||||||
* surfaces (e.g. the SMTP host name itself can be a leak vector).
|
* surfaces (e.g. the SMTP host name itself can be a leak vector).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const bodySchema = z.object({
|
|||||||
* Sends a small text/HTML message to either the body-supplied `to` or
|
* Sends a small text/HTML message to either the body-supplied `to` or
|
||||||
* (default) the admin's own email so they get the verification in their
|
* (default) the admin's own email so they get the verification in their
|
||||||
* inbox. Returns { ok: true } on success or { ok: false, error } on
|
* inbox. Returns { ok: true } on success or { ok: false, error } on
|
||||||
* failure — the admin UI rates accordingly.
|
* failure - the admin UI rates accordingly.
|
||||||
*/
|
*/
|
||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
||||||
@@ -28,13 +28,13 @@ export const POST = withAuth(
|
|||||||
const recipient = body.to ?? ctx.user.email;
|
const recipient = body.to ?? ctx.user.email;
|
||||||
if (!recipient) {
|
if (!recipient) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ data: { ok: false, error: 'No recipient resolved — sign-in email is empty' } },
|
{ data: { ok: false, error: 'No recipient resolved - sign-in email is empty' } },
|
||||||
{ status: 200 },
|
{ status: 200 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const subject = `Port Nimara CRM — SMTP test (${new Date().toLocaleTimeString()})`;
|
const subject = `Port Nimara CRM - SMTP test (${new Date().toLocaleTimeString()})`;
|
||||||
const html = `<p>Hello,</p><p>This is a test message sent from your CRM's <strong>Sales SMTP</strong> configuration. If you received this, your SMTP credentials work.</p><p style="color:#666;font-size:12px;">Timestamp: ${new Date().toISOString()}</p>`;
|
const html = `<p>Hello,</p><p>This is a test message sent from your CRM's <strong>Sales SMTP</strong> configuration. If you received this, your SMTP credentials work.</p><p style="color:#666;font-size:12px;">Timestamp: ${new Date().toISOString()}</p>`;
|
||||||
const text = `This is a test message sent from your CRM's Sales SMTP configuration. If you received this, your SMTP credentials work.\n\nTimestamp: ${new Date().toISOString()}`;
|
const text = `This is a test message sent from your CRM's Sales SMTP configuration. If you received this, your SMTP credentials work.\n\nTimestamp: ${new Date().toISOString()}`;
|
||||||
await sendEmail(recipient, subject, html, undefined, text, ctx.portId);
|
await sendEmail(recipient, subject, html, undefined, text, ctx.portId);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const testSendSchema = z.object({
|
|||||||
* - The branding one exercises the rendering pipeline + logo bytes.
|
* - The branding one exercises the rendering pipeline + logo bytes.
|
||||||
*
|
*
|
||||||
* Surface SMTP errors to the caller directly (auth failure, ENOTFOUND,
|
* Surface SMTP errors to the caller directly (auth failure, ENOTFOUND,
|
||||||
* connection refused) — the whole point of the test is to see them
|
* connection refused) - the whole point of the test is to see them
|
||||||
* inline in the admin UI.
|
* inline in the admin UI.
|
||||||
*/
|
*/
|
||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
@@ -30,7 +30,7 @@ export const POST = withAuth(
|
|||||||
if (!ctx.portId) throw new ValidationError('No active port');
|
if (!ctx.portId) throw new ValidationError('No active port');
|
||||||
const { recipient } = await parseBody(req, testSendSchema);
|
const { recipient } = await parseBody(req, testSendSchema);
|
||||||
|
|
||||||
const subject = 'CRM SMTP test — connection verified';
|
const subject = 'CRM SMTP test - connection verified';
|
||||||
const html = `
|
const html = `
|
||||||
<div style="font-family:system-ui,-apple-system,sans-serif;font-size:14px;color:#1e293b;padding:24px;line-height:1.5;">
|
<div style="font-family:system-ui,-apple-system,sans-serif;font-size:14px;color:#1e293b;padding:24px;line-height:1.5;">
|
||||||
<h1 style="font-size:18px;margin:0 0 12px;">SMTP test</h1>
|
<h1 style="font-size:18px;margin:0 0 12px;">SMTP test</h1>
|
||||||
@@ -39,11 +39,11 @@ export const POST = withAuth(
|
|||||||
are reaching ${recipient}.
|
are reaching ${recipient}.
|
||||||
</p>
|
</p>
|
||||||
<p style="margin:0;color:#64748b;font-size:13px;">
|
<p style="margin:0;color:#64748b;font-size:13px;">
|
||||||
Sent from /admin/email — Port Nimara CRM
|
Sent from /admin/email - Port Nimara CRM
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
const text = `SMTP test\n\nIf you're reading this, the SMTP credentials configured for this port are reaching ${recipient}.\n\nSent from /admin/email — Port Nimara CRM`;
|
const text = `SMTP test\n\nIf you're reading this, the SMTP credentials configured for this port are reaching ${recipient}.\n\nSent from /admin/email - Port Nimara CRM`;
|
||||||
|
|
||||||
const info = await sendEmail(recipient, subject, html, undefined, text, ctx.portId);
|
const info = await sendEmail(recipient, subject, html, undefined, text, ctx.portId);
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
100
src/app/api/v1/admin/email/test-template/route.ts
Normal file
100
src/app/api/v1/admin/email/test-template/route.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { ports } from '@/lib/db/schema/ports';
|
||||||
|
import { sendEmail } from '@/lib/email';
|
||||||
|
import { findTestTemplate, TEST_TEMPLATES } from '@/lib/email/test-registry';
|
||||||
|
import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors';
|
||||||
|
|
||||||
|
const bodySchema = z.object({
|
||||||
|
templateId: z.string().min(1),
|
||||||
|
recipient: z.string().email(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET - return the test-template registry (id + label + description)
|
||||||
|
* so the admin UI dropdown can render without duplicating the catalog
|
||||||
|
* client-side.
|
||||||
|
*/
|
||||||
|
export const GET = withAuth(
|
||||||
|
withPermission('admin', 'manage_settings', async () => {
|
||||||
|
try {
|
||||||
|
return NextResponse.json({
|
||||||
|
data: TEST_TEMPLATES.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
label: t.label,
|
||||||
|
description: t.description,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST - render the chosen template with realistic sample fixtures and
|
||||||
|
* fire it through the configured SMTP transport. Used by admins to
|
||||||
|
* preview each transactional template against a designated address
|
||||||
|
* without triggering the real upstream flow.
|
||||||
|
*
|
||||||
|
* Permission: `admin.manage_settings` - same gate as the existing
|
||||||
|
* SMTP test-send (the port's real From / SMTP credentials are used).
|
||||||
|
*/
|
||||||
|
export const POST = withAuth(
|
||||||
|
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, bodySchema);
|
||||||
|
const template = findTestTemplate(body.templateId);
|
||||||
|
if (!template) {
|
||||||
|
throw new ValidationError(`Unknown templateId: ${body.templateId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve port branding context so the rendered email actually
|
||||||
|
// matches the admin's port (header logo, accent colour) instead of
|
||||||
|
// falling through to defaults.
|
||||||
|
const port = await db.query.ports.findFirst({ where: eq(ports.id, ctx.portId) });
|
||||||
|
if (!port) throw new NotFoundError('Port');
|
||||||
|
|
||||||
|
// No publicUrl column on `ports` yet - synthesise a plausible URL
|
||||||
|
// from the slug so the sample renders with a "real-looking" base.
|
||||||
|
const portUrl = `https://${port.slug}.example`;
|
||||||
|
const rendered = await template.render({
|
||||||
|
recipientName: 'Sample Recipient',
|
||||||
|
recipientEmail: body.recipient,
|
||||||
|
portName: port.name,
|
||||||
|
portUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subject prefix makes it visually unambiguous in the recipient's
|
||||||
|
// inbox that this is a test - important because some of the
|
||||||
|
// templates (signing reminder, etc.) would otherwise look
|
||||||
|
// identical to a real production send.
|
||||||
|
const taggedSubject = `[TEST · ${template.label}] ${rendered.subject}`;
|
||||||
|
|
||||||
|
const info = await sendEmail(
|
||||||
|
body.recipient,
|
||||||
|
taggedSubject,
|
||||||
|
rendered.html,
|
||||||
|
undefined,
|
||||||
|
rendered.text,
|
||||||
|
ctx.portId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
data: {
|
||||||
|
templateId: template.id,
|
||||||
|
recipient: body.recipient,
|
||||||
|
subject: taggedSubject,
|
||||||
|
messageId: info.messageId ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -17,8 +17,8 @@ import { logger } from '@/lib/logger';
|
|||||||
* get sent there from outbound emails.
|
* get sent there from outbound emails.
|
||||||
*
|
*
|
||||||
* Two checks:
|
* Two checks:
|
||||||
* 1. Bare host returns 2xx — the site is up.
|
* 1. Bare host returns 2xx - the site is up.
|
||||||
* 2. `/sign/health` (or `/`) returns 2xx within 5s — soft probe; not
|
* 2. `/sign/health` (or `/`) returns 2xx within 5s - soft probe; not
|
||||||
* every marketing site exposes /sign/health, so we degrade to a
|
* every marketing site exposes /sign/health, so we degrade to a
|
||||||
* root probe when the dedicated path 404s.
|
* root probe when the dedicated path 404s.
|
||||||
*/
|
*/
|
||||||
@@ -60,7 +60,7 @@ export const POST = withAuth(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Try root first — it's the most universal signal of "the site is
|
// Try root first - it's the most universal signal of "the site is
|
||||||
// up." Then probe /sign/success which the post-signing redirect
|
// up." Then probe /sign/success which the post-signing redirect
|
||||||
// typically points to, so admins can also catch a stale path.
|
// typically points to, so admins can also catch a stale path.
|
||||||
await probe('/');
|
await probe('/');
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const GET = withAuth(
|
|||||||
if (!event) throw new NotFoundError('Error event');
|
if (!event) throw new NotFoundError('Error event');
|
||||||
|
|
||||||
// Tenant scoping. A port_id of null on the row means the error
|
// Tenant scoping. A port_id of null on the row means the error
|
||||||
// fired pre-port-resolve (login page, public form, etc.) — those
|
// fired pre-port-resolve (login page, public form, etc.) - those
|
||||||
// are visible to super admins only.
|
// are visible to super admins only.
|
||||||
if (!ctx.isSuperAdmin) {
|
if (!ctx.isSuperAdmin) {
|
||||||
if (!event.portId || event.portId !== ctx.portId) {
|
if (!event.portId || event.portId !== ctx.portId) {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const GET = withAuth(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mutations on global roles are super-admin-only — see route.ts header.
|
// Mutations on global roles are super-admin-only - see route.ts header.
|
||||||
export const PATCH = withAuth(async (req, ctx, params) => {
|
export const PATCH = withAuth(async (req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
requireSuperAdmin(ctx, 'roles.update');
|
requireSuperAdmin(ctx, 'roles.update');
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const GET = withAuth(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Roles are global (no port_id) and assignments span every port via
|
// Roles are global (no port_id) and assignments span every port via
|
||||||
// userPortRoles, so creation must be super-admin-only — a per-port admin
|
// userPortRoles, so creation must be super-admin-only - a per-port admin
|
||||||
// holding admin.manage_users must never be able to mint a role that lives
|
// holding admin.manage_users must never be able to mint a role that lives
|
||||||
// in another tenant.
|
// in another tenant.
|
||||||
export const POST = withAuth(async (req, ctx) => {
|
export const POST = withAuth(async (req, ctx) => {
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ import { getSetting } from '@/lib/settings/resolver';
|
|||||||
* form so the operator can verify what they saved earlier.
|
* form so the operator can verify what they saved earlier.
|
||||||
*
|
*
|
||||||
* Gated on `admin.manage_settings` (the same permission required to write
|
* Gated on `admin.manage_settings` (the same permission required to write
|
||||||
* the value — so this never widens an existing trust boundary). Every
|
* the value - so this never widens an existing trust boundary). Every
|
||||||
* reveal is audit-logged with the request id so a super-admin can trace
|
* reveal is audit-logged with the request id so a super-admin can trace
|
||||||
* who looked at what and when.
|
* who looked at what and when.
|
||||||
*
|
*
|
||||||
* Refuses to reveal values resolved from `env` or `default` — those would
|
* Refuses to reveal values resolved from `env` or `default` - those would
|
||||||
* leak server-process secrets via the API.
|
* leak server-process secrets via the API.
|
||||||
*/
|
*/
|
||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ import { resolveForAdminAPI } from '@/lib/settings/resolver';
|
|||||||
* Returns the resolved value + source (port/global/env/default) for every
|
* Returns the resolved value + source (port/global/env/default) for every
|
||||||
* requested registry entry. Drives both the registry-driven admin form
|
* requested registry entry. Drives both the registry-driven admin form
|
||||||
* (sections param) and the onboarding-checklist auto-detection (keys
|
* (sections param) and the onboarding-checklist auto-detection (keys
|
||||||
* param) — both need port→global→env→default resolution rather than the
|
* param) - both need port→global→env→default resolution rather than the
|
||||||
* raw `/admin/settings` rows (which only show DB writes).
|
* raw `/admin/settings` rows (which only show DB writes).
|
||||||
*
|
*
|
||||||
* Either parameter is supported; if both are present the sets union.
|
* Either parameter is supported; if both are present the sets union.
|
||||||
* Sensitive fields surface `isSet` only — never the decrypted value.
|
* Sensitive fields surface `isSet` only - never the decrypted value.
|
||||||
*/
|
*/
|
||||||
export const GET = withAuth(
|
export const GET = withAuth(
|
||||||
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
||||||
@@ -55,7 +55,7 @@ export const GET = withAuth(
|
|||||||
|
|
||||||
// Return the entry metadata so the client can render labels/types
|
// Return the entry metadata so the client can render labels/types
|
||||||
// without bundling the registry into the client JS. Strip the
|
// without bundling the registry into the client JS. Strip the
|
||||||
// `validator` + `transform` function references — they're not
|
// `validator` + `transform` function references - they're not
|
||||||
// JSON-serializable.
|
// JSON-serializable.
|
||||||
const entriesForClient = entries.map((e) => ({
|
const entriesForClient = entries.map((e) => ({
|
||||||
key: e.key,
|
key: e.key,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Admin storage status + connection test. Super-admin only.
|
* Admin storage status + connection test. Super-admin only.
|
||||||
*
|
*
|
||||||
* GET /api/v1/admin/storage — current backend + capacity stats
|
* GET /api/v1/admin/storage - current backend + capacity stats
|
||||||
* POST /api/v1/admin/storage/test — exercise list/put/get/delete on s3
|
* POST /api/v1/admin/storage/test - exercise list/put/get/delete on s3
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
*
|
*
|
||||||
* PUT accepts a Partial<RolePermissions> map (use null at a leaf to clear an
|
* PUT accepts a Partial<RolePermissions> map (use null at a leaf to clear an
|
||||||
* override) and upserts it onto user_permission_overrides for (userId, portId).
|
* override) and upserts it onto user_permission_overrides for (userId, portId).
|
||||||
* Permission `admin.manage_users` is required — same gate as the user-edit
|
* Permission `admin.manage_users` is required - same gate as the user-edit
|
||||||
* drawer that hosts the matrix.
|
* drawer that hosts the matrix.
|
||||||
*/
|
*/
|
||||||
import { and, eq } from 'drizzle-orm';
|
import { and, eq } from 'drizzle-orm';
|
||||||
@@ -85,7 +85,7 @@ const ALLOWED_RESOURCE_ACTIONS: Record<string, Set<string>> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updateOverridesSchema = z.object({
|
const updateOverridesSchema = z.object({
|
||||||
/** Partial<RolePermissions> — passthrough JSON. Validated structurally
|
/** Partial<RolePermissions> - passthrough JSON. Validated structurally
|
||||||
* by limiting depth + leaf type below. */
|
* by limiting depth + leaf type below. */
|
||||||
overrides: z.record(z.string(), z.record(z.string(), z.boolean())).default({}),
|
overrides: z.record(z.string(), z.record(z.string(), z.boolean())).default({}),
|
||||||
});
|
});
|
||||||
@@ -121,7 +121,7 @@ export const GET = withAuth(
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
if (baseline && portOverride?.permissionOverrides) {
|
if (baseline && portOverride?.permissionOverrides) {
|
||||||
// Cheap structural merge — same shape as helpers.ts's deepMerge.
|
// Cheap structural merge - same shape as helpers.ts's deepMerge.
|
||||||
baseline = mergePerms(baseline, portOverride.permissionOverrides);
|
baseline = mergePerms(baseline, portOverride.permissionOverrides);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,7 +162,7 @@ export const PUT = withAuth(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reject overrides for users that aren't actually assigned to this
|
// Reject overrides for users that aren't actually assigned to this
|
||||||
// port — prevents cross-tenant pollution where an admin in port A
|
// port - prevents cross-tenant pollution where an admin in port A
|
||||||
// writes a row keyed on (userIdFromPortB, portA). The withAuth
|
// writes a row keyed on (userIdFromPortB, portA). The withAuth
|
||||||
// resolver scopes lookups to the caller's port so the row would
|
// resolver scopes lookups to the caller's port so the row would
|
||||||
// never apply, but it still consumes a unique slot and confuses
|
// never apply, but it still consumes a unique slot and confuses
|
||||||
@@ -183,7 +183,7 @@ export const PUT = withAuth(
|
|||||||
// honour.
|
// honour.
|
||||||
// CALLER-SUPERSET (authz-auditor CRITICAL): an admin with only
|
// CALLER-SUPERSET (authz-auditor CRITICAL): an admin with only
|
||||||
// `admin.manage_users` previously could grant another user any
|
// `admin.manage_users` previously could grant another user any
|
||||||
// permission leaf — including ones they don't hold themselves
|
// permission leaf - including ones they don't hold themselves
|
||||||
// (e.g. `permanently_delete_clients`, `system_backup`). Require
|
// (e.g. `permanently_delete_clients`, `system_backup`). Require
|
||||||
// every `true` write to be a leaf the caller already has.
|
// every `true` write to be a leaf the caller already has.
|
||||||
// Super-admins bypass (they hold all leaves by definition).
|
// Super-admins bypass (they hold all leaves by definition).
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { errorResponse } from '@/lib/errors';
|
|||||||
* slot). Returns only the fields needed to render an option: id, email,
|
* slot). Returns only the fields needed to render an option: id, email,
|
||||||
* name. Excludes deactivated users.
|
* name. Excludes deactivated users.
|
||||||
*
|
*
|
||||||
* Gated on `admin.manage_settings` — anyone editing per-port admin
|
* Gated on `admin.manage_settings` - anyone editing per-port admin
|
||||||
* settings can already see all the configured Documenso recipient
|
* settings can already see all the configured Documenso recipient
|
||||||
* email/name values, so revealing the user roster to them doesn't
|
* email/name values, so revealing the user roster to them doesn't
|
||||||
* widen the trust boundary. Tighter than the full `admin/users` GET
|
* widen the trust boundary. Tighter than the full `admin/users` GET
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { parseBody } from '@/lib/api/route-helpers';
|
|||||||
import { requestDraftSchema } from '@/lib/validators/ai';
|
import { requestDraftSchema } from '@/lib/validators/ai';
|
||||||
import { CodedError, errorResponse } from '@/lib/errors';
|
import { CodedError, errorResponse } from '@/lib/errors';
|
||||||
|
|
||||||
// Gated on `email.send` — the draft endpoint spends OpenAI tokens and
|
// Gated on `email.send` - the draft endpoint spends OpenAI tokens and
|
||||||
// renders client/interest-scoped content; only roles permitted to send
|
// renders client/interest-scoped content; only roles permitted to send
|
||||||
// emails should be able to mint drafts (auditor-A3 §7).
|
// emails should be able to mint drafts (auditor-A3 §7).
|
||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { listAlertsForPort } from '@/lib/services/alerts.service';
|
|||||||
type AlertStatus = 'open' | 'dismissed' | 'resolved';
|
type AlertStatus = 'open' | 'dismissed' | 'resolved';
|
||||||
|
|
||||||
// Tier-4 (authz-auditor): alerts include permission_denied + audit-adjacent
|
// Tier-4 (authz-auditor): alerts include permission_denied + audit-adjacent
|
||||||
// signals. Gated on admin.view_audit_log — same permission the audit log
|
// signals. Gated on admin.view_audit_log - same permission the audit log
|
||||||
// page uses.
|
// page uses.
|
||||||
export const GET = withAuth(
|
export const GET = withAuth(
|
||||||
withPermission('admin', 'view_audit_log', async (req: NextRequest, ctx) => {
|
withPermission('admin', 'view_audit_log', async (req: NextRequest, ctx) => {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { listDealDocumentsForBerth } from '@/lib/services/documents.service';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/v1/berths/[id]/interest-documents (renamed from
|
* GET /api/v1/berths/[id]/interest-documents (renamed from
|
||||||
* `/deal-documents` in the 2026-05-14 terminology sweep — canonical
|
* `/deal-documents` in the 2026-05-14 terminology sweep - canonical
|
||||||
* noun is "interest").
|
* noun is "interest").
|
||||||
*
|
*
|
||||||
* Lists documents attached to interests currently linked to this berth.
|
* Lists documents attached to interests currently linked to this berth.
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { getStorageBackend } from '@/lib/storage';
|
|||||||
|
|
||||||
const postBodySchema = z.object({
|
const postBodySchema = z.object({
|
||||||
fileName: z.string().min(1).max(255),
|
fileName: z.string().min(1).max(255),
|
||||||
/** Size hint in bytes — used to early-reject oversized uploads before we
|
/** Size hint in bytes - used to early-reject oversized uploads before we
|
||||||
* burn a presigned URL. */
|
* burn a presigned URL. */
|
||||||
sizeBytes: z.number().int().nonnegative().optional(),
|
sizeBytes: z.number().int().nonnegative().optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export const getHandler: RouteHandler = async (_req, ctx, params) => {
|
|||||||
// and pdf-upload-url tenant-scopes the berth lookup. Without this regex,
|
// and pdf-upload-url tenant-scopes the berth lookup. Without this regex,
|
||||||
// a rep with berths.edit could ship the storage key of a foreign-port
|
// a rep with berths.edit could ship the storage key of a foreign-port
|
||||||
// PDF (signed EOI, brochure blob, another port's berth) and have the
|
// PDF (signed EOI, brochure blob, another port's berth) and have the
|
||||||
// service repoint THIS berth's currentPdfVersionId at it — subsequent
|
// service repoint THIS berth's currentPdfVersionId at it - subsequent
|
||||||
// pdf-download serves those bytes under the rep's own permission gate.
|
// pdf-download serves those bytes under the rep's own permission gate.
|
||||||
const STORAGE_KEY_RE =
|
const STORAGE_KEY_RE =
|
||||||
/^berths\/[A-Za-z0-9_-]+\/uploads\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}_/;
|
/^berths\/[A-Za-z0-9_-]+\/uploads\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}_/;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { bulkAddBerthsSchema } from '@/lib/validators/berths';
|
|||||||
*/
|
*/
|
||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
// F13: aligned with the seed-permissions scope (`berths.import`).
|
// F13: aligned with the seed-permissions scope (`berths.import`).
|
||||||
// The previous `berths.create` was a phantom key — not in the role
|
// The previous `berths.create` was a phantom key - not in the role
|
||||||
// matrix, so non-super-admins silently failed permission resolution.
|
// matrix, so non-super-admins silently failed permission resolution.
|
||||||
withPermission('berths', 'import', async (req, ctx) => {
|
withPermission('berths', 'import', async (req, ctx) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { errorResponse } from '@/lib/errors';
|
|||||||
* Gated by `berths.update_prices`. Returns counts so the UI can present
|
* Gated by `berths.update_prices`. Returns counts so the UI can present
|
||||||
* "Updated N · Unchanged M · Missing K" feedback.
|
* "Updated N · Unchanged M · Missing K" feedback.
|
||||||
*
|
*
|
||||||
* Audit: one `audit_log` row per actually-updated berth (idempotent —
|
* Audit: one `audit_log` row per actually-updated berth (idempotent -
|
||||||
* berths whose new price matches the existing value are skipped and
|
* berths whose new price matches the existing value are skipped and
|
||||||
* counted as `unchanged`).
|
* counted as `unchanged`).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Synchronous bulk endpoint for the berths list — mirrors the
|
* Synchronous bulk endpoint for the berths list - mirrors the
|
||||||
* /api/v1/interests/bulk shape so the rep-facing UX is consistent.
|
* /api/v1/interests/bulk shape so the rep-facing UX is consistent.
|
||||||
*
|
*
|
||||||
* Per-row loop with a 500-id cap. Bigger jobs belong on the BullMQ
|
* Per-row loop with a 500-id cap. Bigger jobs belong on the BullMQ
|
||||||
@@ -58,7 +58,7 @@ interface RowResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Berths share a single `edit` permission for non-price mutations (no
|
// Berths share a single `edit` permission for non-price mutations (no
|
||||||
// separate `archive` perm today — sales-manager + super-admin own all
|
// separate `archive` perm today - sales-manager + super-admin own all
|
||||||
// edit paths).
|
// edit paths).
|
||||||
const PERMISSION_BY_ACTION: Record<
|
const PERMISSION_BY_ACTION: Record<
|
||||||
z.infer<typeof bulkSchema>['action'],
|
z.infer<typeof bulkSchema>['action'],
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const checkSchema = z.object({
|
|||||||
* surfacing the constraint violation at submit time.
|
* surfacing the constraint violation at submit time.
|
||||||
*
|
*
|
||||||
* Format validation mirrors the CLAUDE.md canonical (`^[A-Z]+\d+$`).
|
* Format validation mirrors the CLAUDE.md canonical (`^[A-Z]+\d+$`).
|
||||||
* Archived berths are excluded — bulk-add re-using a previously-archived
|
* Archived berths are excluded - bulk-add re-using a previously-archived
|
||||||
* mooring number is a legitimate flow.
|
* mooring number is a legitimate flow.
|
||||||
*
|
*
|
||||||
* Permission gating: `berths.import` (same scope as bulk-add itself).
|
* Permission gating: `berths.import` (same scope as bulk-add itself).
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { errorResponse } from '@/lib/errors';
|
|||||||
/**
|
/**
|
||||||
* GET /api/v1/bootstrap/status
|
* GET /api/v1/bootstrap/status
|
||||||
*
|
*
|
||||||
* PUBLIC — no auth required. Used by the /setup and /login pages to
|
* PUBLIC - no auth required. Used by the /setup and /login pages to
|
||||||
* decide which screen to show on first visit. Returns only a single
|
* decide which screen to show on first visit. Returns only a single
|
||||||
* boolean to keep the response small and minimize info leakage.
|
* boolean to keep the response small and minimize info leakage.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const bodySchema = z.object({
|
|||||||
/**
|
/**
|
||||||
* POST /api/v1/bootstrap/super-admin
|
* POST /api/v1/bootstrap/super-admin
|
||||||
*
|
*
|
||||||
* PUBLIC — no auth required, but bound by a single-shot precondition:
|
* PUBLIC - no auth required, but bound by a single-shot precondition:
|
||||||
* refuses to run when a super-admin already exists. Idempotently safe:
|
* refuses to run when a super-admin already exists. Idempotently safe:
|
||||||
* the service double-checks the precondition before insert, so two
|
* the service double-checks the precondition before insert, so two
|
||||||
* racing first-run requests can't both create accounts.
|
* racing first-run requests can't both create accounts.
|
||||||
@@ -26,7 +26,7 @@ export async function POST(req: NextRequest) {
|
|||||||
// atomically before the insert.
|
// atomically before the insert.
|
||||||
if (await hasAnySuperAdmin()) {
|
if (await hasAnySuperAdmin()) {
|
||||||
throw new ConflictError(
|
throw new ConflictError(
|
||||||
'A super-administrator account already exists — first-run setup is closed.',
|
'A super-administrator account already exists - first-run setup is closed.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const body = await parseBody(req, bodySchema);
|
const body = await parseBody(req, bodySchema);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { errorResponse } from '@/lib/errors';
|
|||||||
import { promoteContactToPrimary } from '@/lib/services/clients.service';
|
import { promoteContactToPrimary } from '@/lib/services/clients.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase 3d — promote a non-primary `client_contacts` row to primary,
|
* Phase 3d - promote a non-primary `client_contacts` row to primary,
|
||||||
* demoting the prior primary for the same channel inside a single
|
* demoting the prior primary for the same channel inside a single
|
||||||
* transaction. Surfaces from the "[EOI] Set as primary" action on the
|
* transaction. Surfaces from the "[EOI] Set as primary" action on the
|
||||||
* client detail panel, and from the EOI dialog's "Set as default for
|
* client detail panel, and from the EOI dialog's "Set as default for
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { createAuditLog } from '@/lib/audit';
|
|||||||
* Returns a fresh signed URL for an existing GDPR export. Staff use this
|
* Returns a fresh signed URL for an existing GDPR export. Staff use this
|
||||||
* from the admin UI; the email path embeds its own signed URL.
|
* from the admin UI; the email path embeds its own signed URL.
|
||||||
*
|
*
|
||||||
* Every call writes a `view` audit row at 'warning' severity — GDPR
|
* Every call writes a `view` audit row at 'warning' severity - GDPR
|
||||||
* exports contain the entire personal data of a client and a fresh
|
* exports contain the entire personal data of a client and a fresh
|
||||||
* presigned URL would let the operator download it; we want a clear
|
* presigned URL would let the operator download it; we want a clear
|
||||||
* trail of who pulled what when.
|
* trail of who pulled what when.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { errorResponse, NotFoundError } from '@/lib/errors';
|
|||||||
* `clients.delete` (the standard archive permission) is enforced by the
|
* `clients.delete` (the standard archive permission) is enforced by the
|
||||||
* route wrapper; the service additionally requires the client to be
|
* route wrapper; the service additionally requires the client to be
|
||||||
* archived. The dedicated `admin.permanently_delete_clients` flag is
|
* archived. The dedicated `admin.permanently_delete_clients` flag is
|
||||||
* checked by the partner /hard-delete route — see route comment there.
|
* checked by the partner /hard-delete route - see route comment there.
|
||||||
*/
|
*/
|
||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
withPermission(
|
withPermission(
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { errorResponse, NotFoundError } from '@/lib/errors';
|
|||||||
*
|
*
|
||||||
* Backwards-compat: clients archived before the smart-archive feature
|
* Backwards-compat: clients archived before the smart-archive feature
|
||||||
* have no archive_metadata. The dossier returns empty arrays in that
|
* have no archive_metadata. The dossier returns empty arrays in that
|
||||||
* case, and a POST with no body simply un-archives them — same effect
|
* case, and a POST with no body simply un-archives them - same effect
|
||||||
* as the old endpoint.
|
* as the old endpoint.
|
||||||
*/
|
*/
|
||||||
const restoreSchema = z.object({
|
const restoreSchema = z.object({
|
||||||
@@ -32,7 +32,7 @@ export const POST = withAuth(
|
|||||||
try {
|
try {
|
||||||
body = await parseBody(req, restoreSchema);
|
body = await parseBody(req, restoreSchema);
|
||||||
} catch {
|
} catch {
|
||||||
// Empty / non-JSON body — defaults are fine.
|
// Empty / non-JSON body - defaults are fine.
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await restoreClientWithSelections({
|
const result = await restoreClientWithSelections({
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export const POST = withAuth(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Generic blocker text — never include the inner error so an
|
// Generic blocker text - never include the inner error so an
|
||||||
// attacker can't distinguish "not found" from "in another port"
|
// attacker can't distinguish "not found" from "in another port"
|
||||||
// by enumerating UUIDs (audit R2-M9). The operator already
|
// by enumerating UUIDs (audit R2-M9). The operator already
|
||||||
// selected these IDs so they don't need to know the cause.
|
// selected these IDs so they don't need to know the cause.
|
||||||
@@ -59,7 +59,7 @@ export const POST = withAuth(
|
|||||||
fullName: '(unknown)',
|
fullName: '(unknown)',
|
||||||
stakeLevel: 'low',
|
stakeLevel: 'low',
|
||||||
highStakesStage: null,
|
highStakesStage: null,
|
||||||
blockers: ['Could not load dossier — client may have been removed'],
|
blockers: ['Could not load dossier - client may have been removed'],
|
||||||
summary: { berths: 0, yachts: 0, reservations: 0, signedDocs: 0 },
|
summary: { berths: 0, yachts: 0, reservations: 0, signedDocs: 0 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export const POST = withAuth(async (req, ctx) => {
|
|||||||
const reason = perClientReason ?? 'Bulk archive (low-stakes auto-mode)';
|
const reason = perClientReason ?? 'Bulk archive (low-stakes auto-mode)';
|
||||||
// Pick the berth's first linked interest from the dossier
|
// Pick the berth's first linked interest from the dossier
|
||||||
// (authoritative interest_berths join). Berths with no linked
|
// (authoritative interest_berths join). Berths with no linked
|
||||||
// interest for this client are dropped — emitting an empty
|
// interest for this client are dropped - emitting an empty
|
||||||
// interestId causes the delete to silently match zero rows
|
// interestId causes the delete to silently match zero rows
|
||||||
// (audit R2-H3).
|
// (audit R2-H3).
|
||||||
const berthDecisions = dossier.berths
|
const berthDecisions = dossier.berths
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export async function getMatchCandidatesHandler(
|
|||||||
interestsByClient.set(r.clientId, (interestsByClient.get(r.clientId) ?? 0) + 1);
|
interestsByClient.set(r.clientId, (interestsByClient.get(r.clientId) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a lookup from the original pool for archived flag — the dedup
|
// Build a lookup from the original pool for archived flag - the dedup
|
||||||
// candidate type intentionally doesn't carry it, but the suggestion card
|
// candidate type intentionally doesn't carry it, but the suggestion card
|
||||||
// needs to differentiate "use this live client" from "restore this
|
// needs to differentiate "use this live client" from "restore this
|
||||||
// archived client". Without this the UX swallows soft-deleted dupes.
|
// archived client". Without this the UX swallows soft-deleted dupes.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { parseRangeSlug, rangeToBounds } from '@/lib/analytics/range';
|
|||||||
* GET /api/v1/dashboard/forecast
|
* GET /api/v1/dashboard/forecast
|
||||||
* GET /api/v1/dashboard/forecast?range=7d|30d|90d|today|custom-<from>-<to>
|
* GET /api/v1/dashboard/forecast?range=7d|30d|90d|today|custom-<from>-<to>
|
||||||
*
|
*
|
||||||
* Same range semantics as /kpis — the weighted forecast scopes to
|
* Same range semantics as /kpis - the weighted forecast scopes to
|
||||||
* interests whose createdAt falls inside the window when range is set,
|
* interests whose createdAt falls inside the window when range is set,
|
||||||
* or all-time when not.
|
* or all-time when not.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ import {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH supports either { name } (rename) or { parentId } (move).
|
* PATCH supports either { name } (rename) or { parentId } (move).
|
||||||
* Refuses both in the same body — keeps the audit log clean
|
* Refuses both in the same body - keeps the audit log clean
|
||||||
* (one operation per call) and prevents the rep from accidentally
|
* (one operation per call) and prevents the rep from accidentally
|
||||||
* doing two unrelated changes in one click.
|
* doing two unrelated changes in one click.
|
||||||
*/
|
*/
|
||||||
// `.strict()` on each branch so a body with BOTH name and parentId is
|
// `.strict()` on each branch so a body with BOTH name and parentId is
|
||||||
// rejected by both members and the union produces a 400 — without it,
|
// rejected by both members and the union produces a 400 - without it,
|
||||||
// z.union silently picks the first match and drops the other key,
|
// z.union silently picks the first match and drops the other key,
|
||||||
// which would let a rename request silently swallow a move attempt.
|
// which would let a rename request silently swallow a move attempt.
|
||||||
const patchBodySchema = z.union([renameFolderSchema.strict(), moveFolderSchema.strict()]);
|
const patchBodySchema = z.union([renameFolderSchema.strict(), moveFolderSchema.strict()]);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { listTree, createFolder } from '@/lib/services/document-folders.service'
|
|||||||
*
|
*
|
||||||
* Returns the entire folder tree for the caller's port. Roots come
|
* Returns the entire folder tree for the caller's port. Roots come
|
||||||
* back at the top level with `children` nested. Cached on the client
|
* back at the top level with `children` nested. Cached on the client
|
||||||
* via TanStack — folders change rarely; the manager mutations
|
* via TanStack - folders change rarely; the manager mutations
|
||||||
* invalidate the query.
|
* invalidate the query.
|
||||||
*
|
*
|
||||||
* Permission: documents.view (read-only; everyone in the port can
|
* Permission: documents.view (read-only; everyone in the port can
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { getStorageBackend } from '@/lib/storage';
|
|||||||
import { detectFields } from '@/lib/services/document-field-detector';
|
import { detectFields } from '@/lib/services/document-field-detector';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase 4 — Auto-detect signature/date/initials/name/email anchors in the
|
* Phase 4 - Auto-detect signature/date/initials/name/email anchors in the
|
||||||
* template's current source PDF and return suggested field placements.
|
* template's current source PDF and return suggested field placements.
|
||||||
*
|
*
|
||||||
* The detector (`src/lib/services/document-field-detector.ts`) scans each
|
* The detector (`src/lib/services/document-field-detector.ts`) scans each
|
||||||
@@ -18,7 +18,7 @@ import { detectFields } from '@/lib/services/document-field-detector';
|
|||||||
* coords (0..100 of page dimensions), which the editor converts to its
|
* coords (0..100 of page dimensions), which the editor converts to its
|
||||||
* own 0..1 marker coords before adding to the field map.
|
* own 0..1 marker coords before adding to the field map.
|
||||||
*
|
*
|
||||||
* Permission: `admin.manage_settings` — same gate as the editor itself.
|
* Permission: `admin.manage_settings` - same gate as the editor itself.
|
||||||
*/
|
*/
|
||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
withPermission('admin', 'manage_settings', async (_req, ctx, params) => {
|
withPermission('admin', 'manage_settings', async (_req, ctx, params) => {
|
||||||
@@ -29,7 +29,7 @@ export const POST = withAuth(
|
|||||||
if (!template) throw new NotFoundError('Template');
|
if (!template) throw new NotFoundError('Template');
|
||||||
if (!template.sourceFileId) {
|
if (!template.sourceFileId) {
|
||||||
throw new ValidationError(
|
throw new ValidationError(
|
||||||
'Template has no source PDF — upload one first via the Replace PDF button',
|
'Template has no source PDF - upload one first via the Replace PDF button',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ export const POST = withAuth(
|
|||||||
throw new NotFoundError('Source PDF file row missing');
|
throw new NotFoundError('Source PDF file row missing');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read the PDF blob from storage. Buffer the whole stream — the
|
// Read the PDF blob from storage. Buffer the whole stream - the
|
||||||
// detector needs a contiguous Buffer for pdfjs-dist, and template
|
// detector needs a contiguous Buffer for pdfjs-dist, and template
|
||||||
// source PDFs are capped at 10MB by the source-pdf upload route.
|
// source PDFs are capped at 10MB by the source-pdf upload route.
|
||||||
const backend = await getStorageBackend();
|
const backend = await getStorageBackend();
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ const previewBodySchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase 7.2 — live preview endpoint for the PDF editor.
|
* Phase 7.2 - live preview endpoint for the PDF editor.
|
||||||
*
|
*
|
||||||
* Generates a transient EOI PDF against the supplied interest using the
|
* Generates a transient EOI PDF against the supplied interest using the
|
||||||
* template's current source PDF + overlay markers, uploads it to a
|
* template's current source PDF + overlay markers, uploads it to a
|
||||||
* scratch storage key, and returns a 15-minute presigned download URL.
|
* scratch storage key, and returns a 15-minute presigned download URL.
|
||||||
*
|
*
|
||||||
* The blob is intentionally not linked to a `files` row — preview PDFs
|
* The blob is intentionally not linked to a `files` row - preview PDFs
|
||||||
* are throwaway. The storage backend's lifecycle policy (TTL on
|
* are throwaway. The storage backend's lifecycle policy (TTL on
|
||||||
* `previews/` prefix) cleans them up; in dev the filesystem backend
|
* `previews/` prefix) cleans them up; in dev the filesystem backend
|
||||||
* just accumulates them, which is acceptable for the editor workflow.
|
* just accumulates them, which is acceptable for the editor workflow.
|
||||||
@@ -39,7 +39,7 @@ export const POST = withAuth(
|
|||||||
});
|
});
|
||||||
if (!template) throw new NotFoundError('Template');
|
if (!template) throw new NotFoundError('Template');
|
||||||
if (template.templateType !== 'eoi') {
|
if (template.templateType !== 'eoi') {
|
||||||
// Live preview is currently EOI-only — that's where the
|
// Live preview is currently EOI-only - that's where the
|
||||||
// editor's overlay-positions flow into rendering. Other
|
// editor's overlay-positions flow into rendering. Other
|
||||||
// template types are deferred (no in-app fill yet).
|
// template types are deferred (no in-app fill yet).
|
||||||
throw new ValidationError(
|
throw new ValidationError(
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const MAX_PDF_BYTES = 10 * 1024 * 1024;
|
|||||||
const PDF_MAGIC = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d]); // "%PDF-"
|
const PDF_MAGIC = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d]); // "%PDF-"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase 7.2 — replace the template's source PDF while preserving the
|
* Phase 7.2 - replace the template's source PDF while preserving the
|
||||||
* field map. The existing `overlay_positions` is kept exactly as-is;
|
* field map. The existing `overlay_positions` is kept exactly as-is;
|
||||||
* the client warns when the new page count truncates the previous set
|
* the client warns when the new page count truncates the previous set
|
||||||
* (markers on now-orphaned pages are invisible at render time).
|
* (markers on now-orphaned pages are invisible at render time).
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ const cancelBodySchema = z
|
|||||||
.object({
|
.object({
|
||||||
reason: z.string().max(2000).optional().nullable(),
|
reason: z.string().max(2000).optional().nullable(),
|
||||||
notifyRecipients: z.array(z.string().uuid()).max(20).optional(),
|
notifyRecipients: z.array(z.string().uuid()).max(20).optional(),
|
||||||
|
/**
|
||||||
|
* Whether to also DELETE the document from Documenso. `delete` (the
|
||||||
|
* default) frees the upstream envelope slot - useful for unclogging
|
||||||
|
* the Documenso log when a draft was abandoned. `keep_remote`
|
||||||
|
* leaves the envelope intact for audit purposes; only the local
|
||||||
|
* row is marked `cancelled`. Audit-trail copy on the cancelled-doc
|
||||||
|
* badge changes accordingly.
|
||||||
|
*/
|
||||||
|
cancelMode: z.enum(['delete', 'keep_remote']).optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional();
|
.optional();
|
||||||
@@ -17,7 +26,7 @@ const cancelBodySchema = z
|
|||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
withPermission('documents', 'edit', async (req, ctx, params) => {
|
withPermission('documents', 'edit', async (req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
// Body is optional — legacy callers POST with `{}`. parseBody returns
|
// Body is optional - legacy callers POST with `{}`. parseBody returns
|
||||||
// null when the request has no body; default to empty options.
|
// null when the request has no body; default to empty options.
|
||||||
let body: z.infer<typeof cancelBodySchema> = undefined;
|
let body: z.infer<typeof cancelBodySchema> = undefined;
|
||||||
try {
|
try {
|
||||||
@@ -37,6 +46,7 @@ export const POST = withAuth(
|
|||||||
{
|
{
|
||||||
reason: body?.reason ?? null,
|
reason: body?.reason ?? null,
|
||||||
notifyRecipients: body?.notifyRecipients ?? [],
|
notifyRecipients: body?.notifyRecipients ?? [],
|
||||||
|
cancelMode: body?.cancelMode ?? 'delete',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return NextResponse.json({ data: doc });
|
return NextResponse.json({ data: doc });
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* Lookup is keyed off the doc id; the slug embeds the current folder path +
|
* Lookup is keyed off the doc id; the slug embeds the current folder path +
|
||||||
* filename so a forwarded link reads like `Deals 2026/Q1/contract.pdf` even
|
* filename so a forwarded link reads like `Deals 2026/Q1/contract.pdf` even
|
||||||
* though the underlying storage key is a UUID. The slug is rebuilt from
|
* though the underlying storage key is a UUID. The slug is rebuilt from
|
||||||
* current state and compared with the supplied path — a stale or
|
* current state and compared with the supplied path - a stale or
|
||||||
* hand-edited URL 404s rather than silently serving the wrong file.
|
* hand-edited URL 404s rather than silently serving the wrong file.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { createAuditLog } from '@/lib/audit';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-document move endpoint. Moving a single document is a deliberate
|
* Per-document move endpoint. Moving a single document is a deliberate
|
||||||
* user action so we DO bump `updated_at` here — different semantics from
|
* user action so we DO bump `updated_at` here - different semantics from
|
||||||
* the bulk soft-rescue in `deleteFolderSoftRescue` where the timestamp
|
* the bulk soft-rescue in `deleteFolderSoftRescue` where the timestamp
|
||||||
* stays put because reps did not act on the individual documents.
|
* stays put because reps did not act on the individual documents.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { getPortDocumensoConfig } from '@/lib/services/port-config';
|
|||||||
import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors';
|
import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors';
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
/** Optional — defaults to the next pending signer in signing-order. */
|
/** Optional - defaults to the next pending signer in signing-order. */
|
||||||
recipientId: z.string().optional(),
|
recipientId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ export const POST = withAuth(
|
|||||||
|
|
||||||
// Self-heal flow when target.signingUrl is null. Two scenarios:
|
// Self-heal flow when target.signingUrl is null. Two scenarios:
|
||||||
// 1. Envelope was created before the auto-distribute fix shipped
|
// 1. Envelope was created before the auto-distribute fix shipped
|
||||||
// — never distributed, so we must call /envelope/distribute
|
// - never distributed, so we must call /envelope/distribute
|
||||||
// to mint URLs.
|
// to mint URLs.
|
||||||
// 2. Envelope WAS auto-distributed at generate time, but the
|
// 2. Envelope WAS auto-distributed at generate time, but the
|
||||||
// response we got didn't carry signingUrls into our DB row
|
// response we got didn't carry signingUrls into our DB row
|
||||||
@@ -74,7 +74,7 @@ export const POST = withAuth(
|
|||||||
// Defensive flow: try `getEnvelope` FIRST (cheap, always works).
|
// Defensive flow: try `getEnvelope` FIRST (cheap, always works).
|
||||||
// If recipients carry signingUrls, persist + skip distribute.
|
// If recipients carry signingUrls, persist + skip distribute.
|
||||||
// If not, fall through to distribute, but catch 4xx so we don't
|
// If not, fall through to distribute, but catch 4xx so we don't
|
||||||
// surface a confusing "Documenso upstream error" to the rep —
|
// surface a confusing "Documenso upstream error" to the rep -
|
||||||
// instead we re-fetch via GET one more time and accept whatever
|
// instead we re-fetch via GET one more time and accept whatever
|
||||||
// URLs the envelope has.
|
// URLs the envelope has.
|
||||||
if (!target.signingUrl && doc.documensoId) {
|
if (!target.signingUrl && doc.documensoId) {
|
||||||
@@ -116,7 +116,7 @@ export const POST = withAuth(
|
|||||||
recovered = true;
|
recovered = true;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore — fall through to distribute attempt
|
// ignore - fall through to distribute attempt
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: distribute, only if GET didn't recover URLs.
|
// Step 2: distribute, only if GET didn't recover URLs.
|
||||||
@@ -125,7 +125,7 @@ export const POST = withAuth(
|
|||||||
const distributed = await distributeEnvelopeV2(doc.documensoId, ctx.portId);
|
const distributed = await distributeEnvelopeV2(doc.documensoId, ctx.portId);
|
||||||
await persistUrlsForDocument(distributed.recipients);
|
await persistUrlsForDocument(distributed.recipients);
|
||||||
} catch {
|
} catch {
|
||||||
// Probably "already distributed" — last-ditch GET.
|
// Probably "already distributed" - last-ditch GET.
|
||||||
try {
|
try {
|
||||||
const fetched = await getDocument(doc.documensoId, ctx.portId);
|
const fetched = await getDocument(doc.documensoId, ctx.portId);
|
||||||
await persistUrlsForDocument(fetched.recipients);
|
await persistUrlsForDocument(fetched.recipients);
|
||||||
@@ -146,7 +146,7 @@ export const POST = withAuth(
|
|||||||
|
|
||||||
if (!target.signingUrl) {
|
if (!target.signingUrl) {
|
||||||
throw new ValidationError(
|
throw new ValidationError(
|
||||||
'Signer has no Documenso URL yet — try regenerating the EOI; v2 envelopes require distribution before the signing link exists.',
|
'Signer has no Documenso URL yet - try regenerating the EOI; v2 envelopes require distribution before the signing link exists.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ export const POST = withAuth(
|
|||||||
documentLabel: DOC_TYPE_LABEL[doc.documentType] ?? 'Expression of Interest',
|
documentLabel: DOC_TYPE_LABEL[doc.documentType] ?? 'Expression of Interest',
|
||||||
signerRole: (target.signerRole as SignerRole) ?? 'client',
|
signerRole: (target.signerRole as SignerRole) ?? 'client',
|
||||||
senderName: docCfg.developerName ?? null,
|
senderName: docCfg.developerName ?? null,
|
||||||
// Phase 6 — surface the per-doc rep-authored note when set so
|
// Phase 6 - surface the per-doc rep-authored note when set so
|
||||||
// every cascaded invite and any manual resend show the same
|
// every cascaded invite and any manual resend show the same
|
||||||
// copy. Falls back to the template default when null/empty.
|
// copy. Falls back to the template default when null/empty.
|
||||||
customMessage: doc.invitationMessage,
|
customMessage: doc.invitationMessage,
|
||||||
|
|||||||
@@ -6,19 +6,19 @@ import { detectFields } from '@/lib/services/document-field-detector';
|
|||||||
import { isPdfMagic } from '@/lib/services/berth-pdf-parser';
|
import { isPdfMagic } from '@/lib/services/berth-pdf-parser';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase 4 — Auto-detect anchor scanner endpoint.
|
* Phase 4 - Auto-detect anchor scanner endpoint.
|
||||||
*
|
*
|
||||||
* POST `/api/v1/documents/auto-detect-fields`
|
* POST `/api/v1/documents/auto-detect-fields`
|
||||||
*
|
*
|
||||||
* Body: multipart/form-data
|
* Body: multipart/form-data
|
||||||
* - file: the source PDF the rep just uploaded
|
* - file: the source PDF the rep just uploaded
|
||||||
*
|
*
|
||||||
* Returns: `{ data: { fields: DetectedField[] } }` — seed state for the
|
* Returns: `{ data: { fields: DetectedField[] } }` - seed state for the
|
||||||
* drag-drop overlay. Empty array when the PDF has no extractable text
|
* drag-drop overlay. Empty array when the PDF has no extractable text
|
||||||
* (image-only scan) — the dialog falls back to manual placement
|
* (image-only scan) - the dialog falls back to manual placement
|
||||||
* without an error toast.
|
* without an error toast.
|
||||||
*
|
*
|
||||||
* Permission: documents.send_for_signing — the only flow that calls
|
* Permission: documents.send_for_signing - the only flow that calls
|
||||||
* this endpoint is the upload-for-signing dialog, which already
|
* this endpoint is the upload-for-signing dialog, which already
|
||||||
* requires that bit. Reusing it here means a custom role with the
|
* requires that bit. Reusing it here means a custom role with the
|
||||||
* upload bit but no send bit can't dry-run the detector to pull
|
* upload bit but no send bit can't dry-run the detector to pull
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ import { getEoiTemplateSyncReport } from '@/lib/services/documenso-template-sync
|
|||||||
*
|
*
|
||||||
* Returns the per-port developer + approver defaults the
|
* Returns the per-port developer + approver defaults the
|
||||||
* UploadForSigningDialog uses to prefill the recipient configurator.
|
* UploadForSigningDialog uses to prefill the recipient configurator.
|
||||||
* No secrets are exposed — just the display name, email, and the
|
* No secrets are exposed - just the display name, email, and the
|
||||||
* sendMode flag so the UI can show the right CTA copy ("Send now" vs
|
* sendMode flag so the UI can show the right CTA copy ("Send now" vs
|
||||||
* "Save draft, send manually").
|
* "Save draft, send manually").
|
||||||
*
|
*
|
||||||
* Permission: documents.send_for_signing — the only caller is the
|
* Permission: documents.send_for_signing - the only caller is the
|
||||||
* upload-for-signing dialog which already requires this permission to
|
* upload-for-signing dialog which already requires this permission to
|
||||||
* complete the flow.
|
* complete the flow.
|
||||||
*/
|
*/
|
||||||
@@ -25,7 +25,7 @@ export const GET = withAuth(
|
|||||||
|
|
||||||
// Signing order resolution chain (highest → lowest priority):
|
// Signing order resolution chain (highest → lowest priority):
|
||||||
// 1. Cached `documento_eoi_template_sync_report.templateMeta.signingOrder`
|
// 1. Cached `documento_eoi_template_sync_report.templateMeta.signingOrder`
|
||||||
// — populated by the admin "Sync from Documenso" button and
|
// - populated by the admin "Sync from Documenso" button and
|
||||||
// represents the live template's bound order. On v2 this is the
|
// represents the live template's bound order. On v2 this is the
|
||||||
// authoritative value because `/template/use` doesn't accept a
|
// authoritative value because `/template/use` doesn't accept a
|
||||||
// per-call override.
|
// per-call override.
|
||||||
@@ -53,7 +53,7 @@ export const GET = withAuth(
|
|||||||
signingOrder,
|
signingOrder,
|
||||||
// Surface where the value came from so the UI tooltip can be
|
// Surface where the value came from so the UI tooltip can be
|
||||||
// honest about the source. Helps reps debug "I changed it in
|
// honest about the source. Helps reps debug "I changed it in
|
||||||
// Documenso but the CRM still says X" — they need to re-run
|
// Documenso but the CRM still says X" - they need to re-run
|
||||||
// Sync to pull the change.
|
// Sync to pull the change.
|
||||||
signingOrderSource: syncReport?.templateMeta?.signingOrder
|
signingOrderSource: syncReport?.templateMeta?.signingOrder
|
||||||
? 'template'
|
? 'template'
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user