--- phase: 14-crm-attio-style-fix plan: 01 type: execute wave: 1 depends_on: [] files_modified: - src/lib/admin-queries.ts - src/app/admin/leads/actions.ts autonomous: true requirements: [CRM-08, CRM-09] must_haves: truths: - "Querying getLeadsWithTags() returns every lead row augmented with a `tags: string[]` array populated from the polymorphic tags table (entity_type='leads')" - "Querying getLeadFieldOptions() returns the fixed LEAD_STAGES list for `status` and the distinct sorted set of lead tag names for `tags`" - "Calling updateLeadField() for an unauthenticated request throws 'Non autorizzato' before touching the database" - "Calling updateLeadField(leadId, 'status', 'not_a_stage') throws 'Stato non valido' and does not write to the leads table" - "Calling addLeadTag/removeLeadTag/renameLeadTag mutates only rows where entity_type='leads', never touching entity_type='services' tag rows" artifacts: - path: "src/lib/admin-queries.ts" provides: "LeadWithTags type, LeadFieldOptions type, getLeadsWithTags(), getLeadFieldOptions()" contains: "export async function getLeadsWithTags" - path: "src/app/admin/leads/actions.ts" provides: "updateLeadField, addLeadTag, removeLeadTag, renameLeadTag server actions with requireAdmin() guard" contains: "export async function updateLeadField" key_links: - from: "src/app/admin/leads/actions.ts" to: "src/db/schema.ts tags table" via: "db.insert(tags)/db.delete(tags)/db.update(tags) scoped by entity_type='leads'" pattern: "LEADS_TAG_ENTITY" - from: "src/lib/admin-queries.ts getLeadsWithTags" to: "src/db/schema.ts tags table" via: "leftJoin scoped by entity_type='leads'" pattern: "eq\\(tags\\.entity_type, LEADS_TAG_ENTITY\\)" --- Build the data-layer foundation for the Attio-style lead table redesign: extend `src/lib/admin-queries.ts` with `getLeadsWithTags()` / `getLeadFieldOptions()` (mirroring Phase 11's `getAllServices()` / `getCatalogFieldOptions()` for the polymorphic `tags` table), and extend `src/app/admin/leads/actions.ts` with `updateLeadField`, `addLeadTag`, `removeLeadTag`, `renameLeadTag` server actions — all guarded by `requireAdmin()` per the Phase 11 catalog convention. 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). @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.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.md From src/lib/admin-queries.ts (lines 360-448, getAllServices / getCatalogFieldOptions — analog pattern): ```typescript const TAG_ENTITY = "services"; const PACCHETTO_ENTITY = "services.pacchetto"; export type ServiceWithTags = Service & { tags: string[]; pacchetto: string[] }; export async function getAllServices(): Promise { 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(); 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 { 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): ```typescript 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): ```typescript 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): ```typescript 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): ```typescript "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): ```typescript 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): ```typescript 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): ```typescript "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()`. Task 1: Extend admin-queries.ts with LeadWithTags and LeadFieldOptions src/lib/admin-queries.ts - src/lib/admin-queries.ts (lines 1-46 for existing imports, lines 360-448 for the getAllServices/getCatalogFieldOptions analog pattern to replicate) - src/lib/lead-validators.ts (LEAD_STAGES export) - src/db/schema.ts (lines 119-134 tags table, lines 396-411 leads table) At the end of src/lib/admin-queries.ts (after the getCatalogFieldOptions function, i.e. after line 448), add a new section: 1. 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). 2. Add a new section with a header comment: ```typescript // ── 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 { 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(); 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 { 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`, `asc` are already imported from `drizzle-orm` at line 26 — do not re-import. - `leads` and `tags` are already imported from `@/db/schema` — do not re-import. - `Lead` type 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, matching `getAllLeads()`'s current `desc(leads.updated_at)`, which is the current LeadTable's data source). `desc` is 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 - `grep -c "export async function getLeadsWithTags" src/lib/admin-queries.ts` returns 1 - `grep -c "export async function getLeadFieldOptions" src/lib/admin-queries.ts` returns 1 - `grep -c "export type LeadWithTags" src/lib/admin-queries.ts` returns 1 - `grep -c "export type LeadFieldOptions" src/lib/admin-queries.ts` returns 1 - `grep -c "LEADS_TAG_ENTITY = \"leads\"" src/lib/admin-queries.ts` returns 1 - `grep -c "import { LEAD_STAGES } from \"@/lib/lead-validators\"" src/lib/admin-queries.ts` returns 1 - `grep -c "desc(leads.updated_at), asc(tags.name)" src/lib/admin-queries.ts` returns 1 - `npx tsc --noEmit` exits 0 (no new type errors introduced) 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. Task 2: Add updateLeadField + tag CRUD server actions to leads/actions.ts src/app/admin/leads/actions.ts - src/app/admin/leads/actions.ts (full current file — existing actions, imports, pattern conventions) - src/app/admin/catalog/actions.ts (lines 1-21 requireAdmin pattern; lines 71-116 updateServiceField field-allowlist pattern; lines 131-199 tag CRUD pattern) - src/lib/lead-validators.ts (LEAD_STAGES export) These are server actions (no dedicated test file in this codebase's convention — Phase 11's equivalent actions also ship without unit tests, verified via `npx tsc --noEmit` + manual QA in Plan 14-02). Document expected behaviors here for the executor's own verification during implementation (manual trace, not an automated test file): - `updateLeadField(leadId, "name", "")` throws "Nome richiesto" (empty name rejected) - `updateLeadField(leadId, "name", " Mario Rossi ")` trims to "Mario Rossi" and updates `leads.name` - `updateLeadField(leadId, "status", "bogus")` throws "Stato non valido", no DB write - `updateLeadField(leadId, "status", "won")` updates `leads.status` to "won" (valid LEAD_STAGES member) - `updateLeadField(leadId, "email", "")` sets `leads.email` to `null` (empty clears nullable field) - `updateLeadField(leadId, "email", "foo@bar.com")` sets `leads.email` to "foo@bar.com" - `updateLeadField(leadId, "next_action", "Richiamare")` sets `leads.next_action` to "Richiamare" - `addLeadTag(leadId, "VIP")` inserts a row into `tags` with `entity_type="leads", entity_id=leadId, name="VIP"`; calling it twice with the same value does not error (onConflictDoNothing) - `removeLeadTag(leadId, "VIP")` deletes the matching `tags` row scoped to `entity_type="leads"` only (never touches `entity_type="services"` rows even if a service happens to share the same `entity_id` string) - `renameLeadTag("VIP", "Priorita Alta")` updates ALL `tags` rows where `entity_type="leads"` and `name="VIP"` to `name="Priorita Alta"` (propagates across all leads sharing that tag) - Every one of the above throws `"Non autorizzato"` immediately if `getServerSession(authOptions)` returns null/undefined, before any DB call Append to src/app/admin/leads/actions.ts (after the existing `assignQuoteToLead` function, end of file): 1. Add imports at the top of the file (merge with existing import block): ```typescript 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"; ``` 2. Add the `requireAdmin()` helper (exact copy from catalog/actions.ts): ```typescript async function requireAdmin() { const session = await getServerSession(authOptions); if (!session) throw new Error("Non autorizzato"); } ``` 3. Add the field-update action with allowlist: ```typescript // ── 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}`); } ``` 4. Add the polymorphic tag CRUD actions: ```typescript // ── 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 - `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 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. ## 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 | 1. `npx tsc --noEmit` — exits 0, no new type errors in admin-queries.ts or leads/actions.ts 2. `npx eslint src/lib/admin-queries.ts src/app/admin/leads/actions.ts` — exits 0 3. `grep -n "export async function getLeadsWithTags\|export async function getLeadFieldOptions" src/lib/admin-queries.ts` — both present 4. `grep -n "export async function updateLeadField\|export async function addLeadTag\|export async function removeLeadTag\|export async function renameLeadTag" src/app/admin/leads/actions.ts` — all four present 5. Manual trace through `` block in Task 2 against the written code — every documented case has a corresponding code branch - `src/lib/admin-queries.ts` exports `LeadWithTags`, `LeadFieldOptions`, `getLeadsWithTags()`, `getLeadFieldOptions()` — all type-checked, following the getAllServices/getCatalogFieldOptions left-join + Map-reduce pattern - `src/app/admin/leads/actions.ts` exports `updateLeadField`, `addLeadTag`, `removeLeadTag`, `renameLeadTag` — all guarded by `requireAdmin()`, `status` validated against `LEAD_STAGES`, tags scoped to `entity_type="leads"` - No schema migration created or required (confirmed: `tags.entity_type` is unconstrained text, already supports "leads") - `npx tsc --noEmit` and `npx eslint` both 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) After completion, create `.planning/phases/14-crm-attio-style-fix/14-01-SUMMARY.md`