Files
clienthub/.planning/phases/14-crm-attio-style-fix/14-01-PLAN.md
T
simone d115465240 docs(14): create phase plan
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.
2026-06-14 12:09:45 +02:00

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
src/lib/admin-queries.ts
src/app/admin/leads/actions.ts
true
CRM-08
CRM-09
truths artifacts key_links
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
path provides contains
src/lib/admin-queries.ts LeadWithTags type, LeadFieldOptions type, getLeadsWithTags(), getLeadFieldOptions() export async function getLeadsWithTags
path provides contains
src/app/admin/leads/actions.ts updateLeadField, addLeadTag, removeLeadTag, renameLeadTag server actions with requireAdmin() guard export async function updateLeadField
from to via pattern
src/app/admin/leads/actions.ts src/db/schema.ts tags table db.insert(tags)/db.delete(tags)/db.update(tags) scoped by entity_type='leads' LEADS_TAG_ENTITY
from to via pattern
src/lib/admin-queries.ts getLeadsWithTags src/db/schema.ts tags table leftJoin scoped by entity_type='leads' 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).

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

From 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().

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:

// ── 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. 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.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> 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):
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";
  1. 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");
}
  1. 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}`);
}
  1. 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>
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

<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>
After completion, create `.planning/phases/14-crm-attio-style-fix/14-01-SUMMARY.md`