dc934fb04a
3 piani: 20-01 migration BLOCKING, 20-02 schema+actions, 20-03 UI. KB-01 e KB-02 coperti. Wave 1→2→3 sequenziali per vincolo migration-prima-del-codice. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
402 lines
16 KiB
Markdown
402 lines
16 KiB
Markdown
---
|
|
phase: 20-knowledge-base-cliente
|
|
plan: 02
|
|
type: execute
|
|
wave: 2
|
|
depends_on:
|
|
- 20-01
|
|
files_modified:
|
|
- src/db/schema.ts
|
|
- src/lib/lead-service.ts
|
|
- src/app/admin/leads/actions.ts
|
|
autonomous: true
|
|
requirements:
|
|
- KB-01
|
|
- KB-02
|
|
must_haves:
|
|
truths:
|
|
- "La tabella clientTranscripts è definita in schema.ts con le relazioni Drizzle corrette"
|
|
- "getTranscripts(leadId) restituisce i transcript in ordine call_date DESC"
|
|
- "addTranscript e deleteTranscript sono server actions con requireAdmin guard"
|
|
artifacts:
|
|
- path: "src/db/schema.ts"
|
|
provides: "Definizione tabella clientTranscripts + relazioni Drizzle per leads e clients"
|
|
contains: "export const clientTranscripts = pgTable"
|
|
- path: "src/lib/lead-service.ts"
|
|
provides: "Query getTranscripts(leadId) — tutti i campi incluso content completo"
|
|
exports: ["getTranscripts"]
|
|
- path: "src/app/admin/leads/actions.ts"
|
|
provides: "Server actions addTranscript e deleteTranscript con requireAdmin"
|
|
exports: ["addTranscript", "deleteTranscript"]
|
|
key_links:
|
|
- from: "src/app/admin/leads/actions.ts"
|
|
to: "src/db/schema.ts"
|
|
via: "import clientTranscripts"
|
|
pattern: "clientTranscripts"
|
|
- from: "src/lib/lead-service.ts"
|
|
to: "src/db/schema.ts"
|
|
via: "import clientTranscripts"
|
|
pattern: "getTranscripts"
|
|
---
|
|
|
|
<objective>
|
|
Aggiungere la definizione Drizzle della tabella `clientTranscripts` in schema.ts, la query `getTranscripts` in lead-service.ts e le server actions `addTranscript` / `deleteTranscript` in actions.ts.
|
|
|
|
Purpose: Fornisce il layer dati completo per la UI del piano 20-03. Phase 21 (AI) leggerà i transcript via `getTranscripts(leadId)` — la firma deve restituire tutti i campi incluso `content` completo.
|
|
|
|
Output: Schema Drizzle + query + actions pronte, build TypeScript senza errori.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/phases/20-knowledge-base-cliente/20-CONTEXT.md
|
|
@.planning/phases/20-knowledge-base-cliente/20-01-SUMMARY.md
|
|
|
|
<interfaces>
|
|
<!-- Pattern esistenti estratti da src/db/schema.ts (linee 464-643) -->
|
|
|
|
Pattern tabella CRM con FK lead_id (activities, linee 464-481):
|
|
```typescript
|
|
export const activities = pgTable("activities", {
|
|
id: text("id").primaryKey().$defaultFn(() => nanoid()),
|
|
lead_id: text("lead_id").notNull().references(() => leads.id, { onDelete: "cascade" }),
|
|
type: text("type").notNull(),
|
|
notes: text("notes").notNull(),
|
|
activity_date: timestamp("activity_date", { withTimezone: true }).notNull(),
|
|
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
});
|
|
```
|
|
|
|
Pattern relazioni (leadsRelations, linea 631-635):
|
|
```typescript
|
|
export const leadsRelations = relations(leads, ({ many }) => ({
|
|
quotes: many(quotes),
|
|
activities: many(activities),
|
|
reminders: many(reminders),
|
|
}));
|
|
|
|
export const activitiesRelations = relations(activities, ({ one }) => ({
|
|
lead: one(leads, { fields: [activities.lead_id], references: [leads.id] }),
|
|
}));
|
|
```
|
|
|
|
Pattern TypeScript types (fine file):
|
|
```typescript
|
|
export type Activity = typeof activities.$inferSelect;
|
|
export type NewActivity = typeof activities.$inferInsert;
|
|
```
|
|
|
|
Pattern query (lead-service.ts linee 51-57):
|
|
```typescript
|
|
export async function getActivityLog(leadId: string) {
|
|
return await db
|
|
.select()
|
|
.from(activities)
|
|
.where(eq(activities.lead_id, leadId))
|
|
.orderBy(desc(activities.activity_date));
|
|
}
|
|
```
|
|
|
|
Pattern requireAdmin + server action (actions.ts linee 164-205):
|
|
```typescript
|
|
async function requireAdmin() {
|
|
const session = await getServerSession(authOptions);
|
|
if (!session) throw new Error("Non autorizzato");
|
|
}
|
|
|
|
export async function updateLeadField(leadId: string, ...) {
|
|
await requireAdmin();
|
|
// ... validazione ...
|
|
await db.update(leads).set({...}).where(eq(leads.id, leadId));
|
|
revalidatePath("/admin/leads");
|
|
revalidatePath(`/admin/leads/${leadId}`);
|
|
}
|
|
```
|
|
|
|
Import correnti in actions.ts (linee 1-16):
|
|
```typescript
|
|
"use server";
|
|
import { z } from "zod";
|
|
import { db } from "@/db";
|
|
import { leads, quotes, tags } from "@/db/schema";
|
|
import { eq, and } from "drizzle-orm";
|
|
import { revalidatePath } from "next/cache";
|
|
import { getServerSession } from "next-auth";
|
|
import { authOptions } from "@/lib/auth";
|
|
```
|
|
|
|
Import correnti in lead-service.ts (linee 1-4):
|
|
```typescript
|
|
import { eq, and, isNull, desc, gte, lte, ilike, or } from "drizzle-orm";
|
|
import { db } from "@/db";
|
|
import { leads, activities, reminders, quotes } from "@/db/schema";
|
|
```
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Aggiungere clientTranscripts a src/db/schema.ts</name>
|
|
<files>src/db/schema.ts</files>
|
|
<read_first>
|
|
- src/db/schema.ts (leggere tutto il file per capire dove inserire la nuova tabella e come aggiornare le relations esistenti)
|
|
- .planning/phases/20-knowledge-base-cliente/20-CONTEXT.md (D-01, D-02 — campi esatti)
|
|
</read_first>
|
|
<action>
|
|
Aggiungere in `src/db/schema.ts` tre blocchi, rispettando la struttura esistente del file:
|
|
|
|
**1. Definizione tabella** — aggiungere dopo `// ============ REMINDERS TABLE` (dopo la chiusura della definizione reminders a linea ~499) e prima di `// ============ RELATIONS`:
|
|
|
|
```typescript
|
|
// ============ CLIENT TRANSCRIPTS TABLE (Knowledge Base — Phase 20) ============
|
|
// Transcript datati delle call, multipli per lead o cliente.
|
|
// lead_id e client_id sono entrambi nullable (D-01): un transcript può appartenere
|
|
// a un lead pre-conversione (Phase 20 UI) o a un cliente post-conversione (futuro).
|
|
// onDelete cascade su entrambe le FK — se il lead/cliente viene eliminato, i transcript vengono eliminati.
|
|
export const clientTranscripts = pgTable("client_transcripts", {
|
|
id: text("id")
|
|
.primaryKey()
|
|
.$defaultFn(() => nanoid()),
|
|
lead_id: text("lead_id")
|
|
.references(() => leads.id, { onDelete: "cascade" }),
|
|
client_id: text("client_id")
|
|
.references(() => clients.id, { onDelete: "cascade" }),
|
|
title: text("title"), // opzionale — es. "Discovery call 12 giugno"
|
|
content: text("content").notNull(), // testo grezzo illimitato (PostgreSQL text)
|
|
call_date: text("call_date").notNull(), // DATE come text "YYYY-MM-DD" — coerente col pattern date nel progetto
|
|
created_at: timestamp("created_at", { withTimezone: true })
|
|
.notNull()
|
|
.defaultNow(),
|
|
});
|
|
```
|
|
|
|
Nota su `call_date`: usare `text` invece di un tipo `date` Drizzle — il progetto gestisce le date come string ISO nei form e nel DB (vedi `activity_date` che è timestamp, ma `call_date` è date pura). Se Drizzle pg-core espone `date` nativo, usarlo; altrimenti usare `text("call_date").notNull()` per coerenza con i pattern di input da `<input type="date">` che restituisce stringhe "YYYY-MM-DD".
|
|
|
|
**2. Relazioni** — aggiungere nei blocchi relations esistenti:
|
|
|
|
Aggiornare `leadsRelations` (attuale linea ~631):
|
|
```typescript
|
|
export const leadsRelations = relations(leads, ({ many }) => ({
|
|
quotes: many(quotes),
|
|
activities: many(activities),
|
|
reminders: many(reminders),
|
|
transcripts: many(clientTranscripts), // ← aggiungere questa riga
|
|
}));
|
|
```
|
|
|
|
Aggiornare `clientsRelations` (attuale linea ~503):
|
|
```typescript
|
|
export const clientsRelations = relations(clients, ({ many }) => ({
|
|
projects: many(projects),
|
|
transcripts: many(clientTranscripts), // ← aggiungere questa riga
|
|
}));
|
|
```
|
|
|
|
Aggiungere le nuove relations dopo `remindersRelations`:
|
|
```typescript
|
|
export const clientTranscriptsRelations = relations(clientTranscripts, ({ one }) => ({
|
|
lead: one(leads, {
|
|
fields: [clientTranscripts.lead_id],
|
|
references: [leads.id],
|
|
}),
|
|
client: one(clients, {
|
|
fields: [clientTranscripts.client_id],
|
|
references: [clients.id],
|
|
}),
|
|
}));
|
|
```
|
|
|
|
**3. TypeScript types** — aggiungere in fondo al file dopo `export type Reminder`:
|
|
```typescript
|
|
export type ClientTranscript = typeof clientTranscripts.$inferSelect;
|
|
export type NewClientTranscript = typeof clientTranscripts.$inferInsert;
|
|
```
|
|
</action>
|
|
<verify>
|
|
<automated>cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `grep -c "export const clientTranscripts = pgTable" src/db/schema.ts` restituisce 1
|
|
- `grep -c "transcripts: many(clientTranscripts)" src/db/schema.ts` restituisce 2 (leadsRelations + clientsRelations)
|
|
- `grep -c "export const clientTranscriptsRelations" src/db/schema.ts` restituisce 1
|
|
- `grep -c "export type ClientTranscript" src/db/schema.ts` restituisce 1
|
|
- `npx tsc --noEmit` passa senza errori TypeScript relativi a schema.ts
|
|
</acceptance_criteria>
|
|
<done>clientTranscripts definita in schema.ts con relazioni Drizzle bidirezionali e TypeScript types esportati.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Aggiungere getTranscripts a lead-service.ts e addTranscript/deleteTranscript ad actions.ts</name>
|
|
<files>src/lib/lead-service.ts, src/app/admin/leads/actions.ts</files>
|
|
<read_first>
|
|
- src/lib/lead-service.ts (leggere per vedere gli import correnti e dove appendere getTranscripts)
|
|
- src/app/admin/leads/actions.ts (leggere per vedere gli import correnti e la posizione di requireAdmin)
|
|
- src/db/schema.ts (dopo il Task 1 — per verificare il nome esatto dell'export clientTranscripts)
|
|
</read_first>
|
|
<action>
|
|
**In src/lib/lead-service.ts:**
|
|
|
|
Aggiungere `clientTranscripts` agli import esistenti:
|
|
```typescript
|
|
import { leads, activities, reminders, quotes, clientTranscripts } from "@/db/schema";
|
|
```
|
|
|
|
Appendere in fondo al file (dopo `getQuotesByLeadId`):
|
|
```typescript
|
|
// Get transcript log for a lead — call_date DESC (D-05)
|
|
// Restituisce tutti i campi incluso content completo (Phase 21 li legge in blocco).
|
|
export async function getTranscripts(leadId: string) {
|
|
return await db
|
|
.select()
|
|
.from(clientTranscripts)
|
|
.where(eq(clientTranscripts.lead_id, leadId))
|
|
.orderBy(desc(clientTranscripts.call_date));
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
**In src/app/admin/leads/actions.ts:**
|
|
|
|
Aggiungere `clientTranscripts` agli import dal DB schema:
|
|
```typescript
|
|
import { leads, quotes, tags, clientTranscripts } from "@/db/schema";
|
|
```
|
|
|
|
Aggiungere `nanoid` agli import (serve per generare l'id del transcript):
|
|
```typescript
|
|
import { nanoid } from "nanoid";
|
|
```
|
|
|
|
Definire lo schema Zod per il transcript e appendere le due actions dopo le tag actions esistenti (dopo `renameLeadTag`):
|
|
|
|
```typescript
|
|
// ── Transcript actions (Phase 20 — KB-02) ────────────────────────────────────
|
|
|
|
const addTranscriptSchema = z.object({
|
|
lead_id: z.string().min(1),
|
|
title: z.string().optional(),
|
|
content: z.string().min(1, "Il testo del transcript è obbligatorio"),
|
|
call_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Data non valida (YYYY-MM-DD)"),
|
|
});
|
|
|
|
export async function addTranscript(data: z.infer<typeof addTranscriptSchema>) {
|
|
await requireAdmin();
|
|
|
|
const parsed = addTranscriptSchema.safeParse(data);
|
|
if (!parsed.success) {
|
|
return { success: false, error: parsed.error.issues[0].message };
|
|
}
|
|
|
|
try {
|
|
const [transcript] = await db
|
|
.insert(clientTranscripts)
|
|
.values({
|
|
id: nanoid(),
|
|
lead_id: parsed.data.lead_id,
|
|
title: parsed.data.title || null,
|
|
content: parsed.data.content,
|
|
call_date: parsed.data.call_date,
|
|
})
|
|
.returning();
|
|
|
|
revalidatePath(`/admin/leads/${parsed.data.lead_id}`);
|
|
return { success: true, transcript };
|
|
} catch (error) {
|
|
console.error("addTranscript error:", error);
|
|
return { success: false, error: "Errore nel salvataggio del transcript" };
|
|
}
|
|
}
|
|
|
|
export async function deleteTranscript(transcriptId: string, leadId: string) {
|
|
await requireAdmin();
|
|
|
|
if (!transcriptId) throw new Error("ID transcript richiesto");
|
|
|
|
try {
|
|
await db
|
|
.delete(clientTranscripts)
|
|
.where(eq(clientTranscripts.id, transcriptId));
|
|
|
|
revalidatePath(`/admin/leads/${leadId}`);
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error("deleteTranscript error:", error);
|
|
return { success: false, error: "Errore nell'eliminazione del transcript" };
|
|
}
|
|
}
|
|
```
|
|
</action>
|
|
<verify>
|
|
<automated>cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | grep -E "lead-service|actions" | head -10</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `grep -c "export async function getTranscripts" src/lib/lead-service.ts` restituisce 1
|
|
- `grep -c "orderBy(desc(clientTranscripts.call_date))" src/lib/lead-service.ts` restituisce 1
|
|
- `grep -c "export async function addTranscript" src/app/admin/leads/actions.ts` restituisce 1
|
|
- `grep -c "export async function deleteTranscript" src/app/admin/leads/actions.ts` restituisce 1
|
|
- `grep -c "await requireAdmin()" src/app/admin/leads/actions.ts` è >= 3 (addTranscript e deleteTranscript devono averlo, più le actions esistenti)
|
|
- `grep -c "revalidatePath" src/app/admin/leads/actions.ts` aumenta di 2 rispetto al file originale (una per addTranscript, una per deleteTranscript)
|
|
- `npx tsc --noEmit` passa senza errori su lead-service.ts e actions.ts
|
|
</acceptance_criteria>
|
|
<done>getTranscripts esportata da lead-service.ts, addTranscript e deleteTranscript aggiunte ad actions.ts con requireAdmin guard e revalidatePath.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<threat_model>
|
|
## Trust Boundaries
|
|
|
|
| Boundary | Descrizione |
|
|
|----------|-------------|
|
|
| browser → server action | addTranscript e deleteTranscript sono "use server" — input non trusted |
|
|
| server action → database | Query Drizzle con parametri typed |
|
|
|
|
## STRIDE Threat Register
|
|
|
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
|
|-----------|----------|-----------|-------------|-----------------|
|
|
| T-20-04 | Elevation of Privilege | addTranscript / deleteTranscript | mitigate | `await requireAdmin()` come prima istruzione in entrambe le actions — lancia Error se sessione assente, blocca esecuzione |
|
|
| T-20-05 | Tampering | addTranscript — content | accept | Nessuna sanitization HTML richiesta (testo grezzo, non renderizzato come HTML — CONTEXT.md security note); Zod valida presenza e tipo |
|
|
| T-20-06 | Tampering | deleteTranscript — transcriptId | mitigate | L'ID viene passato dalla UI admin-only (protetta da requireAdmin); Drizzle usa query parametrizzata — no SQL injection |
|
|
| T-20-07 | Information Disclosure | getTranscripts | mitigate | Funzione usata solo in server components admin (`/admin/leads/[id]/page.tsx`); non esposta su API client (D-04 — client_id FK presente ma non UI client in Phase 20) |
|
|
</threat_model>
|
|
|
|
<verification>
|
|
```bash
|
|
cd /Users/simonecavalli/Vault/IAMCAVALLI
|
|
|
|
# Schema: tabella e relazioni presenti
|
|
grep -E "clientTranscripts|ClientTranscript" src/db/schema.ts | grep -v "^//"
|
|
|
|
# lead-service: query con ordinamento corretto
|
|
grep -A 6 "getTranscripts" src/lib/lead-service.ts
|
|
|
|
# actions: requireAdmin su entrambe le nuove actions
|
|
grep -B 1 "requireAdmin" src/app/admin/leads/actions.ts
|
|
|
|
# Build TypeScript
|
|
npx tsc --noEmit 2>&1 | tail -5
|
|
```
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- schema.ts contiene `clientTranscripts` table, relazioni bidirezionali (leads + clients), e TypeScript types (KB-01)
|
|
- lead-service.ts esporta `getTranscripts(leadId)` che ordina per `call_date DESC` (KB-02, D-05)
|
|
- actions.ts ha `addTranscript` e `deleteTranscript` entrambe con `requireAdmin()` guard (KB-02)
|
|
- `npx tsc --noEmit` passa senza errori sui file modificati
|
|
</success_criteria>
|
|
|
|
<output>
|
|
Dopo il completamento, creare `.planning/phases/20-knowledge-base-cliente/20-02-SUMMARY.md` con:
|
|
- Tabella Drizzle aggiunta: clientTranscripts con 7 campi
|
|
- Query aggiunta: getTranscripts(leadId) in lead-service.ts
|
|
- Actions aggiunte: addTranscript, deleteTranscript in actions.ts
|
|
- TypeScript check: passed/failed
|
|
</output>
|