docs(05): create Phase 5 Offer System plans — 4 plans in 3 waves
Wave 1: 05-01 schema migration (5 new tables, additive only) Wave 2: 05-02 offer catalog CRUD (/admin/offers + NavBar) Wave 3: 05-03 project offer assignment (OffersTab) + 05-04 client dashboard offers + /admin/forecast Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,530 @@
|
||||
---
|
||||
plan_id: 05-04
|
||||
phase: 5
|
||||
wave: 3
|
||||
title: "Client dashboard offer card + /admin/forecast revenue forecast page (12 months)"
|
||||
type: execute
|
||||
depends_on: [05-01, 05-03]
|
||||
files_modified:
|
||||
- src/lib/client-view.ts
|
||||
- src/components/client-dashboard.tsx
|
||||
- src/components/client/OffersSection.tsx
|
||||
- src/lib/forecast-queries.ts
|
||||
- src/app/admin/forecast/page.tsx
|
||||
requirements_addressed: [OFFER-05, OFFER-06]
|
||||
autonomous: true
|
||||
must_haves:
|
||||
truths:
|
||||
- "Client dashboard shows active offers for a project with public_name, cumulative_price, accepted_total — never internal_name or individual service prices"
|
||||
- "If a project has no active offers, the client dashboard does not show an offer section"
|
||||
- "Admin can visit /admin/forecast and see a 12-month revenue breakdown table based on active project_offers"
|
||||
- "Forecast uses project_offers.accepted_total (not projects.accepted_total) spread over duration_months starting from start_date"
|
||||
artifacts:
|
||||
- path: "src/lib/client-view.ts"
|
||||
provides: "Extended ProjectView interface with activeOffers field"
|
||||
contains: "activeOffers"
|
||||
- path: "src/components/client/OffersSection.tsx"
|
||||
provides: "Client-facing offer card showing public_name, cumulative_price, accepted_total"
|
||||
contains: "OffersSection"
|
||||
- path: "src/lib/forecast-queries.ts"
|
||||
provides: "getRevenueForecast12Months() server function"
|
||||
contains: "ForecastMonth, getRevenueForecast12Months"
|
||||
- path: "src/app/admin/forecast/page.tsx"
|
||||
provides: "Admin revenue forecast page at /admin/forecast"
|
||||
contains: "ForecastTable"
|
||||
key_links:
|
||||
- from: "src/lib/client-view.ts"
|
||||
to: "src/db/schema.ts"
|
||||
via: "project_offers + offer_micros + offer_micro_services + offer_services JOIN queries"
|
||||
pattern: "activeOffers"
|
||||
- from: "src/app/admin/forecast/page.tsx"
|
||||
to: "src/lib/forecast-queries.ts"
|
||||
via: "getRevenueForecast12Months() server import"
|
||||
pattern: "getRevenueForecast12Months"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Extend the client dashboard to display active offers per project, and build the admin revenue forecast page at `/admin/forecast`.
|
||||
|
||||
Purpose: These are the two read surfaces that give value to the offer data collected in Plans 01-03. The client sees what they are getting; the admin sees projected revenue.
|
||||
Output: `ProjectView` extended with `activeOffers`; `OffersSection` client component; `forecast-queries.ts` with computation function; `/admin/forecast` page.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/Users/simonecavalli/IAMCAVALLI/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/simonecavalli/IAMCAVALLI/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@/Users/simonecavalli/IAMCAVALLI/.planning/ROADMAP.md
|
||||
@/Users/simonecavalli/IAMCAVALLI/.planning/STATE.md
|
||||
@/Users/simonecavalli/IAMCAVALLI/.planning/phases/05-offer-system/05-RESEARCH.md
|
||||
@/Users/simonecavalli/IAMCAVALLI/.planning/phases/05-offer-system/05-01-SUMMARY.md
|
||||
@/Users/simonecavalli/IAMCAVALLI/.planning/phases/05-offer-system/05-03-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- From src/lib/client-view.ts — ProjectView interface to extend (SECURITY: never expose internal_name): -->
|
||||
```typescript
|
||||
export interface ProjectView {
|
||||
project: { id: string; name: string; client_id: string; accepted_total: string; };
|
||||
phases: Array<...>;
|
||||
payments: Array<{ id: string; label: string; status: string; }>;
|
||||
// ... other fields ...
|
||||
global_progress_pct: number;
|
||||
// ADD:
|
||||
// activeOffers?: Array<{
|
||||
// id: string;
|
||||
// public_name: string; // offer_micros.public_name ONLY — NEVER internal_name
|
||||
// cumulative_price: string; // SUM of offer_services.price for this micro's services
|
||||
// accepted_total: string | null; // project_offers.accepted_total
|
||||
// }>;
|
||||
}
|
||||
```
|
||||
|
||||
<!-- SECURITY RULE: getProjectView() must NEVER select offer_micros.internal_name -->
|
||||
<!-- Use explicit column selection: offer_micros.public_name, project_offers.accepted_total -->
|
||||
<!-- See client-view.ts line 89: "amount intentionally excluded" — same discipline for offer internal data -->
|
||||
|
||||
<!-- From src/db/schema.ts (after 05-01): -->
|
||||
```typescript
|
||||
export const project_offers = pgTable("project_offers", {
|
||||
id, project_id, micro_id, start_date, accepted_total, created_at
|
||||
});
|
||||
export const offer_micros = pgTable("offer_micros", {
|
||||
id, macro_id, internal_name, public_name, transformation_promise, duration_months, sort_order
|
||||
});
|
||||
export const offer_micro_services = pgTable("offer_micro_services", {
|
||||
micro_id, service_id // composite PK
|
||||
});
|
||||
export const offer_services = pgTable("offer_services", {
|
||||
id, name, price, transformation_description, active
|
||||
});
|
||||
```
|
||||
|
||||
<!-- From src/lib/analytics-queries.ts — JS computation pattern to mirror for forecast: -->
|
||||
```typescript
|
||||
// getMonthlyCollected() pattern: query → JS array fill → return
|
||||
const byMonth: number[] = Array(12).fill(0);
|
||||
for (const row of rows) {
|
||||
byMonth[(row.month as number) - 1] = parseFloat(row.total);
|
||||
}
|
||||
```
|
||||
|
||||
<!-- From src/components/client-dashboard.tsx — where to inject OffersSection: -->
|
||||
```tsx
|
||||
// In the sidebar <aside> after the Documenti section, or in main content
|
||||
// The dashboard receives: view: ClientView, token: string, comments: Comment[]
|
||||
// ClientView wraps ProjectView data via projectViewToClientView() adapter
|
||||
// THEREFORE: extend ClientView to include activeOffers, and extend the adapter in client/[token]/page.tsx
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Extend client-view.ts + forecast-queries.ts</name>
|
||||
<files>
|
||||
src/lib/client-view.ts
|
||||
src/lib/forecast-queries.ts
|
||||
</files>
|
||||
|
||||
<read_first>
|
||||
- src/lib/client-view.ts — read the FULL file; understand ProjectView interface, getProjectView() function structure, import block
|
||||
- src/lib/analytics-queries.ts — read lines 1-60 for the JS computation pattern
|
||||
- src/db/schema.ts — confirm project_offers, offer_micros, offer_micro_services, offer_services column names
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
**Extend `src/lib/client-view.ts`:**
|
||||
|
||||
1. Add these imports to the existing import block at the top:
|
||||
```typescript
|
||||
import { project_offers, offer_micros, offer_micro_services, offer_services } from "@/db/schema";
|
||||
import { sql } from "drizzle-orm"; // sql may already be imported — check first
|
||||
```
|
||||
|
||||
2. Add `activeOffers` to `ProjectView` interface (AFTER the existing `global_progress_pct` field):
|
||||
```typescript
|
||||
activeOffers?: Array<{
|
||||
id: string;
|
||||
public_name: string; // offer_micros.public_name — NEVER internal_name
|
||||
cumulative_price: string; // sum of assigned offer_services.price
|
||||
accepted_total: string | null;
|
||||
}>;
|
||||
```
|
||||
|
||||
3. Also extend `ClientView` interface (AFTER `global_progress_pct` field):
|
||||
```typescript
|
||||
activeOffers?: Array<{
|
||||
id: string;
|
||||
public_name: string;
|
||||
cumulative_price: string;
|
||||
accepted_total: string | null;
|
||||
}>;
|
||||
```
|
||||
|
||||
4. In `getProjectView()`, after the existing `notesRows` query and before the `commentsRows` query, add two new queries in a parallel `Promise.all`:
|
||||
|
||||
```typescript
|
||||
// Fetch active offers for this project (client-safe fields only — never internal_name)
|
||||
const projectOfferRows = await db
|
||||
.select({
|
||||
id: project_offers.id,
|
||||
public_name: offer_micros.public_name, // EXPLICITLY public_name, never internal_name
|
||||
accepted_total: project_offers.accepted_total,
|
||||
micro_id: project_offers.micro_id,
|
||||
})
|
||||
.from(project_offers)
|
||||
.innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id))
|
||||
.where(eq(project_offers.project_id, projectId));
|
||||
|
||||
// Cumulative price per micro (sum of assigned services)
|
||||
const microIds = projectOfferRows.map((o) => o.micro_id);
|
||||
const cumulativePriceRows = microIds.length === 0 ? [] : await db
|
||||
.select({
|
||||
micro_id: offer_micro_services.micro_id,
|
||||
cumulative_price: sql<string>`coalesce(sum(${offer_services.price}::numeric), 0)`,
|
||||
})
|
||||
.from(offer_micro_services)
|
||||
.innerJoin(offer_services, eq(offer_micro_services.service_id, offer_services.id))
|
||||
.where(inArray(offer_micro_services.micro_id, microIds))
|
||||
.groupBy(offer_micro_services.micro_id);
|
||||
|
||||
const cumulMap = new Map(cumulativePriceRows.map((r) => [r.micro_id, r.cumulative_price]));
|
||||
|
||||
const activeOffers = projectOfferRows.map((o) => ({
|
||||
id: o.id,
|
||||
public_name: o.public_name,
|
||||
cumulative_price: cumulMap.get(o.micro_id) ?? "0",
|
||||
accepted_total: o.accepted_total ? String(o.accepted_total) : null,
|
||||
}));
|
||||
```
|
||||
|
||||
5. In the return statement of `getProjectView()`, add `activeOffers` to the returned object:
|
||||
```typescript
|
||||
return {
|
||||
// ...existing fields...
|
||||
global_progress_pct,
|
||||
activeOffers: activeOffers.length > 0 ? activeOffers : undefined,
|
||||
};
|
||||
```
|
||||
|
||||
**Create `src/lib/forecast-queries.ts`:**
|
||||
|
||||
```typescript
|
||||
import { db } from "@/db";
|
||||
import { project_offers, offer_micros } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export type ForecastMonth = {
|
||||
year: number;
|
||||
month: number; // 1-12
|
||||
label: string; // "Giu 2026"
|
||||
total: number;
|
||||
};
|
||||
|
||||
export async function getRevenueForecast12Months(): Promise<ForecastMonth[]> {
|
||||
// Load all project offers with micro duration info
|
||||
const offers = await db
|
||||
.select({
|
||||
start_date: project_offers.start_date,
|
||||
duration_months: offer_micros.duration_months,
|
||||
accepted_total: project_offers.accepted_total,
|
||||
})
|
||||
.from(project_offers)
|
||||
.innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id));
|
||||
|
||||
// Build 12-month bucket array starting from current month
|
||||
const now = new Date();
|
||||
const buckets: ForecastMonth[] = Array.from({ length: 12 }, (_, i) => {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() + i, 1);
|
||||
return {
|
||||
year: d.getFullYear(),
|
||||
month: d.getMonth() + 1,
|
||||
label: d.toLocaleDateString("it-IT", { month: "short", year: "numeric" }),
|
||||
total: 0,
|
||||
};
|
||||
});
|
||||
|
||||
for (const offer of offers) {
|
||||
// Skip offers with no accepted_total — they contribute 0 to forecast
|
||||
if (!offer.accepted_total) continue;
|
||||
const total = parseFloat(String(offer.accepted_total));
|
||||
if (isNaN(total) || total <= 0) continue;
|
||||
|
||||
const perMonth = total / offer.duration_months;
|
||||
const start = new Date(offer.start_date);
|
||||
|
||||
for (let m = 0; m < offer.duration_months; m++) {
|
||||
const offerMonth = new Date(start.getFullYear(), start.getMonth() + m, 1);
|
||||
const bucket = buckets.find(
|
||||
(b) => b.year === offerMonth.getFullYear() && b.month === offerMonth.getMonth() + 1
|
||||
);
|
||||
if (bucket) bucket.total += perMonth;
|
||||
}
|
||||
}
|
||||
|
||||
// Round totals to 2 decimal places
|
||||
buckets.forEach((b) => { b.total = Math.round(b.total * 100) / 100; });
|
||||
|
||||
return buckets;
|
||||
}
|
||||
```
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `npx tsc --noEmit` exits 0
|
||||
- `grep "activeOffers" src/lib/client-view.ts` returns at least 3 matches (interface + query + return)
|
||||
- `grep -v "internal_name" src/lib/client-view.ts | grep "offer_micros"` — offer_micros is only referenced for public_name (SECURITY: verify internal_name never appears in getProjectView query context for offers)
|
||||
- `grep "internal_name" src/lib/client-view.ts` returns 0 matches related to offer queries (it should NOT appear anywhere in the client-view offer code)
|
||||
- File `src/lib/forecast-queries.ts` exists
|
||||
- `grep "getRevenueForecast12Months\|ForecastMonth" src/lib/forecast-queries.ts` returns 2 matches
|
||||
- `grep "project_offers.accepted_total\|offer.accepted_total" src/lib/forecast-queries.ts` matches (forecast uses offer-level total, not projects.accepted_total)
|
||||
</acceptance_criteria>
|
||||
|
||||
<done>client-view.ts extended with activeOffers (public_name only); forecast-queries.ts created with JS bucket algorithm; TypeScript compiles</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: OffersSection component + client dashboard wiring + /admin/forecast page</name>
|
||||
<files>
|
||||
src/components/client/OffersSection.tsx
|
||||
src/components/client-dashboard.tsx
|
||||
src/app/client/[token]/page.tsx
|
||||
src/app/admin/forecast/page.tsx
|
||||
</files>
|
||||
|
||||
<read_first>
|
||||
- src/components/client-dashboard.tsx — read FULL file (understand ClientDashboardProps, ClientView shape, sidebar layout)
|
||||
- src/app/client/[token]/page.tsx — read FULL file (understand projectViewToClientView adapter — must extend it)
|
||||
- src/components/payment-status.tsx — read first 30 lines for the sidebar card pattern (styling reference)
|
||||
- src/lib/forecast-queries.ts — confirm ForecastMonth shape (just created in Task 1)
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
**Create `src/components/client/OffersSection.tsx`:**
|
||||
|
||||
This is a pure presentational RSC-compatible component (no client directives needed):
|
||||
|
||||
```typescript
|
||||
interface ActiveOffer {
|
||||
id: string;
|
||||
public_name: string; // micro offer public name — never internal_name
|
||||
cumulative_price: string; // sum of service prices
|
||||
accepted_total: string | null;
|
||||
}
|
||||
|
||||
interface OffersSectionProps {
|
||||
offers: ActiveOffer[];
|
||||
}
|
||||
|
||||
export function OffersSection({ offers }: OffersSectionProps) {
|
||||
if (offers.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{offers.map((offer) => (
|
||||
<div key={offer.id} className="bg-white rounded-lg border border-[#e5e5e5] p-4">
|
||||
<p className="text-sm font-semibold text-[#1a1a1a]">{offer.public_name}</p>
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#71717a]">Valore incluso</span>
|
||||
<span className="text-xs font-mono text-[#1a1a1a]">
|
||||
€{parseFloat(offer.cumulative_price).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
{offer.accepted_total && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-[#1a1a1a]">Prezzo finale</span>
|
||||
<span className="text-sm font-bold text-[#1A463C]">
|
||||
€{parseFloat(offer.accepted_total).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Extend `src/components/client-dashboard.tsx`:**
|
||||
|
||||
Read the full file. Make two targeted changes:
|
||||
|
||||
1. Add import at the top:
|
||||
```typescript
|
||||
import { OffersSection } from './client/OffersSection';
|
||||
```
|
||||
|
||||
2. In the sidebar `<aside>` section, add an "Offerte Attive" block after the Pagamenti block, conditionally rendered when `view.activeOffers` exists and is non-empty:
|
||||
|
||||
```tsx
|
||||
{view.activeOffers && view.activeOffers.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">Offerte Attive</h2>
|
||||
<OffersSection offers={view.activeOffers} />
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
Insert this block between the Pagamenti block and the Documenti block in the sidebar.
|
||||
|
||||
**Extend `src/app/client/[token]/page.tsx`:**
|
||||
|
||||
Read the full file. Extend the `projectViewToClientView` adapter function to map `activeOffers` from `ProjectView` to `ClientView`:
|
||||
|
||||
```typescript
|
||||
// In projectViewToClientView() function, add to the return object:
|
||||
activeOffers: view.activeOffers,
|
||||
```
|
||||
|
||||
No other changes to this file are needed.
|
||||
|
||||
**Create `src/app/admin/forecast/page.tsx`:**
|
||||
|
||||
RSC page — no "use client". Fetches forecast data server-side and renders a table:
|
||||
|
||||
```typescript
|
||||
import { getRevenueForecast12Months } from "@/lib/forecast-queries";
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
export default async function ForecastPage() {
|
||||
const forecast = await getRevenueForecast12Months();
|
||||
const totalForecast = forecast.reduce((sum, m) => sum + m.total, 0);
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto py-8 px-4">
|
||||
<h1 className="text-2xl font-bold text-[#1a1a1a] mb-2">Revenue Forecast</h1>
|
||||
<p className="text-sm text-[#71717a] mb-6">
|
||||
Proiezione 12 mesi basata sulle offerte attive, i loro accepted_total e le durate in mesi.
|
||||
Ogni offerta distribuisce il suo totale equamente per i mesi di durata a partire dalla data di inizio.
|
||||
</p>
|
||||
|
||||
<div className="bg-white rounded-lg border border-[#e5e7eb] overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-[#71717a] uppercase tracking-wider">
|
||||
Mese
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-semibold text-[#71717a] uppercase tracking-wider">
|
||||
Fatturato previsto
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-semibold text-[#71717a] uppercase tracking-wider">
|
||||
Barra
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#e5e7eb]">
|
||||
{forecast.map((month) => {
|
||||
const maxTotal = Math.max(...forecast.map((m) => m.total), 1);
|
||||
const pct = Math.round((month.total / maxTotal) * 100);
|
||||
return (
|
||||
<tr key={`${month.year}-${month.month}`} className="hover:bg-[#f9f9f9]">
|
||||
<td className="px-4 py-3 text-[#1a1a1a] capitalize">{month.label}</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-[#1a1a1a]">
|
||||
{month.total > 0 ? `€${month.total.toFixed(2)}` : <span className="text-[#71717a]">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{month.total > 0 && (
|
||||
<div className="flex justify-end items-center">
|
||||
<div
|
||||
className="bg-[#1A463C] h-2 rounded"
|
||||
style={{ width: `${pct}%`, minWidth: "4px", maxWidth: "120px" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot className="border-t-2 border-[#e5e7eb] bg-[#f9f9f9]">
|
||||
<tr>
|
||||
<td className="px-4 py-3 text-sm font-semibold text-[#1a1a1a]">Totale 12 mesi</td>
|
||||
<td className="px-4 py-3 text-right font-mono font-bold text-[#1a1a1a]">
|
||||
€{totalForecast.toFixed(2)}
|
||||
</td>
|
||||
<td />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{forecast.every((m) => m.total === 0) && (
|
||||
<p className="text-sm text-[#71717a] mt-4">
|
||||
Nessuna offerta con accepted_total trovata. Assegna micro-offerte ai progetti e imposta il totale accettato.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `npx tsc --noEmit` exits 0
|
||||
- `grep "OffersSection" src/components/client-dashboard.tsx` returns 2 matches (import + usage)
|
||||
- `grep "activeOffers" src/app/client/\[token\]/page.tsx` returns at least 1 match (adapter wiring)
|
||||
- File `src/components/client/OffersSection.tsx` exists
|
||||
- File `src/app/admin/forecast/page.tsx` exists
|
||||
- `grep "getRevenueForecast12Months" src/app/admin/forecast/page.tsx` returns 1 match
|
||||
- `grep "internal_name" src/components/client/OffersSection.tsx` returns 0 matches (security: internal_name never in client component)
|
||||
- `grep "internal_name" src/app/admin/forecast/page.tsx` returns 0 matches (forecast page is admin-only but still uses only aggregates)
|
||||
</acceptance_criteria>
|
||||
|
||||
<done>OffersSection renders public_name + cumulative_price + accepted_total; client dashboard wired; /admin/forecast shows 12-month table; TypeScript compiles</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| DB → getProjectView() → client browser | Client-facing data path — must never include internal names or individual prices |
|
||||
| DB → getRevenueForecast12Months() → admin browser | Admin-only read path — acceptable to show all offer data |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-05-09 | Information Disclosure | getProjectView() activeOffers query | mitigate | Explicit column selection: `offer_micros.public_name` only — never `offer_micros.internal_name`; enforce via acceptance criteria grep check |
|
||||
| T-05-10 | Information Disclosure | OffersSection component | mitigate | Component receives `public_name` only; no prop for internal_name; grep gate in acceptance_criteria confirms 0 occurrences |
|
||||
| T-05-11 | Information Disclosure | /admin/forecast page | accept | Route is under /admin/* — Auth.js middleware session guard; low risk |
|
||||
| T-05-12 | Tampering | Forecast computation (JS client-side alternative) | mitigate | `getRevenueForecast12Months()` runs server-side in RSC — no client-side computation; forecast data never sent for client-side calculation |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
After both tasks complete:
|
||||
- `npx tsc --noEmit` exits 0
|
||||
- Client dashboard: If a project has active offers with accepted_total set → "Offerte Attive" section appears in sidebar with public name and price
|
||||
- Client dashboard: Project with no offers → no offer section visible
|
||||
- `/admin/forecast` → 12-month table renders; months with active offers show totals; empty-state message shown if no accepted_total set
|
||||
- Security check: `grep "internal_name" src/lib/client-view.ts` — must not appear in the offer-related query section of getProjectView()
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
1. Client dashboard shows active offers with public_name, cumulative_price, accepted_total — never internal_name
|
||||
2. Client sees nothing when no offers are assigned to their project
|
||||
3. Admin forecast page shows 12-month breakdown with totals
|
||||
4. Forecast correctly uses project_offers.accepted_total (not projects.accepted_total)
|
||||
5. Months outside active offer windows show 0/empty
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/05-offer-system/05-04-SUMMARY.md` using the summary template.
|
||||
</output>
|
||||
Reference in New Issue
Block a user