- Add ServiceWithTags type = Service & { tags: string[] }
- getAllServices() now left-joins tags scoped to entity_type=services
- Tags aggregated per service, ordered by service name then tag name
- Add polymorphic tags table (entity_type/entity_id/name) with unique
index on (entity_type, entity_id, name) and lookup index on
(entity_type, entity_id) — pattern follows comments table (D-05/D-06/D-07)
- Add Tag/NewTag types and tagsRelations (no direct FK, query-time join)
- Hand-write src/db/migrations/0006_add_tags_table.sql following the
established project convention (drizzle-kit generate is unusable here —
meta/_journal.json snapshots out of sync since 0001, pre-existing since
Phase 8) and add journal entry for 0006_add_tags_table
- Add scripts/push-11-tags-migration.ts (idempotent CREATE TABLE/INDEX IF
NOT EXISTS) with production deploy note for manual SSH+docker exec apply
Lead detail 500'd because params was accessed synchronously;
Next 16 App Router passes params as a Promise. Same pattern
already used correctly in /quote/[token].
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stage C of staged Phase 10 redo. Restores dangling phase10-wip work:
- schema.ts: leads expansion + activities/reminders tables + relations
- lead-service.ts / lead-validators.ts service layer
- /admin/leads list + detail + actions, LeadTable/LeadDetail/LeadForm
- LogActivityModal, SendQuoteModal, FollowUpWidget on dashboard
- migration 0005 (applied to prod DB in Stage B, along with
previously-missing 0001/0003/0004 — root cause of all post-deploy
500s: prod DB had no migrations applied since 0000)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stage A of staged Phase 10 redo: dependencies and shared UI only,
no schema or DB-querying code. Verifies Coolify rebuilds clean
before migration (Stage B) and CRM code (Stage C).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- layout.tsx: Simple centered public layout (no auth header) with quote branding
- page.tsx: Server component validating token, handling quote states (draft/accepted/rejected)
- actions.ts: Server actions for acceptQuote() and rejectQuote() with immutable accepted_at enforcement
- Validates token format (21-char nanoid), returns 404 for invalid tokens
- Shows 'already accepted' message if quote was previously accepted
- Integrates with QuoteMultistep component for active quotes
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- QuoteMultistep.tsx (110 lines): Parent component managing step state and navigation with visual indicator
- QuoteStep1Overview.tsx: Read-only overview with offer name, total price, and phase list
- QuoteStep2Selection.tsx: Phase/service listing (read-only MVP) with calculated total
- QuoteStep3Summary.tsx: Summary card with optional email and notes capture, Accept/Reject buttons
All components use shadcn/ui with Tailwind styling and follow accessibility patterns.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- Enhanced proxy.ts with rate limit check for /quote/[token] routes
- Enforces 3 views per minute per IP address (MVP in-memory store)
- Returns 429 Too Many Requests when limit exceeded
- Rate limit utility supports distributed use (ready for Upstash Redis in Phase 10)
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- src/lib/quote-actions.ts: createQuote server action with nanoid tokens and atomic DB transaction
- src/components/admin/quotes/QuoteBuilderForm.tsx: Two-column form (left: client/offer/price inputs, right: live preview)
- src/components/admin/quotes/OfferSelector.tsx: Grouped dropdown for macro/micro offer selection
- src/components/admin/quotes/PriceOverrideInput.tsx: Price input with server validation feedback
- src/components/admin/quotes/QuotePreview.tsx: Live preview card showing offer details and calculated total
- src/app/admin/quotes/new/page.tsx: Quote builder page rendering form with data from DB
- src/app/admin/quotes/new/actions.ts: Re-export of createQuote for page-level action imports
- src/lib/admin-queries.ts: Added getAllOfferMacrosWithMicros() query for form population
Features:
- Admin selects client and offer (required fields)
- Form validates on blur and displays success/error states
- Server action validates data and rejects if total mismatch
- On success, displays public /quote/[token] link with copy button
- Quote saved as draft state with immutable accepted_total
Completes Phase 9 Plan 2 Wave 1: Admin UI Layer
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- Add getOfferPhases(microId) — get all phases for an offer micro
- Add getOfferPhaseServices(phaseId) — get all services assigned to a phase
- Add getQuoteById(quoteId) — get full quote header by ID
- Add getQuoteByToken(token) — get quote by public token
- Add getQuotesByClient(clientId) — get all quotes for a client
- Update imports to include new tables and types
- All queries properly typed with Drizzle ORM
- Build verified with npm run build
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- Create SQL migration 0003_offer_phases_quote_templates.sql
- Creates offers_phases, offer_phase_services, quotes, leads tables
- Adds new columns to quote_items, projects, phases (additive-only)
- Includes proper FK constraints, indexes, and CHECK constraints
- ZERO DATA LOSS: No drops, no truncates — fully reversible
- Create validation script scripts/validate-phase8-migration.ts
- Checks all 4 new tables exist
- Verifies 7 new columns added to existing tables
- Validates FK references intact
- Exit code 0 on success, 1 on failure
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- Task 1: Update getAllServices() and activeServices queries to read from services table instead of service_catalog
- Both ClientFullDetail and ProjectFullDetail now return Service[] (not ServiceCatalog[])
- Quote items label join remains unchanged — still references service_catalog for historical quote_items.service_id FK integrity
- Task 2: Rewire /admin/catalog actions and components to operate on services table
- src/app/admin/catalog/actions.ts: createService/updateService/toggleServiceActive now write to services with optional category field
- ServiceTable.tsx: Updated to Service[] type, added category column rendering
- ServiceForm.tsx: Added optional category input field
- QuoteTab.tsx: Updated activeServices type annotation to Service[]
- New services created via /admin/catalog have migrated_from=null, migrated_id=null (not migrated)
- TypeScript compiles successfully
- npm run build: PASS
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- 4 KPI cards: clienti attivi, revenue totale, progetti in corso, pagamenti in sospeso
- Activity feed: last 10 events with icon, label, detail, timestamp
- Page is RSC (async server component, no use client)
- revalidate = 0 for fresh data on each request
- KPI queries: clienti attivi, revenue totale, pagamenti in sospeso, progetti in corso
- Activity feed: last 10 events across clients, projects, deliverables, time entries
- Uses isNotNull for nullable timestamp filters, Promise.all for parallel fetch
- New /admin/clients/page.tsx contains full client list with getAllClientsWithPayments
- Toggle archived link updated from /admin?archived=1 to /admin/clients?archived=1
- /admin/page.tsx replaced with redirect() to /admin/clients
- AdminLayout now uses flex-row: AdminSidebar (left) + main (right)
- Removed NavBar import and max-w-5xl centered constraint
- Main content area takes remaining width with px-8 py-8 padding
- Add activeOffers field to ProjectView and ClientView interfaces (public_name only — never internal_name)
- Add offer queries to getProjectView(): project_offers JOIN offer_micros (explicit public_name selection), cumulative price via offer_micro_services JOIN offer_services
- Create src/lib/forecast-queries.ts with ForecastMonth type and getRevenueForecast12Months() 12-bucket JS algorithm
- Forecast spreads accepted_total evenly across duration_months from start_date
- Add ClientActiveOfferSummary type to admin-queries.ts
- Add getClientActiveOffers() function joining project_offers with offer_micros
- Only selects public_name (never internal_name) per CLAUDE.md constraint
- Update /admin/clients/[id] to fetch active offers in parallel with project data
- Add Offerte Attive section at bottom of client detail page
- Create OffersTab.tsx client component with assign form and offer list
- Inline accepted_total editing via onBlur handler
- Add Offerte tab trigger and content to project workspace page
- Destructure projectOffers and availableMicros from getProjectFullDetail result
- Add offer_micros, project_offers imports to admin-queries.ts
- Add ProjectOfferWithMicro type to admin-queries.ts
- Extend ProjectFullDetail type with projectOffers + availableMicros fields
- Extend getProjectFullDetail() to query project offers and available micros in parallel
- Add z import and project_offers import to project-actions.ts
- Add assignOfferToProject, removeProjectOffer, updateProjectOfferTotal server actions
- All three actions call requireAdmin() and revalidate /admin/forecast
- Add primaryKey to drizzle-orm/pg-core import
- Define offer_macros, offer_micros, offer_services, offer_micro_services, project_offers
- Add Drizzle relations for all five new tables (+ projectOffers in projectsRelations)
- Export TypeScript infer types for all new tables
- initProjectPayments: crea Acconto 50%/Saldo 50% se il progetto non ha pagamenti
- splitPayment: divide un pagamento in due rate con importi custom
- SplitPaymentForm: form client interattivo con anteprima live della rata 2
- PaymentsTab: stato vuoto con CTA + pulsante Dividi per ogni riga
- projectId prop opzionale per attivare le feature solo nel contesto progetto
Root cause: INTERNAL_SECRET was on same line as HOST= in .env (no trailing
newline when appended), so proxy middleware got empty secret and rejected
all /api/internal/validate-* calls → rewrote every /client/* request to
/not-found without running the page component.
- Add requireAdmin() to all unprotected admin server actions
(clients/new, clients/[id], timer-actions — 17 functions total)
- Protect /api/internal/* endpoints with X-Internal-Secret header
(proxy.ts sends it; routes reject requests without it)
- Randomize auto-generated client slugs with 4-char suffix
to prevent enumeration via predictable name-based slugs
- Add in-memory rate limiting to /api/client/approve (20/min)
and /api/client/comment (10/min) per IP
- Add security headers: X-Frame-Options, X-Content-Type-Options,
Referrer-Policy, Permissions-Policy, X-DNS-Prefetch-Control
- Reduce JWT session from 30 days to 7 days with daily rotation
- Remove hardcoded NEXTAUTH_URL from Dockerfile (pass via Coolify env)
- Genericize client API error messages to not leak data structure
- Update .env.example with all required variables including INTERNAL_SECRET
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Middleware (proxy.ts): switch internal API calls to localhost instead of
request.url — avoids Docker hairpin NAT issues where the container can't
reach its own external hostname via Traefik
- ProjectRow timer: pass projectId={project.id} to TimerCell so it calls
startTimer(projectId) directly instead of startTimerForClient(project.id)
- Admin client list: add slug field to ClientWithPayments + show slug link
when available (falls back to token) — so admins see the human-readable URL
- Analytics contracted: sum projects.accepted_total (authoritative) instead
of clients.accepted_total (always 0 in multi-project architecture)
- createClient: auto-generate slug from client name at creation time
(e.g. "Mario Rossi" → "mario-rossi"), with -2/-3 suffix on conflict
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Admin client list: remove Timer column, rename Acconto→Importo incassato
and Saldo→Importo da saldare (calculated from payment amounts, not badges)
- Project workspace timer: pass projectId prop to TimerCell so it calls
startTimer(projectId) directly instead of startTimerForClient(projectId)
which was failing with "nessun progetto trovato"
- Public client page: pass client.token (real DB token) not the URL path
segment (which may be a slug) to ClientDashboard → fixes approve/chat APIs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
NEXT_PHASE=phase-production-build is set by Next.js during static analysis.
Route handlers are analyzed but not executed, so the DB client is never
actually used — the placeholder URL never connects to anything.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>