docs(uat): SHIPPED annotations for PR12 (env-reveal + stage sortable)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -249,7 +249,7 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._
|
||||
> - **Effort:** ~3-4h end-to-end (TableCell base + width token helper + column-def sweep + truncate-tooltip on verbose cells + default-visible audit). Captured 2026-05-21 from UAT.
|
||||
> - **Berth list "Latest deal stage" column: make sortable by pipeline-stage rank** — _src/components/berths/berth-columns.tsx:273-287_ + _src/lib/services/berths.service.ts:80-120_ — the column currently has `enableSorting: false`; sorts by status / area / active interests / etc. already work via the existing `sortColumn` switch + `customOrderBy` correlated-subquery pattern (see `activeInterestCount` at lines 107-120). latestInterestStage isn't a column on `berths` — it's the highest-ranked active interest's stage, populated in a two-pass post-fetch.
|
||||
> - **Fix:** (a) drop `enableSorting: false` on the column. (b) Add a `'latestInterestStage'` case to the sortColumn switch returning `null` (handled in customOrderBy, like `activeInterestCount`). (c) Add a `stageSort` correlated subquery mirroring `demandSort`: select the rank of the highest-active-stage interest per berth via a `CASE i.pipeline_stage WHEN 'enquiry' THEN 1 WHEN 'qualified' THEN 2 ... WHEN 'contract' THEN 7 END` ladder, then `ORDER BY ... ASC/DESC` per `query.order`. Filter same as demandSort (`port_id`, `archived_at IS NULL`, `outcome IS NULL`). Berths with no active interest → NULL; use `NULLS LAST` (ascending) / flip per direction so they land at the bottom regardless.
|
||||
> - **Effort:** ~45 min. Pure additive — no schema work, no API contract change. Captured 2026-05-18 from UAT.
|
||||
> - **Effort:** ~45 min. Pure additive — no schema work, no API contract change. Captured 2026-05-18 from UAT. **SHIPPED in ca51000:** column toggles to `enableSorting: true`; service-side adds a `stageSort` correlated subquery via the existing `customOrderBy` pattern (ranking 1=enquiry through 7=contract; NULLS LAST regardless of direction).
|
||||
> - **Berth list: bulk-edit affordance (parity with bulk-add)** — _src/components/berths/berth-list.tsx_ + _berth-columns.tsx_ + _src/lib/services/berths.service.ts_ + _new_ `src/app/api/v1/berths/bulk/route.ts` — bulk-add for berths exists; bulk-edit doesn't, so any cross-row mutation (status flip on a row range, price re-tier on a pontoon, tag application, area rename, archive a season's worth) is a 50× one-row-at-a-time grind. **Cross-reference:** the Bucket 3 finding "Bulk-price editing UI" already shipped the price-specific backend (`POST /api/v1/berths/bulk-update-prices`); this is the broader sibling covering every other column reps want to edit in bulk. Coordinate the two as a single rollout.
|
||||
> - **Scope:** (a) Row-select infra on `<DataTable />` — checkbox column, "select all on page" / "select all matching filters" header, persistent selection across pagination (~1h, mirror `InterestList`'s `bulkActions` pattern). (b) Bulk-actions bar on ≥1 row selected: change status, change area, set price / % adjust (folds in the already-built endpoint), add/remove tags, archive/restore, export selection CSV — each opens a small confirm/edit dialog (~2-3h). (c) Unified backend `POST /api/v1/berths/bulk` (mirror `/interests/bulk`) taking `{ action, ids, ...args }`, port-validates IDs, per-row transactional with per-row failure summary so the rep sees which of 50 berths failed and why; per-row audit + realtime fan-out; cap 500 IDs (~2-3h incl tests). (d) Each action gated by the appropriate berth perm (`berths.edit`, `berths.update_prices`, `berths.archive`, `tags.manage`); endpoint enforces the most-restrictive perm of the requested action (~30min).
|
||||
> - **Effort:** ~5-7h end-to-end. Captured 2026-05-18 from UAT.
|
||||
@@ -287,7 +287,7 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._
|
||||
> - **(b) Optional:** allow env-reveal under a stricter permission (e.g. `admin.reveal_env_secrets`) — defaults off, super-admin only. The server endpoint's "refuses to reveal env" guard would honour the permission as an override. Riskier; only do this if there's an operational need. Capture as Bucket 3 if pursued.
|
||||
> - **(c) Diagnose path:** add a console.warn / dev-mode toast when the click is swallowed silently so the next person debugging this can see what's happening.
|
||||
> - **Sibling check:** the server-side route comment at lines 21-22 says it "refuses to reveal values resolved from env or default," but the implementation at lines 39-52 just calls `getSetting()` and returns whatever it gets — there's no actual refusal check in the route handler. If `getSetting()` reaches into the env fallback the endpoint would leak env values. Verify the refusal is enforced upstream in `getSetting()` (or in the registry resolver) — if not, that's a separate finding (low/medium severity bug: env secrets leakable via API to anyone with `admin.manage_settings`). Worth running through to confirm.
|
||||
> - **Effort:** ~15 min for (a) UI message + tooltip; ~30 min if the route's env-refusal check needs to be added too. Captured 2026-05-18 from UAT.
|
||||
> - **Effort:** ~15 min for (a) UI message + tooltip; ~30 min if the route's env-refusal check needs to be added too. Captured 2026-05-18 from UAT. **SHIPPED (a) in ca51000:** eye toggle now `disabled` + `title` tooltip when value resolved from env/default. Sibling check on the route's env-refusal guard deferred to a security-side follow-up.
|
||||
> - **Email settings page: add explainer copy clarifying why sales send-from and noreply have separate credentials** — _src/app/(dashboard)/[portSlug]/admin/email/page.tsx_ (the page) + _src/components/admin/sales-email-config-card.tsx_ (the sales card) + the existing noreply transport card — the admin page renders two cards with overlapping field names (SMTP host/port/user/pass on both, plus IMAP on the sales card) and zero context for why both exist. Operators reasonably ask "why am I configuring this twice?" The two streams are intentionally separated (per CLAUDE.md "Send-from accounts (sales send-outs)"): sales = human-initiated rep emails with IMAP-bounce-poll monitoring; noreply = fire-and-forget automation (portal invites, password resets, signing reminders, inquiry confirmations). Reasons to keep them separate include sender reputation (mixing transactional volume with human sends hurts deliverability), reply handling (reps need replies in a monitored mailbox; automation shouldn't generate reply threads), and the practical pattern of using a transactional provider (Postmark/SendGrid) for noreply + Google Workspace / Outlook for the sales mailbox.
|
||||
> - **Fix:** add an explanatory header block at the top of the email-settings page (above the two cards) summarizing the split in plain language: 2-3 sentences max + a small table (sales vs noreply, what each sends, why split). Each card's CardDescription gets a one-liner anchoring to its role ("Used for rep-initiated emails (berth PDFs, brochures, manual follow-ups). Replies land in this mailbox and are bounce-monitored via IMAP." / "Used for automated emails (portal invites, password resets, signing reminders). Replies bounce."). Optional: a "Quick setup" toggle/button — "Use one mailbox for both streams" — that auto-mirrors SMTP creds from sales → noreply (or vice versa) for ports that don't need the split. Default state stays split (preserves the design intent for ports that have grown into it).
|
||||
> - **Effort:** ~30 min for the explainer copy + per-card descriptions; +1h for the "Quick setup" mirror affordance if pursued. Captured 2026-05-18 from UAT.
|
||||
|
||||
Reference in New Issue
Block a user