d115465240
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.
576 lines
27 KiB
Markdown
576 lines
27 KiB
Markdown
---
|
|
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>
|