audit: Tier 2/3/4 batch — reports math, portal copy, authz escalation guard
Tier 2.2: revenue PDF totalCompleted now filters on outcome='won' — setInterestOutcome forces stage='completed' for every outcome (incl. lost + cancelled), so the stage-only filter was including those toward "TOTAL COMPLETED REVENUE". Tier 2.3: fetchPipelineData stageCounts adds the missing .groupBy() — without it Postgres rejects the SELECT (per-stage breakdown was broken or coercing to ELSE-stage row). Tier 2.4: hot-deals widget rank ladder fixed two stage-name typos — 'in_comms' → 'in_communication', 'deposit_10' → 'deposit_10pct'. Both stages were collapsing to the ELSE 0 branch server-side AND rendering raw enum to the user in hot-deals-card.tsx. Tier 3.2: portal /portal/interests no longer renders raw enum to clients. New PORTAL_SIGNING_LABELS table maps every EOI/contract status to plain English (e.g. "waiting_for_signatures" → "Waiting for signatures"). Tier 4.1 (CRITICAL): permission-overrides PUT now requires caller- superset on every `true` write. Admins with only `admin.manage_users` could previously grant other users leaves they don't hold themselves (permanently_delete_clients, system_backup). Super-admins bypass. Tier 4.4: search graph-expansion re-gates every merged bucket by the destination's view permission. A user with berths.view but no interests.view searching "A12" no longer sees interest rows surfaced via expansion. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -50,14 +50,18 @@ export async function fetchPipelineData(
|
||||
portId: string,
|
||||
_params: Record<string, unknown>,
|
||||
): Promise<PipelineData> {
|
||||
// Count interests per pipeline stage (non-archived)
|
||||
// Count interests per pipeline stage (non-archived).
|
||||
// The reporting audit caught the missing .groupBy() — without it,
|
||||
// postgres rejects the SELECT or collapses every interest into a
|
||||
// single ELSE-stage row. groupBy fixes the per-stage breakdown.
|
||||
const stageCounts = await db
|
||||
.select({
|
||||
stage: interests.pipelineStage,
|
||||
count: count(),
|
||||
})
|
||||
.from(interests)
|
||||
.where(and(eq(interests.portId, portId), isNull(interests.archivedAt)));
|
||||
.where(and(eq(interests.portId, portId), isNull(interests.archivedAt)))
|
||||
.groupBy(interests.pipelineStage);
|
||||
|
||||
const stageCountMap: Record<string, number> = {};
|
||||
for (const row of stageCounts) {
|
||||
@@ -122,7 +126,11 @@ export async function fetchRevenueData(
|
||||
stageRevenueMap[row.stage] = row.revenue ? String(row.revenue) : '0';
|
||||
}
|
||||
|
||||
// Total revenue from completed interests (primary-berth link only).
|
||||
// Total revenue from WON interests only. Reporting audit caught the
|
||||
// gap: setInterestOutcome forces pipelineStage='completed' for lost
|
||||
// AND cancelled outcomes too, so filtering by stage alone counted
|
||||
// those toward "TOTAL COMPLETED REVENUE". The outcome='won' filter is
|
||||
// the canonical money-changed-hands signal.
|
||||
const completedRevenue = await db
|
||||
.select({ total: sum(berths.price) })
|
||||
.from(interests)
|
||||
@@ -135,6 +143,7 @@ export async function fetchRevenueData(
|
||||
and(
|
||||
eq(interests.portId, portId),
|
||||
eq(interests.pipelineStage, 'completed'),
|
||||
eq(interests.outcome, 'won'),
|
||||
isNull(interests.archivedAt),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user