Plan Phase 14 (CRM Attio-style & Fix) into 3 waves: data-layer foundation (query helpers + server actions), LeadTable raw-table rewrite with inline edit/tags, and isolated bug fixes for CRM-10/11/12.
27 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 14-crm-attio-style-fix | 01 | execute | 1 |
|
true |
|
|
Purpose: This is the Wave 1 "contracts + backend" plan. Plan 14-02 (LeadTable rewrite, LeadsSearch, page wiring) depends on the types and server actions created here.
Output: LeadWithTags/LeadFieldOptions types and query functions in admin-queries.ts; four new server actions in leads/actions.ts, all with requireAdmin() checks and dual revalidatePath calls (list + detail).
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/ROADMAP.md @.planning/REQUIREMENTS.md @.planning/phases/14-crm-attio-style-fix/14-RESEARCH.md @.planning/phases/14-crm-attio-style-fix/14-PATTERNS.mdFrom src/lib/admin-queries.ts (lines 360-448, getAllServices / getCatalogFieldOptions — analog pattern):
const TAG_ENTITY = "services";
const PACCHETTO_ENTITY = "services.pacchetto";
export type ServiceWithTags = Service & { tags: string[]; pacchetto: string[] };
export async function getAllServices(): Promise<ServiceWithTags[]> {
const rows = await db
.select({
id: services.id,
name: services.name,
// ...all service columns...
tag_name: tags.name,
tag_type: tags.entity_type,
})
.from(services)
.leftJoin(
tags,
and(
eq(tags.entity_id, services.id),
inArray(tags.entity_type, [TAG_ENTITY, PACCHETTO_ENTITY])
)
)
.orderBy(asc(services.name), asc(tags.name));
const serviceMap = new Map<string, ServiceWithTags>();
for (const row of rows) {
const { tag_name, tag_type, ...serviceFields } = row;
if (!serviceMap.has(row.id)) {
serviceMap.set(row.id, { ...serviceFields, tags: [], pacchetto: [] });
}
if (tag_name) {
const target = serviceMap.get(row.id)!;
if (tag_type === PACCHETTO_ENTITY) target.pacchetto.push(tag_name);
else target.tags.push(tag_name);
}
}
return Array.from(serviceMap.values());
}
export type CatalogFieldOptions = {
tag: string[];
pacchetto: string[];
categoria: string[];
fase: string[];
};
export async function getCatalogFieldOptions(): Promise<CatalogFieldOptions> {
const tagRows = await db
.selectDistinct({ name: tags.name, type: tags.entity_type })
.from(tags)
.where(inArray(tags.entity_type, [TAG_ENTITY, PACCHETTO_ENTITY]));
const sortUnique = (arr: (string | null)[]) =>
Array.from(new Set(arr.filter((v): v is string => !!v && v.trim().length > 0))).sort(
(a, b) => a.localeCompare(b, "it")
);
return {
tag: sortUnique(tagRows.filter((r) => r.type === TAG_ENTITY).map((r) => r.name)),
pacchetto: sortUnique(tagRows.filter((r) => r.type === PACCHETTO_ENTITY).map((r) => r.name)),
categoria: sortUnique(catRows.map((r) => r.value)),
fase: sortUnique(faseRows.map((r) => r.value)),
};
}
From src/lib/admin-queries.ts (line 27-46, existing top-of-file imports — Lead is ALREADY imported):
import { db } from "@/db";
import {
// ... existing entity tables ...
leads,
tags,
} from "@/db/schema";
import { eq, inArray, asc, isNull, sql, and } from "drizzle-orm";
import type {
// ... existing types ...
Lead,
} from "@/db/schema";
NOTE: eq, and, asc are already imported. leads and tags table objects are already imported (used elsewhere in admin-queries.ts for other queries). Lead type is already imported. No new top-level imports needed for the query additions — only add LEAD_STAGES from @/lib/lead-validators if not already present (it is NOT currently imported in admin-queries.ts).
From src/db/schema.ts (lines 396-411, leads table — for column reference):
export const leads = pgTable("leads", {
id: text("id").primaryKey().$defaultFn(() => nanoid()),
name: text("name").notNull(),
email: text("email"),
phone: text("phone"),
company: text("company"),
status: text("status").notNull().default("contacted"),
last_contact_date: timestamp("last_contact_date", { withTimezone: true }),
next_action: text("next_action"),
next_action_date: timestamp("next_action_date", { withTimezone: true }),
notes: text("notes"),
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updated_at: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});
export type Lead = typeof leads.$inferSelect;
From src/db/schema.ts (lines 119-134, tags table — polymorphic, already in prod):
export const tags = pgTable(
"tags",
{
id: text("id").primaryKey().$defaultFn(() => nanoid()),
entity_type: text("entity_type").notNull(), // "services" | "leads" (Phase 14)
entity_id: text("entity_id").notNull(),
name: text("name").notNull(),
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
entityTagUnique: uniqueIndex("tags_entity_name_unique").on(t.entity_type, t.entity_id, t.name),
entityIdx: index("tags_entity_idx").on(t.entity_type, t.entity_id),
})
);
No migration needed — this table is already deployed to production with entity_type='leads' explicitly reserved in its design.
From src/app/admin/catalog/actions.ts (lines 1-21, imports + requireAdmin — exact pattern to replicate):
"use server";
import { db } from "@/db";
import { services, tags } from "@/db/schema";
import { revalidatePath } from "next/cache";
import { eq, and } from "drizzle-orm";
import { z } from "zod";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
async function requireAdmin() {
const session = await getServerSession(authOptions);
if (!session) throw new Error("Non autorizzato");
}
From src/app/admin/catalog/actions.ts (lines 131-199, tag CRUD — exact analog for addLeadTag/removeLeadTag/renameLeadTag):
export async function addServiceOption(field: MultiSelectField, serviceId: string, value: string) {
await requireAdmin();
if (!MULTI_FIELDS.includes(field)) throw new Error(`Campo non valido: ${field}`);
const trimmed = value.trim();
if (trimmed.length === 0) throw new Error("Valore richiesto");
await db
.insert(tags)
.values({ entity_type: MULTI_ENTITY[field], entity_id: serviceId, name: trimmed })
.onConflictDoNothing();
revalidatePath("/admin/catalog");
}
export async function removeServiceOption(field: MultiSelectField, serviceId: string, value: string) {
await requireAdmin();
if (!MULTI_FIELDS.includes(field)) throw new Error(`Campo non valido: ${field}`);
await db
.delete(tags)
.where(
and(
eq(tags.entity_type, MULTI_ENTITY[field]),
eq(tags.entity_id, serviceId),
eq(tags.name, value)
)
);
revalidatePath("/admin/catalog");
}
export async function renameServiceOption(field: MultiSelectField | SingleSelectField, oldValue: string, newValue: string) {
await requireAdmin();
const next = newValue.trim();
if (next.length === 0) throw new Error("Nuovo nome richiesto");
if (next === oldValue) return;
if (field === "tag" || field === "pacchetto") {
await db
.update(tags)
.set({ name: next })
.where(and(eq(tags.entity_type, MULTI_ENTITY[field]), eq(tags.name, oldValue)));
}
// ... else branches for single-select fields, not needed for leads tags ...
revalidatePath("/admin/catalog");
}
From src/lib/lead-validators.ts (existing, exports LEAD_STAGES):
export const LEAD_STAGES = [
"contacted",
"qualified",
"proposal_sent",
"negotiating",
"won",
"lost",
] as const;
From src/app/admin/leads/actions.ts (existing file — current top, lines 1-10, what's already there):
"use server";
import { z } from "zod";
import { db } from "@/db";
import { leads, quotes } from "@/db/schema";
import { eq } from "drizzle-orm";
import { createLeadSchema, updateLeadSchema, createActivitySchema } from "@/lib/lead-validators";
import { revalidatePath } from "next/cache";
import { createActivity, updateLeadStage } from "@/lib/lead-service";
Existing pre-Phase-14 actions (createLead, updateLead, deleteLead, logActivity, assignQuoteToLead) have NO requireAdmin() check — this is a pre-existing gap, OUT OF SCOPE for this plan (do not retrofit). New actions added in this plan MUST call requireAdmin().
-
Add import:
import { LEAD_STAGES } from "@/lib/lead-validators";to the top of the file (near other lib imports, after line 26's drizzle-orm import or with other internal imports — place it as a new top-level import line). -
Add a new section with a header comment:
// ── LeadWithTags — leads + tags (Phase 14 database-view) ─────────────────────
// Lead tags are stored in the polymorphic `tags` table, scoped by
// entity_type="leads". `status` is a fixed enum (LEAD_STAGES), not a
// dynamic pool — exposed via getLeadFieldOptions for the OptionSelect UI.
const LEADS_TAG_ENTITY = "leads";
export type LeadWithTags = Lead & { tags: string[] };
export async function getLeadsWithTags(): Promise<LeadWithTags[]> {
const rows = await db
.select({
id: leads.id,
name: leads.name,
email: leads.email,
phone: leads.phone,
company: leads.company,
status: leads.status,
last_contact_date: leads.last_contact_date,
next_action: leads.next_action,
next_action_date: leads.next_action_date,
notes: leads.notes,
created_at: leads.created_at,
updated_at: leads.updated_at,
tag_name: tags.name,
})
.from(leads)
.leftJoin(
tags,
and(
eq(tags.entity_id, leads.id),
eq(tags.entity_type, LEADS_TAG_ENTITY)
)
)
.orderBy(desc(leads.updated_at), asc(tags.name));
const leadMap = new Map<string, LeadWithTags>();
for (const row of rows) {
const { tag_name, ...leadFields } = row;
if (!leadMap.has(row.id)) leadMap.set(row.id, { ...leadFields, tags: [] });
if (tag_name) leadMap.get(row.id)!.tags.push(tag_name);
}
return Array.from(leadMap.values());
}
export type LeadFieldOptions = { status: string[]; tags: string[] };
export async function getLeadFieldOptions(): Promise<LeadFieldOptions> {
const tagRows = await db
.selectDistinct({ name: tags.name })
.from(tags)
.where(eq(tags.entity_type, LEADS_TAG_ENTITY));
return {
status: [...LEAD_STAGES],
tags: tagRows
.map((r) => r.name)
.sort((a, b) => a.localeCompare(b, "it")),
};
}
Notes:
eq,and,ascare already imported fromdrizzle-ormat line 26 — do not re-import.leadsandtagsare already imported from@/db/schema— do not re-import.Leadtype is already imported from@/db/schema(line 45) — do not re-import.- Ordering: use
.orderBy(desc(leads.updated_at), asc(tags.name))— this preserves the existing list ordering behavior (most-recently-updated first, matchinggetAllLeads()'s currentdesc(leads.updated_at), which is the current LeadTable's data source).descis NOT currently in the drizzle-orm import list at line 26 (eq, inArray, asc, isNull, sql, and) — add it to that import. grep -c "export async function getLeadsWithTags" src/lib/admin-queries.ts && grep -c "export async function getLeadFieldOptions" src/lib/admin-queries.ts && npx tsc --noEmit <acceptance_criteria>grep -c "export async function getLeadsWithTags" src/lib/admin-queries.tsreturns 1grep -c "export async function getLeadFieldOptions" src/lib/admin-queries.tsreturns 1grep -c "export type LeadWithTags" src/lib/admin-queries.tsreturns 1grep -c "export type LeadFieldOptions" src/lib/admin-queries.tsreturns 1grep -c "LEADS_TAG_ENTITY = \"leads\"" src/lib/admin-queries.tsreturns 1grep -c "import { LEAD_STAGES } from \"@/lib/lead-validators\"" src/lib/admin-queries.tsreturns 1grep -c "desc(leads.updated_at), asc(tags.name)" src/lib/admin-queries.tsreturns 1npx tsc --noEmitexits 0 (no new type errors introduced) </acceptance_criteria> getLeadsWithTags() and getLeadFieldOptions() exist in admin-queries.ts, type-check cleanly, follow the getAllServices/getCatalogFieldOptions pattern exactly (left-join + Map-reduce for tags), and use LEADS_TAG_ENTITY="leads" scoping.
- Add imports at the top of the file (merge with existing import block):
import { tags } from "@/db/schema"; // add to existing `import { leads, quotes } from "@/db/schema";` -> becomes `import { leads, quotes, tags } from "@/db/schema";`
import { eq, and } from "drizzle-orm"; // existing line has `import { eq } from "drizzle-orm";` -> add `and`
import { LEAD_STAGES } from "@/lib/lead-validators"; // add to existing lead-validators import line
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
- Add the
requireAdmin()helper (exact copy from catalog/actions.ts):
async function requireAdmin() {
const session = await getServerSession(authOptions);
if (!session) throw new Error("Non autorizzato");
}
- Add the field-update action with allowlist:
// ── Inline-edit field update (Phase 14 database-view) ───────────────────────
const EDITABLE_FIELDS = ["name", "email", "phone", "company", "status", "next_action"] as const;
type EditableField = (typeof EDITABLE_FIELDS)[number];
export async function updateLeadField(
leadId: string,
fieldName: EditableField,
value: string
) {
await requireAdmin();
if (!EDITABLE_FIELDS.includes(fieldName)) {
throw new Error(`Campo non editabile: ${fieldName}`);
}
if (fieldName === "name") {
const s = value.trim();
if (s.length === 0) throw new Error("Nome richiesto");
await db.update(leads).set({ name: s, updated_at: new Date() }).where(eq(leads.id, leadId));
} else if (fieldName === "status") {
if (!LEAD_STAGES.includes(value as (typeof LEAD_STAGES)[number])) {
throw new Error("Stato non valido");
}
await db.update(leads).set({ status: value, updated_at: new Date() }).where(eq(leads.id, leadId));
} else {
// email | phone | company | next_action — nullable text fields, empty clears
const s = value.trim();
await db
.update(leads)
.set({ [fieldName]: s || null, updated_at: new Date() })
.where(eq(leads.id, leadId));
}
revalidatePath("/admin/leads");
revalidatePath(`/admin/leads/${leadId}`);
}
- Add the polymorphic tag CRUD actions:
// ── Lead tags (Phase 14, CRM-09) — polymorphic `tags` table, entity_type="leads" ─
const LEADS_TAG_ENTITY = "leads";
export async function addLeadTag(leadId: string, value: string) {
await requireAdmin();
const trimmed = value.trim();
if (trimmed.length === 0) throw new Error("Valore richiesto");
await db
.insert(tags)
.values({ entity_type: LEADS_TAG_ENTITY, entity_id: leadId, name: trimmed })
.onConflictDoNothing();
revalidatePath("/admin/leads");
revalidatePath(`/admin/leads/${leadId}`);
}
export async function removeLeadTag(leadId: string, value: string) {
await requireAdmin();
await db
.delete(tags)
.where(
and(
eq(tags.entity_type, LEADS_TAG_ENTITY),
eq(tags.entity_id, leadId),
eq(tags.name, value)
)
);
revalidatePath("/admin/leads");
revalidatePath(`/admin/leads/${leadId}`);
}
export async function renameLeadTag(oldValue: string, newValue: string) {
await requireAdmin();
const next = newValue.trim();
if (next.length === 0) throw new Error("Nuovo nome richiesto");
if (next === oldValue) return;
await db
.update(tags)
.set({ name: next })
.where(and(eq(tags.entity_type, LEADS_TAG_ENTITY), eq(tags.name, oldValue)));
revalidatePath("/admin/leads");
}
Note on renameLeadTag: no per-lead revalidatePath(/admin/leads/${leadId}) call since the rename propagates across all leads sharing the tag (no single leadId to target) — revalidatePath("/admin/leads") covers the list; the detail page will pick up the change on next navigation/refresh (consistent with catalog's renameServiceOption which only revalidates /admin/catalog).
grep -c "export async function updateLeadField|export async function addLeadTag|export async function removeLeadTag|export async function renameLeadTag" src/app/admin/leads/actions.ts && npx tsc --noEmit && npx eslint src/app/admin/leads/actions.ts
<acceptance_criteria>
- grep -c "export async function updateLeadField" src/app/admin/leads/actions.ts returns 1
- grep -c "export async function addLeadTag" src/app/admin/leads/actions.ts returns 1
- grep -c "export async function removeLeadTag" src/app/admin/leads/actions.ts returns 1
- grep -c "export async function renameLeadTag" src/app/admin/leads/actions.ts returns 1
- grep -c "await requireAdmin()" src/app/admin/leads/actions.ts returns at least 4 (one per new action)
- grep -c "LEAD_STAGES.includes" src/app/admin/leads/actions.ts returns 1
- grep -c "LEADS_TAG_ENTITY = \"leads\"" src/app/admin/leads/actions.ts returns 1
- grep -c "Non autorizzato" src/app/admin/leads/actions.ts returns 1 (single shared requireAdmin helper)
- npx tsc --noEmit exits 0
- npx eslint src/app/admin/leads/actions.ts exits 0
</acceptance_criteria>
Four new server actions (updateLeadField, addLeadTag, removeLeadTag, renameLeadTag) exist in src/app/admin/leads/actions.ts, each calling requireAdmin() first, with status validated against LEAD_STAGES server-side and tags scoped to entity_type="leads". File type-checks and lints cleanly. Pre-existing actions (createLead, updateLead, deleteLead, logActivity, assignQuoteToLead) are unchanged.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| Admin browser -> Server Action | Authenticated admin session calls updateLeadField/addLeadTag/removeLeadTag/renameLeadTag via Next.js Server Actions (directly invocable RPC endpoints if unguarded) |
| Server Action -> Postgres | Drizzle ORM parameterized queries against leads and tags tables |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-14-01 | Elevation of Privilege | updateLeadField, addLeadTag, removeLeadTag, renameLeadTag (all new in this plan) |
mitigate | Each action calls await requireAdmin() as its first statement — throws "Non autorizzato" if getServerSession(authOptions) returns null, before any DB read/write (Task 2) |
| T-14-02 | Tampering | updateLeadField(leadId, "status", value) |
mitigate | Server-side allowlist check LEAD_STAGES.includes(value) rejects any status value outside the 6 fixed pipeline stages, independent of client UI restrictions (Task 2) |
| T-14-03 | Tampering | updateLeadField field allowlist |
mitigate | EDITABLE_FIELDS const array gates which columns can be written; any other fieldName throws Campo non editabile before reaching the DB (Task 2) |
| T-14-04 | Information Disclosure / Tampering (cross-entity tag pollution) | addLeadTag/removeLeadTag/renameLeadTag |
mitigate | All tag queries scoped with eq(tags.entity_type, LEADS_TAG_ENTITY) — cannot read/write/rename entity_type="services" tag rows even if entity_id strings collide (Task 2) |
| T-14-05 | Tampering (SQL injection) | All new DB queries | accept (no new risk) | Drizzle ORM parameterized queries used throughout; no raw SQL introduced |
| T-14-06 | Information Disclosure | getLeadsWithTags() exposes all lead PII columns (email, phone, company) to any caller |
accept | Function is only invoked from /admin/leads/page.tsx, an Auth.js-session-protected route per CLAUDE.md constraint #4 (/admin/* -> Auth.js session) — no new exposure surface introduced by this plan |
| </threat_model> |
<success_criteria>
src/lib/admin-queries.tsexportsLeadWithTags,LeadFieldOptions,getLeadsWithTags(),getLeadFieldOptions()— all type-checked, following the getAllServices/getCatalogFieldOptions left-join + Map-reduce patternsrc/app/admin/leads/actions.tsexportsupdateLeadField,addLeadTag,removeLeadTag,renameLeadTag— all guarded byrequireAdmin(),statusvalidated againstLEAD_STAGES, tags scoped toentity_type="leads"- No schema migration created or required (confirmed:
tags.entity_typeis unconstrained text, already supports "leads") npx tsc --noEmitandnpx eslintboth exit 0- Pre-existing actions in
leads/actions.ts(createLead, updateLead, deleteLead, logActivity, assignQuoteToLead) remain unchanged — no retrofit of requireAdmin() on those (out of scope per RESEARCH.md A5 / Open Question 4) </success_criteria>