---
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\\)"
---

<objective>
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).
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<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.md
</context>

<interfaces>
<!-- Exact analog patterns from Phase 11 (shipped, lint-passing) — replicate structure 1:1 for leads -->

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<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):
```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()`.
</interfaces>

<tasks>

<task type="auto">
  <name>Task 1: Extend admin-queries.ts with LeadWithTags and LeadFieldOptions</name>
  <files>src/lib/admin-queries.ts</files>
  <read_first>
    - 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)
  </read_first>
  <action>
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<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`, `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.
  </action>
  <verify>
    <automated>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</automated>
  </verify>
  <acceptance_criteria>
    - `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)
  </acceptance_criteria>
  <done>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.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Add updateLeadField + tag CRUD server actions to leads/actions.ts</name>
  <files>src/app/admin/leads/actions.ts</files>
  <read_first>
    - 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)
  </read_first>
  <behavior>
    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
  </behavior>
  <action>
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`).
  </action>
  <verify>
    <automated>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</automated>
  </verify>
  <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>
  <done>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.</done>
</task>

</tasks>

<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>

<verification>
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 `<behavior>` block in Task 2 against the written code — every documented case has a corresponding code branch
</verification>

<success_criteria>
- `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)
</success_criteria>

<output>
After completion, create `.planning/phases/14-crm-attio-style-fix/14-01-SUMMARY.md`
</output>
