Six audit documents capture the 2026-05-06 review pass (comprehensive, frontend, missing-features, permissions, reliability) along with the Documenso integration audit + locked build plan that drove the bulk of subsequent feature work. Adds `docs/admin-ux-backlog.md` as a living tracker for the autonomous push — every item marked DONE or REMAINING with file pointers and scope estimates so future sessions can pick up where this one stopped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
32 KiB
Comprehensive Audit — 2026-05-06
Conducted directly after the smart-archive / hard-delete / bulk-wizard /
audit-overhaul / synthetic-seed batches landed (commits d07f1ed
through 9890d06). Prior comprehensive audit:
docs/audit-comprehensive-2026-05-05.md.
Findings are sorted by severity. Each has a concrete file:line, a scenario, and a fix recommendation.
CRITICAL
C1. 5 of 10 BullMQ workers are never imported (production + dev)
Files: src/worker.ts:13-17, src/server.ts:72-76
src/worker.ts (production) and src/server.ts (dev fallback) both
import only:
emailWorkerdocumentsWorkernotificationsWorkerimportWorkerexportWorker
Missing: aiWorker, bulkWorker, maintenanceWorker, reportsWorker, webhooksWorker.
Because BullMQ workers are constructed at the top of each worker module and only "start" when the module is imported, never importing them means:
- Webhooks never deliver.
webhooksWorkeris what processes thewebhooksqueue; the admin "Replay" button we just shipped enqueues jobs that pile up inpendingforever. - All maintenance crons silently no-op.
maintenanceWorkerhandlesdatabase-backup,backup-cleanup,session-cleanup,currency-refresh,gdpr-export-cleanup,ai-usage-retention,error-events-retention,website-submissions-retention,alerts-evaluate,analytics-refresh,calendar-sync,temp-file-cleanup,form-expiry-check— none run. - Scheduled reports never generate.
reportsWorkerhandlesreport-scheduler(every minute). - Bulk jobs never process (the synchronous bulk endpoints work, but any deferred-bulk path is dead).
- AI usage features never run.
Impact: Production CRM has been silently shedding webhook deliveries, never running retention/cleanup, never sending scheduled reports.
Fix:
// Append to src/worker.ts AND the inline section of src/server.ts:
import { aiWorker } from '@/lib/queue/workers/ai';
import { bulkWorker } from '@/lib/queue/workers/bulk';
import { maintenanceWorker } from '@/lib/queue/workers/maintenance';
import { reportsWorker } from '@/lib/queue/workers/reports';
import { webhooksWorker } from '@/lib/queue/workers/webhooks';
const workers = [
emailWorker,
documentsWorker,
notificationsWorker,
importWorker,
exportWorker,
aiWorker,
bulkWorker,
maintenanceWorker,
reportsWorker,
webhooksWorker,
];
After fix, run pnpm dev and watch /admin/webhooks/{id} deliveries
go from pending → success to confirm.
HIGH
H1. Hard-delete request endpoints have zero rate limiting
Files:
src/app/api/v1/clients/[id]/hard-delete-request/route.ts:1-37src/app/api/v1/clients/bulk-hard-delete-request/route.ts:1-32
Each call writes a fresh code to Redis and emails it to the operator's
address. No withRateLimit(...). An attacker who has compromised an
admin account (or even just the new permanently_delete_clients
permission) can:
- Email-bomb the admin's own inbox (every request → email).
- Probe whether arbitrary client IDs exist (200 +
sentToMaskedEmailvs 404client not foundis a UID oracle). - Burn SMTP quota.
Fix: add withRateLimit('auth', ...) or a new dedicated bucket
(e.g. 5 per hour per user). Pattern is already in
src/app/api/v1/clients/[id]/gdpr-export/route.ts.
H2. Audit-page view fires on every paginated reload (log spam)
File: src/app/api/v1/admin/audit/route.ts:48-72
I added a "watch the watchers" view audit row for first-page audit
fetches. That's the right idea, but the page also re-fires the request
on every filter change (severity, source, action, date range, search).
A diligent admin filtering through the inspector for an investigation
will write dozens of view audit rows per minute — making it harder to
find the actual events they're looking for.
Fix: dedupe in Redis with a 60-second per-user TTL key, only emit if the key didn't exist. Or only fire when no filters are active.
H3. Hard-delete error messages distinguish "no code" vs "wrong code"
File: src/lib/services/client-hard-delete.service.ts:166-174
if (!stored) throw new ValidationError('Confirmation code expired or not requested');
if (!safeEqualStr(stored, args.code.trim())) {
throw new ValidationError('Confirmation code is incorrect');
}
The two messages let an attacker distinguish "you've never requested a code" (so spam the request endpoint to open the window) from "wrong code" (so brute-force more codes). 4-digit space is only 10,000 — with distinguishable feedback an attacker can confirm code validity in ≤5,000 attempts on average.
Fix: collapse to a single 'Invalid or expired code' message; the
operator already has the email open and knows what they typed.
H4. Synthetic seed leaves super_admin linked-port-roles empty
File: src/lib/db/seed-bootstrap.ts:147-160
The bootstrap creates the userProfiles row with
isSuperAdmin: true for super-admin-matt-portnimara, but doesn't
create userPortRoles rows. The actual real user rows (admin@,
agent@, viewer@) are only created via the Playwright global-setup.
Anyone running pnpm db:seed:synthetic then pnpm dev and trying to
log in via the UI hits an unauthenticated state until they also run
playwright setup or sign up via better-auth manually.
Fix: either document this in CLAUDE.md Quick Reference, or add a
pnpm db:seed:dev-users companion script that signs up the three
test users + links roles. Today's synthetic-seed flow felt clean
because the playwright setup was still applied; in a fresh clone it
will surprise.
H5. Documenso bad-secret 200 response is correct, but enables enum oracle
File: src/app/api/webhooks/documenso/route.ts:67-86
The route returns 200 ok=false error=Invalid secret for a wrong
secret. That's webhook best-practice (don't leak signal to attackers),
but combined with the new audit row that captures
metadata.providedLen, an attacker can probe secret-length over time
without being detected (just a "warning" row per attempt). On an admin
inspector with 1000s of rows, a slow-rate probe is invisible.
Fix: add per-IP rate limit (5/min) to /api/webhooks/documenso/
when secret check fails. Don't block real Documenso traffic — it
shouldn't fail the secret check.
H6. The audit-log inspector page itself isn't backed by a real "view" gate beyond admin.view_audit_log
File: src/app/api/v1/admin/audit/route.ts:31
Audit log has the most sensitive cross-cutting data in the system
(every login attempt with attempted email, every secret-regenerate,
every hard-delete). It's gated only by admin.view_audit_log. The
seed grants this to director AND super_admin. Consider:
- making the page super-admin-only for production, OR
- adding a secondary confirmation when viewing rows that contain attempted emails / IP ranges (PII).
Fix: change withPermission('admin', 'view_audit_log', ...) to
add if (!ctx.isSuperAdmin) check sensitive_audit_view. Or accept
the current model but document it in the role docs.
H7. Three "coming soon" stubs in production UI
Files:
src/components/clients/client-tabs.tsx:276— "File attachments coming soon."src/components/clients/client-reservations-tab.tsx:41— "History is coming soon."src/components/berths/berth-tabs.tsx:327— "{label} coming soon"
Visible to every user on every client / berth detail page. Either ship the feature or hide the tab.
Fix: for client-tabs.tsx line 276 (Files), the files table
already exists and supports clientId — ship a list view.
For berth-tabs.tsx line 327 — find the calling tab labels and
either implement or remove from the tabs array.
For client-reservations-tab.tsx line 41 — query past reservations
when the user toggles a "show history" filter.
MEDIUM
M1. attachWorkerAudit recurring job names list duplicates scheduler.ts (drift risk)
File: src/lib/queue/audit-helpers.ts:23-46
The 20 recurring job names are hardcoded in the audit helper; the scheduler also has its own list. If someone adds a new cron without updating both, the cron_run audit row never fires for that job.
Fix: export the list from scheduler.ts and import it in
audit-helpers.ts. Single source of truth.
M2. client-merge-log.surviving_client_id deleted by hard-delete (history loss)
File: src/lib/services/client-hard-delete.service.ts:200-202
Hard-delete drops every client_merge_log row whose surviving id
matches. Those rows are the audit trail of WHO was merged INTO this
client. Once deleted, you've lost evidence of the prior merge.
Fix: replace delete with a column nullification, or move the row
to a client_merge_log_archive table. Audit trail per GDPR Article 5
should outlive the data.
M3. Bulk hard-delete loops one-shot codes through Redis (5x writes)
File: src/lib/services/client-hard-delete.service.ts:382-396
For a 100-client bulk delete, the function writes 100 single-client
codes to Redis just to satisfy hardDeleteClient's expectation. Each
write is a round-trip; on a Redis hiccup mid-loop, you can end up
with a half-deleted batch.
Fix: refactor hardDeleteClient so the inner deletion can be called
without the per-client code check (extract _doHardDelete() private
helper used by both single and bulk paths). Keeps Redis clean.
M4. Smart-restore wizard has dead reversal applier for berth_released
File: src/lib/services/client-restore.service.ts:360-372
The applyReversal switch case for 'berth_released' does nothing —
it just leaves the berth available. The wizard surfaces this as
"auto-reversible" if the berth is still free, but the actual restore
doesn't re-attach the berth to any interest. Operator clicks Restore
expecting their berth back; nothing changes on the berth.
Fix: either (a) at archive time, persist the original interestId in the decision metadata so we can re-link, or (b) update the wizard copy to make clear the berth is "available for re-attach" rather than "will be re-attached."
M5. Several services use void createAuditLog(...) without .catch()
Files: widespread; e.g. src/lib/services/client-hard-delete.service.ts:127-136, 230-240,
src/lib/services/portal-auth.service.ts:269-276
createAuditLog is documented as never-throwing (catches internally),
but defense-in-depth: a void Promise that throws produces an
unhandled rejection event. Most paths are fine because the helper
catches; if anyone refactors createAuditLog and removes the catch,
this becomes a process-killer.
Fix: convention rule: every void someAsync() must have a .catch().
Codify with a custom ESLint rule, or wrap at call sites:
void createAuditLog({...}).catch(() => undefined);
M6. Hard-delete audit metadata leaks client fullName
File: src/lib/services/client-hard-delete.service.ts:241-247
After the hard-delete the audit row carries
metadata: { fullName: client.fullName }. The client record itself is
gone but their name lives on in the audit log. For a GDPR data subject
who exercised their right-to-erasure, this is technically a retention
of personal data in audit history. Not necessarily wrong (audit logs
have a legitimate-interest basis), but should be conscious.
Fix: decide policy: either (a) keep as-is and document, (b) replace with a hash of the name, or (c) substitute a tombstone identifier.
M7. Webhook delivery DLQ admin-replay can re-trigger downstream side-effects
File: src/lib/services/webhooks.service.ts:282-326
Replaying a successful webhook (operator presses Replay on a delivery
that already had status: 'success') re-fires the same payload to the
recipient. If the recipient's idempotency check is weak, you've just
caused a duplicate. The replay payload includes retried_from /
retried_at markers, which is good — but most recipients won't honor
them.
Fix: disable the Replay button when status === 'success'. The UI
already gates on 'failed' || 'dead_letter' — verify it stays that
way (webhook-delivery-log.tsx:118-131 looks correct; double-check
no regressions).
M8. audit_logs table has no DELETE permission gate
Files: schema and routes
There's no admin endpoint to delete audit rows (good). But there's no
DB-level guard either. A super_admin who runs db:reset wipes audit
history. Audit retention should be enforced at the schema level so
even a misconfigured operator can't blow away the trail.
Fix: create a audit_logs_no_delete_role postgres role that lacks
DELETE on the table; document that the app's DB user should not have
DELETE on audit_logs in production deployments.
M9. Documenso void worker uses dynamic import every time
File: src/lib/queue/workers/documents.ts:25
const { voidDocument } = await import('@/lib/services/documenso-client');
Dynamic import inside a hot per-job path is fine the first time but slows every subsequent call slightly. Move to top-of-file import unless there's a deliberate reason (circular dep?).
Fix: test moving to top-level import; if it works (no circular deps), keep it there.
M10. Bulk archive wizard "blocked" reason copy truncates at first line
File: src/components/clients/bulk-archive-wizard.tsx:153-163
The wizard shows b.blockers[0] for blocked clients. If the dossier
has multiple blockers, only the first is shown. Operators may fix the
first one, retry, and discover a second.
Fix: show all blockers (joined with ·) or a "+N more" badge
with click-to-expand.
LOW
L1. next-in-line-notify.service.ts could double-fire on archive retry
File: src/app/api/v1/clients/[id]/archive/route.ts:114-135
If the smart-archive request succeeds at the DB transaction level but
the response upload-side fails (network blip, browser closes), the
operator may retry. Each retry re-fires the next-in-line notification
to all sales recipients. The dedupeKey: berth-released:{berthId}
inside the notification helper deduplicates within a cooldown window —
so this is mitigated, but worth verifying the cooldown is set and
not 0.
L2. interests.berth_id reference in seed-data.ts (legacy seed)
File: src/lib/db/seed-data.ts:973
The realistic seed inserts berthId: ... on the interests table. Per
CLAUDE.md, that column was dropped in migration 0029 and replaced
with interest_berths junction. The synthetic seed uses the junction
correctly. The realistic seed will FAIL at insert time if anyone
tries to run it on a freshly-migrated DB.
Fix: rewrite seed-data.ts:969-982 to insert into interests
without berthId, then insert the junction rows separately (mirror
the synthetic seed's pattern).
L3. Audit log entry for failed login uses entityId = attemptedEmail (unbounded)
File: src/app/api/auth/[...all]/route.ts:53-68
If the entityId is very long (a 500-char "email"), it goes into the
DB column. The column is text (unbounded) so no DB error, but FTS
search-text may bloat.
Fix: truncate attempted email to 256 chars before using as entityId.
L4. The "watch the watchers" audit fires for filtered queries too
File: src/app/api/v1/admin/audit/route.ts:48-72
(See H2 above for the page-spam variant.) Even on a single search, an audit row containing the search term is written. If the search term itself is sensitive (e.g. an admin searches for a specific client's name in audit logs), it's now in the audit log of audit-log viewing. Acceptable but worth documenting.
L5. Import worker is a stub
File: src/lib/queue/workers/import.ts:13
// TODO(L2): implement import job handlers — the worker is wired
into the queue and registered, but does nothing. If anyone enqueues
an import:* job, it returns immediately. Either ship the feature
or remove the queue.
L6. interest-form.tsx two TODOs about company-yacht filter + add-yacht inline
File: src/components/interests/interest-form.tsx:332-333
Real product gaps. When creating an interest for a client who's a member of a company, you can't pick a yacht owned by that company. And there's no inline "Add yacht" shortcut in the form.
L7. berth-spec-template.ts defaults to 'Price: TBD' when price is null
File: src/lib/pdf/templates/berth-spec-template.ts:128
Generated berth-spec PDFs say "Price: TBD" for any berth without a price. Cosmetic — verify whether sales considers this an acceptable fallback or wants to suppress the line entirely.
Things checked and found OK (so we don't re-audit)
- Tenant isolation on hard-delete (
portIdfilter on every query and inside the tx). withPermissiongates on every new route (bulk-archive-preflight, hard-delete-, bulk-hard-delete-, redeliver).- Audit log: no public DELETE endpoint, no PATCH endpoint.
- Sidebar nav properly gates marina sections from
residential_partnerviahasMarinaAccess. - Auth wrapper rebuilds the request body correctly so the upstream better-auth handler can re-read it (no body-already-consumed bug).
- Webhook outbound SSRF guard with DNS rebinding protection still intact.
- 1175/1175 vitest suite passing as of last run.
Recommended fix order (ROUND 1 + 2 combined — see below for Round 2)
See "Triage list" at the end of this document — combined ranking across both audit rounds.
Round 2 — focused agents (added 2026-05-06 evening)
After the original synthesis above, four scoped agents (smaller blast
radius, hard finding caps) successfully audited their domains and
produced dedicated docs. Findings are linked here with R2--prefixed
IDs. Detail in:
- audit-reliability-2026-05-06.md — 11 findings
- audit-frontend-2026-05-06.md — 12 findings
- audit-permissions-2026-05-06.md — 9 findings
- audit-missing-features-2026-05-06.md — 12 findings
Round 2 — CRITICAL
R2-C1. Bulk archive discards post-commit side effects (reliability C1)
- File:
src/app/api/v1/clients/bulk/route.ts:68-134 - The bulk wizard's
runBulkcallback discards the return value fromarchiveClientWithDecisions. Documenso envelopes markedvoid_documensoare never queued for void; "next-in-line" sales notifications never fire. The CRM ends up showingdocuments.status='cancelled'while the live envelope is still out for signature — a signer can legally complete a doc the CRM thinks is voided. - Same severity tier as the original C1 (worker-imports).
R2-C2. Frontend: Restore icon hovers destructive-red on archived clients (frontend C1)
- File:
src/components/clients/client-detail-header.tsx:174-186 - Conditional
hover:text-destructiveis overridden by an unconditionalhover:text-foregroundearlier in the class string. Result: the Restore button on archived clients hovers blood-red, signalling "destructive" on a fully reversible action. Users hesitate to click. Promoted to "critical UX" because it's directly misleading on every archived client view.
Round 2 — HIGH
R2-H1. Smart-restore wizard's berth_released reversal is a no-op but the audit log claims success
(reliability H1)
- File:
src/lib/services/client-restore.service.ts:359-372 - Already noted as M4 in the original synthesis. Round-2 reliability
agent escalated to HIGH because the wizard counter increments and
the audit log records "1 auto-reversed" — operator believes the berth
was re-attached when nothing happened. Same fix path: persist the
original
interestIdin the decision detail and re-link on restore.
R2-H2. Smart-archive berth status update has TOCTOU race (reliability H2)
- File:
src/lib/services/client-archive.service.ts:191-207 - Berth row read outside tx, mutated inside tx without
for updatelock. Concurrent archive + sale of the same berth can race: the archive flow flips a freshly-sold berth back toavailable. Addselect … for updateonberthsbefore the status flip.
R2-H3. Bulk archive can pick the wrong interest for berth release (reliability H3)
- File:
src/app/api/v1/clients/bulk/route.ts:95-103 - Lookup by
primaryBerthMooringfalls back todossier.interests[0]?.interestId ?? ''. Empty-stringinterestIdreaches the delete and silently matches zero rows; the link is silently retained while the audit log claims it was removed.
R2-H4. External EOI runs five operations outside a transaction (reliability H4)
- File:
src/lib/services/external-eoi.service.ts:67-155 - Storage upload + 4 DB writes are independent. Mid-flight failure leaves orphan PDFs in S3/MinIO and partial DB state.
R2-H5. Bulk wizard double-submit treats ConflictError('already archived') as a per-row error
(reliability H5)
- File:
src/app/api/v1/clients/bulk/route.ts:68-120 - No idempotency key on the bulk endpoint. A double-submit (network retry, double click) makes the second response look like all rows failed even though the first succeeded.
R2-H6. Webhook replay button has no UI permission gate (403 toast spam) (permissions H1)
- File:
src/components/admin/webhooks/webhook-delivery-log.tsx:118-131 - Replay button renders for any user who can load the page. Server gates
on
admin.manage_webhooks. Non-admins see enabled buttons; clicking surfaces a generic 403 toast.
R2-H7. Bulk Archive bulk action exposed to roles without clients.delete
(permissions H2)
- File:
src/components/clients/client-list.tsx:182-190 sales_agentandviewersee the Archive bulk action; clicking surfaces a 403 from preflight. Mirror thecanHardDeletepattern:const canBulkArchive = can('clients', 'delete');
R2-H8. Bulk add_tag / remove_tag exposed to viewer (permissions H3)
- File:
src/components/clients/client-list.tsx:165-181 - Same pattern as R2-H7 — no UI gate; server gates on
clients.edit.
R2-H9. Bulk hard-delete silently skips rows that vanish between preflight and execute (permissions H4)
- File:
src/lib/services/client-hard-delete.service.ts:377 if (!c) continue;swallows any client that was archived/restored/ deleted by another operator between preflight and execute. Operator sees adeletedCountlower than requested and no signal which IDs were skipped.
R2-H10. Frontend: webhook-delivery-log and audit-log-list swallow fetch errors silently
(frontend H3, H4)
- Files:
src/components/admin/webhooks/webhook-delivery-log.tsx:61-74,src/components/admin/audit/audit-log-list.tsx:150-175 - Both wrap fetches in
try/finallywith nocatch. Failed loads show spinner forever or stale data; user has no signal that anything failed. Surface viatoast.error+ inline retry banner.
R2-H11. Frontend: audit-log-card renders as <a href="#"> — page-jumps on mobile tap
(frontend H5)
- File:
src/components/admin/audit/audit-log-card.tsx:96 - Card view rows on mobile insert
#in URL on tap (back-button trap). Render as button or div, or link to a useful destination.
R2-H12. Frontend: smart-archive-dialog doesn't invalidate the dossier or single-client query
(frontend H6)
- File:
src/components/clients/smart-archive-dialog.tsx:197-212 - Detail page header keeps showing client as un-archived after a
successful archive until hard reload. Add
qc.invalidateQueries({queryKey: ['clients', clientId]})andqc.removeQueries({queryKey: ['client-archive-dossier', clientId]}).
R2-H13. Frontend: bulk tag mutation uses alert() and lacks onError
(frontend H2)
- File:
src/components/clients/client-list.tsx:88-106 - Native
alert()blocks the page on partial failure; pure network failure shows nothing. Replace withtoast.warning/toast.error.
R2-H14. Email-template subject overrides are no-ops for 6 of 8 templates (missing-features V1)
- Files:
src/components/admin/email-templates-admin.tsx:24-72(UI),src/lib/services/portal-auth.service.ts:120,332(only consumers) - Admin sees an "Overridden" badge after saving a custom subject for
CRM invite, inquiry confirmation, residential templates, etc. — but
the senders ship the hardcoded subject regardless. Wire
loadSubjectOverride(portId, key)into the 6 missing senders.
R2-H15. Branding admin saves 5 settings that nothing reads (missing-features V2)
- Files:
src/app/(dashboard)/[portSlug]/admin/branding/page.tsx,src/lib/services/port-config.ts:240-272 - Logo URL, app name, primary color, header HTML, footer HTML all
dead-end.
getPortBrandingConfighas zero callers. Multi-tenant promise broken — every port's emails ship Port Nimara's branding.
R2-H16. Reminder admin saves digest defaults that no scheduler applies (missing-features V3)
- Files:
src/app/(dashboard)/[portSlug]/admin/reminders/page.tsx,src/lib/services/port-config.ts:284-306 - Sales reps think they configured a daily digest at 09:00 in their TZ; they get fire-as-they-hit notifications instead. The digest scheduler doesn't exist.
Round 2 — MEDIUM (selected highlights)
R2-M1. Portal "My Memberships" tile is a dead-end (missing-features V4)
- Tile on
/portal/dashboardhas nohref; route doesn't exist. Either ship/portal/membershipsor remove the tile.
R2-M2. Company detail Documents tab is a "Coming soon" stub (missing-features V5)
src/components/companies/company-tabs.tsx:230-234. Same problem as the three already-noted "coming soon" stubs but on a different entity.
R2-M3. Onboarding page is a static checklist not the wizard it advertises (missing-features V6)
- The page literally says "what this page will become". Either build the wizard or relabel the landing card.
R2-M4. Backup admin page is a docs page despite landing copy promising "on-demand exports" (missing-features V7)
- Once C1 (worker imports) is fixed, the existing
database-backupjob is reachable; small lift to wire a "Take backup now" button.
R2-M5. Inquiry inbox has zero triage actions (missing-features V8)
- No "Convert to client", no "Resolve", no "Assign".
website_submissionstable is permanent; sales has to copy-paste emails into client forms.
R2-M6. external-eoi grants only documents.upload_signed but mutates interest state (permissions M1)
- A custom role with
documents.upload_signed:true+interests.edit:falsecan flip an interest to "signed" via the external-EOI route.
R2-M7. InlineStagePicker never sends override:true — override_stage permission unreachable from the most-used UI path (permissions M2)
- Users with the perm have to fall back to the modal
InterestStagePickerto actually use it.
R2-M8. sales_agent granted interests.override_stage:true — likely copy-paste from sales_manager (permissions M3)
- All other trust-elevated flags are stripped from sales_agent. Needs a product decision; either flip to false or document intent.
R2-M9. bulk-archive-preflight leaks dossier-loader error text in blockers (permissions M4)
- An attacker enumerating UUIDs can distinguish "doesn't exist" vs "exists but you can't see it". Replace with generic "Could not load dossier".
R2-M10. Documenso void worker has no max-retry alert hook (reliability M2)
- A persistent 401/403 retries forever. On exhaustion, write back to
documents(cancellation_failed=true) and notify admin.
R2-M11. Mobile More-sheet missing residential, notifications, berth-reservations, website-analytics (missing-features V9)
- Mobile users have zero path to entire feature domains. Add to
MORE_ITEMS.
R2-M12. Portal has no profile / change-password surface (missing-features V10)
- Forces every portal user to use the forgot-password flow even when
they remember their old password. Ship
/portal/profile.
R2-M13. Portal invoices show amounts but no PDF download (missing-features V11)
- Documents page does have downloads; mirror the pattern.
(Plus several more medium/low items in the dedicated docs; see those for the full set.)
TRIAGE LIST (combined Round 1 + Round 2)
Ship now — CRITICAL
- C1 — wire the 5 missing BullMQ workers (
worker.ts,server.ts) — 5-line fix; every webhook + cron flow is currently dead. - R2-C1 — make bulk archive enqueue Documenso voids + next-in-line
notifications (return value plumbing in
bulk/route.ts). - R2-C2 — fix the destructive-red hover on the Restore button
(
client-detail-header.tsx). Trivial CSS fix.
Ship this week — HIGH (security/UX with concrete user impact)
- H1 — rate-limit the hard-delete-request endpoints.
- H3 — collapse "no code" vs "wrong code" into one error message.
- H7 — three "coming soon" stubs in client/berth tabs.
- R2-H1 — fix smart-restore's silent
berth_releasedno-op (or reclassify asreversibleWithPrompt). - R2-H2 — add
for updatelock on the smart-archive berth status flip (TOCTOU race). - R2-H3 — bulk-archive's wrong-interest fallback — empty-string interestId silently no-ops.
- R2-H6, R2-H7, R2-H8 — three permission UI-gate misses on bulk actions and the webhook-replay button. ~30 lines total.
- R2-H10, R2-H12, R2-H13 — frontend swallowed errors + missing invalidation + alert() instead of toast. Small fixes, immediate UX win.
- R2-H11 —
audit-log-cardhref="#"mobile back-button trap. - R2-H14 — wire 6 missing email-subject overrides through their senders.
Next sprint — HIGH/MEDIUM (operational + multi-tenant correctness)
- R2-H4 — wrap external-EOI in a transaction.
- R2-H5 — bulk-archive idempotency key + treat already-archived as success in bulk.
- R2-H9 — bulk hard-delete should return
skipped: string[]. - R2-H15, R2-H16 — branding + reminder admin pages save settings nothing reads (silently broken multi-tenancy).
- H2 — audit-page-view de-dupe (don't spam on every filter change).
- H4 — synthetic seed needs documented dev-user setup or its own bootstrap script.
- H5 — Documenso bad-secret rate-limit per IP.
- R2-M1 through R2-M5 — portal memberships dead-end, company Documents stub, onboarding wizard, backup page, inquiry inbox triage.
Backlog — MEDIUM/LOW + remaining items
- The remaining MEDIUM/LOW from both rounds — see the dedicated docs.
Headline numbers (combined)
- 3 CRITICAL (worker imports, bulk-archive side-effects, restore-button hover)
- 22 HIGH (security + UX with concrete impact)
- ~15 MEDIUM (operational hygiene, multi-tenancy gaps, unfinished features)
- ~10 LOW (cleanup, defensive)
Round 1 was a manual synthesis after agent-pool stalls; Round 2 was four focused agents with hard finding caps that all completed inside the watchdog window. Every finding is grounded in code references.