Wave 1: 07-01-PLAN.md — Unified services table (audit trail, backfill, validation) Wave 2: 07-02-PLAN.md — Admin catalog CRUD rewire (preserve historical references) Ready for execution: /gsd-execute-phase 7 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
15 KiB
phase, verified, status, score, overrides_applied, human_verification
| phase | verified | status | score | overrides_applied | human_verification | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 05-offer-system | 2026-05-30T00:00:00Z | human_needed | 7/7 must-haves verified | 0 |
|
Phase 5: Offer System Verification Report
Phase Goal: Implement a complete hierarchical Offer System — macro-offers -> micro-offers -> services — with admin catalog CRUD, project assignment, client dashboard visibility (public_name only), and 12-month revenue forecast. Verified: 2026-05-30T00:00:00Z Status: human_needed Re-verification: No — initial verification
Goal Achievement
Observable Truths
| # | Truth | Status | Evidence |
|---|---|---|---|
| 1 | Five tables exist in schema: offer_macros, offer_micros, offer_services, offer_micro_services, project_offers | VERIFIED | All five pgTable exports confirmed in src/db/schema.ts lines 199-268 |
| 2 | No existing table (clients, projects, payments, phases) modified or dropped | VERIFIED | All four original tables intact at schema.ts lines 14, 39, 53, 109; no DROP or ALTER COLUMN found |
| 3 | /admin/offers page with CRUD for macros, micros, and services | VERIFIED | src/app/admin/offers/page.tsx exists; calls getCatalogWithMicros + getAllOfferServices; forms wired to createMacro, createMicro, createOfferService, deleteMacro, deleteMicro, toggleOfferServiceActive |
| 4 | OffersTab in admin project workspace for offer assignment | VERIFIED | src/components/admin/tabs/OffersTab.tsx exists; TabsTrigger value="offers" at project page line 77; TabsContent value="offers" at line 141; OffersTab imported and receiving projectOffers + availableMicros props |
| 5 | Client dashboard shows offer public_name ONLY — never internal_name | VERIFIED | getProjectView() selects only offer_micros.public_name (client-view.ts line 282); OffersSection.tsx has zero internal_name occurrences; ClientView.activeOffers type exposes only public_name, cumulative_price, accepted_total |
| 6 | /admin/forecast shows 12-month revenue projection | VERIFIED | src/app/admin/forecast/page.tsx exists; calls getRevenueForecast12Months(); renders 12-row table with month label, projected total, proportional bar, and totals footer |
| 7 | Forecast computed without DB persistence | VERIFIED | src/lib/forecast-queries.ts contains zero db.insert / db.update calls; algorithm builds 12 in-memory buckets and distributes accepted_total / duration_months per month via JS loop |
Score: 7/7 truths verified
Required Artifacts
| Artifact | Expected | Status | Details |
|---|---|---|---|
src/db/schema.ts |
Five new pgTable definitions appended | VERIFIED | All five tables present with correct FK constraints (offer_micros.macro_id cascade, project_offers.micro_id restrict, composite PK on offer_micro_services) |
src/lib/offer-queries.ts |
Read queries: getCatalogWithMicros, getAllOfferServices, getMicroAssignedServiceIds | VERIFIED | All three functions exported; real DB queries with joins, no hardcoded data |
src/app/admin/offers/actions.ts |
Server actions for all offer catalog mutations | VERIFIED | 7 exported actions: createMacro, deleteMacro, createMicro, deleteMicro, createOfferService, toggleOfferServiceActive, updateMicroOfferServices |
src/app/admin/offers/page.tsx |
RSC page listing macros, micros, offer services | VERIFIED | RSC page with revalidate = 0; three sections fully implemented |
src/components/admin/offers/ServiceCheckboxList.tsx |
Client component for many-to-many service assignment | VERIFIED | "use client"; useState Set; useTransition + router.refresh(); wired to updateMicroOfferServices |
src/components/admin/tabs/OffersTab.tsx |
Project workspace tab for offer assignment | VERIFIED | "use client"; assignOfferToProject, removeProjectOffer, updateProjectOfferTotal all called |
src/app/admin/projects/project-actions.ts |
Three new offer server actions | VERIFIED | assignOfferToProject, removeProjectOffer, updateProjectOfferTotal — all call requireAdmin(); all revalidate /admin/forecast |
src/lib/admin-queries.ts |
Extended with ProjectOfferWithMicro type, getProjectFullDetail extension, getClientActiveOffers | VERIFIED | Lines 189-202 (type); lines 601-602 (return); lines 646-687 (getClientActiveOffers with public_name only) |
src/lib/client-view.ts |
Extended ProjectView and ClientView with activeOffers | VERIFIED | activeOffers field in both interfaces; query at lines 279-309; returned at line 354 |
src/lib/forecast-queries.ts |
getRevenueForecast12Months() with 12-bucket JS algorithm | VERIFIED | File exists; DB query + JS bucket fill; rounds to 2 decimal places; no DB writes |
src/components/client/OffersSection.tsx |
Client-facing offer card (public_name only) | VERIFIED | Zero internal_name occurrences; renders public_name, cumulative_price, accepted_total |
src/app/admin/forecast/page.tsx |
Admin /admin/forecast RSC page | VERIFIED | RSC page; calls getRevenueForecast12Months(); table with month/total/bar/footer |
src/components/admin/NavBar.tsx |
NavBar with /admin/offers and /admin/forecast links | VERIFIED | Both links confirmed at lines 21 and 27 |
Key Link Verification
| From | To | Via | Status | Details |
|---|---|---|---|---|
src/app/admin/offers/page.tsx |
src/lib/offer-queries.ts |
getCatalogWithMicros() import | WIRED | Direct import; called in Promise.all |
src/app/admin/offers/actions.ts |
src/db/schema.ts |
offer_macros, offer_micros, offer_services, offer_micro_services imports | WIRED | All four tables imported and used in mutations |
src/components/admin/offers/ServiceCheckboxList.tsx |
src/app/admin/offers/actions.ts |
updateMicroOfferServices import | WIRED | Imported and called in toggle handler |
src/components/admin/tabs/OffersTab.tsx |
src/app/admin/projects/project-actions.ts |
assignOfferToProject import | WIRED | All three offer actions imported and called |
src/app/admin/projects/[id]/page.tsx |
src/lib/admin-queries.ts |
getProjectFullDetail() — includes projectOffers | WIRED | projectOffers + availableMicros destructured from detail; passed to OffersTab |
src/app/admin/clients/[id]/page.tsx |
src/lib/admin-queries.ts |
getClientActiveOffers() import | WIRED | Parallel fetch at line 14-17; activeOffers rendered at lines 112-130 |
src/lib/client-view.ts |
src/db/schema.ts |
project_offers + offer_micros join in getProjectView() | WIRED | Explicit select on public_name only; result mapped to activeOffers |
src/app/client/[token]/page.tsx |
src/lib/client-view.ts |
projectViewToClientView adapter | WIRED | activeOffers: view.activeOffers at line 69 |
src/components/client-dashboard.tsx |
src/components/client/OffersSection.tsx |
OffersSection import | WIRED | Imported at line 10; rendered conditionally at lines 58-63 |
src/app/admin/forecast/page.tsx |
src/lib/forecast-queries.ts |
getRevenueForecast12Months() import | WIRED | Direct import; called in RSC function body |
Data-Flow Trace (Level 4)
| Artifact | Data Variable | Source | Produces Real Data | Status |
|---|---|---|---|---|
src/app/admin/offers/page.tsx |
catalog, allServices | getCatalogWithMicros(), getAllOfferServices() — DB queries on offer_macros, offer_micros, offer_services | Yes — live DB SELECT with joins | FLOWING |
src/components/admin/tabs/OffersTab.tsx |
projectOffers, availableMicros | getProjectFullDetail() extended queries on project_offers JOIN offer_micros | Yes — live DB SELECT | FLOWING |
src/components/client/OffersSection.tsx |
offers (activeOffers) | getProjectView() -> projectOfferRows (project_offers JOIN offer_micros) + cumulativePriceRows (SQL SUM) | Yes — live DB queries; returns undefined when empty (not []) | FLOWING |
src/app/admin/forecast/page.tsx |
forecast | getRevenueForecast12Months() -> DB SELECT project_offers JOIN offer_micros + JS bucket algorithm | Yes — reads from DB; pure JS computation for monthly split | FLOWING |
Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|---|---|---|---|
| TypeScript compiles without errors | npx tsc --noEmit |
Exit 0, no output | PASS |
| Five offer tables defined in schema | grep "^export const offer_macros|offer_micros|offer_services|offer_micro_services|project_offers" schema.ts |
5 matches | PASS |
| requireAdmin in all offer actions | grep -c "requireAdmin" src/app/admin/offers/actions.ts |
8 (definition + 7 calls) | PASS |
| revalidatePath in all offer actions | grep -c "revalidatePath" src/app/admin/offers/actions.ts |
8 | PASS |
| No internal_name in OffersSection | grep "internal_name" src/components/client/OffersSection.tsx |
0 matches | PASS |
| No internal_name in forecast page | grep "internal_name" src/app/admin/forecast/page.tsx |
0 matches | PASS |
| activeOffers adapter wired | grep "activeOffers" src/app/client/[token]/page.tsx |
1 match: activeOffers: view.activeOffers |
PASS |
| No DB writes in forecast-queries.ts | grep "db.insert|db.update" src/lib/forecast-queries.ts |
0 matches | PASS |
| NavBar links present | grep "/admin/offers|/admin/forecast" NavBar.tsx |
2 matches | PASS |
Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
|---|---|---|---|---|
| OFFER-01 | 05-02 | Admin crea macro-offerte con nome interno, nome pubblico, promessa trasformazione | SATISFIED | createMacro action + form in /admin/offers page; Zod schema validates all three fields |
| OFFER-02 | 05-02 | Macro-offerte hanno micro-offerte figlie con nome, promessa, durata in mesi | SATISFIED | createMicro action + nested form per macro in /admin/offers page; duration_months field included |
| OFFER-03 | 05-02 | Admin crea servizi con nome, prezzo, descrizione; assegnabili a micro-offerte via multi-select | SATISFIED | createOfferService action; ServiceCheckboxList with updateMicroOfferServices (delete+re-insert); offer_micro_services junction table |
| OFFER-04 | 05-03 | Admin assegna micro-offerte a progetto; vede offerte attive per progetto e cliente | SATISFIED | OffersTab in project workspace; assignOfferToProject/removeProjectOffer actions; getClientActiveOffers on /admin/clients/[id] |
| OFFER-05 | 05-04 | Dashboard cliente mostra offerte con nome pubblico, prezzo cumulativo, prezzo finale; mai internal_name | SATISFIED | OffersSection renders public_name + cumulative_price + accepted_total; getProjectView() selects public_name only; 0 internal_name occurrences in client component |
| OFFER-06 | 05-04 | Admin ha vista forecast 12 mesi da offerte attive, durata, accepted_total; breakdown mensile | SATISFIED | /admin/forecast page with getRevenueForecast12Months(); JS bucket algorithm; 12 rows with totals footer |
All 6 requirements (OFFER-01 through OFFER-06) are SATISFIED.
Anti-Patterns Found
| File | Pattern | Severity | Impact |
|---|---|---|---|
| None found | — | — | — |
No TODO/FIXME/PLACEHOLDER comments in any Phase 5 files. No hardcoded empty arrays returned as final data. No stub implementations. No console.log-only handlers.
Note: OffersTab.tsx renders offer.micro_internal_name (line 67) — this is intentional and correct. The tab is admin-only under /admin/projects/*, which is Auth.js session-guarded. The plan's threat model (T-05-08) explicitly accepts this as correct admin-visible data.
Human Verification Required
1. Admin offer catalog round-trip
Test: Create a macro-offer at /admin/offers, add a micro-offer under it with duration_months=3, create an offer service with a price, then toggle the service checkbox to assign it to the micro. Refresh the page.
Expected: Macro appears in the list; micro is nested under it showing cumulative price equal to the service price; checkbox remains checked after refresh.
Why human: Server action + router.refresh() round-trips require a live browser session against the production DB.
2. Project offer assignment and accepted_total save
Test: At /admin/projects/[id], open the Offerte tab. Assign the micro-offer created above. Enter an accepted_total value and click away (onBlur). Refresh the page.
Expected: The micro-offer appears in the active assignments list with duration and start date. The accepted_total value persists after refresh.
Why human: onBlur inline save behavior and server action response cannot be verified without a running browser.
3. Client dashboard security — no internal_name visible
Test: Navigate to /client/[token] for a client whose project has at least one micro-offer assigned with an accepted_total set. Inspect the "Offerte Attive" sidebar block. Also inspect the page HTML source.
Expected: Only public_name (e.g. "Starter Branding") appears — never the internal_name (e.g. "Entry A"). Individual service prices do not appear. Only cumulative price and final accepted price show.
Why human: Security verification requires confirming the rendered HTML and network responses contain no internal data. Code confirms this structurally, but a live check is required for the security constraint.
4. Forecast accuracy
Test: With at least one project_offer having accepted_total=1200 and duration_months=3 starting from the current month, visit /admin/forecast.
Expected: The current month, next month, and the month after each show €400.00 (1200/3). Other months show €0 / dash.
Why human: Bucket algorithm correctness depends on real start_date values stored in production DB relative to current date.
Gaps Summary
No gaps found. All 7 must-haves are verified against the actual codebase. All 6 OFFER requirements are accounted for with satisfying implementation evidence. The phase goal is structurally achieved — human verification is required only to confirm live runtime behavior and the security property (no internal_name visible to clients).
Verified: 2026-05-30T00:00:00Z Verifier: Claude (gsd-verifier)