chore: merge executor worktree (worktree-agent-a6c4f07a973e2df91)

This commit is contained in:
2026-06-14 12:50:54 +02:00
4 changed files with 316 additions and 4 deletions
@@ -0,0 +1,131 @@
---
phase: 14-crm-attio-style-fix
plan: 01
subsystem: database
tags: [drizzle, postgres, server-actions, leads, tags, crm]
# Dependency graph
requires:
- phase: 11-catalog-database-view
provides: "Polymorphic `tags` table + getAllServices/getCatalogFieldOptions left-join + Map-reduce pattern, requireAdmin() convention in catalog/actions.ts"
provides:
- "LeadWithTags type + getLeadsWithTags() — leads augmented with tags:string[] from polymorphic tags table (entity_type='leads')"
- "LeadFieldOptions type + getLeadFieldOptions() — fixed LEAD_STAGES for status, distinct sorted lead tag names for tags"
- "updateLeadField server action — allowlisted inline-edit for name/email/phone/company/status/next_action, server-side LEAD_STAGES validation"
- "addLeadTag/removeLeadTag/renameLeadTag server actions — polymorphic tags CRUD scoped to entity_type='leads'"
affects: [14-02-leadtable-rewrite]
# Tech tracking
tech-stack:
added: []
patterns:
- "Left-join + Map-reduce pattern for polymorphic tags table (reused from Phase 11 getAllServices/getCatalogFieldOptions)"
- "requireAdmin() session guard as first statement in every new server action (reused from Phase 11 catalog/actions.ts)"
- "EDITABLE_FIELDS allowlist gating which columns a generic field-update action can write"
key-files:
created: []
modified:
- src/lib/admin-queries.ts
- src/app/admin/leads/actions.ts
key-decisions:
- "Logged 2 pre-existing lint issues (Record<string, any> in updateLead, 4 unused imports in admin-queries.ts) to deferred-items.md per Scope Boundary rule rather than fixing — both predate this plan and are unrelated to the new code added"
patterns-established:
- "Lead tag mutations always scope tags table queries with eq(tags.entity_type, LEADS_TAG_ENTITY) to prevent cross-entity pollution with entity_type='services' rows"
requirements-completed: [CRM-08, CRM-09]
# Metrics
duration: 12min
completed: 2026-06-14
---
# Phase 14 Plan 01: Lead Data-Layer Foundation Summary
**Added `getLeadsWithTags()`/`getLeadFieldOptions()` query functions and `updateLeadField`/`addLeadTag`/`removeLeadTag`/`renameLeadTag` server actions, mirroring Phase 11's catalog database-view pattern for the polymorphic `tags` table scoped to `entity_type="leads"`.**
## Performance
- **Duration:** 12 min
- **Started:** 2026-06-14T10:30:00Z
- **Completed:** 2026-06-14T10:42:03Z
- **Tasks:** 2
- **Files modified:** 2
## Accomplishments
- `LeadWithTags` type + `getLeadsWithTags()` left-joins `leads` with `tags` (scoped to `entity_type="leads"`), returning every lead row with a `tags: string[]` array, ordered `desc(leads.updated_at), asc(tags.name)`
- `LeadFieldOptions` type + `getLeadFieldOptions()` returns the fixed `LEAD_STAGES` list for `status` and the distinct sorted set of lead tag names for `tags`
- `updateLeadField(leadId, fieldName, value)` — allowlisted inline-edit action (`name`, `email`, `phone`, `company`, `status`, `next_action`), with server-side `LEAD_STAGES.includes()` validation for `status` and trim/null-clear handling for nullable text fields
- `addLeadTag`/`removeLeadTag`/`renameLeadTag` — polymorphic `tags` table CRUD scoped to `LEADS_TAG_ENTITY = "leads"`, never touching `entity_type="services"` rows
- All four new actions guarded by `requireAdmin()` as the first statement, throwing `"Non autorizzato"` before any DB access
## Task Commits
Each task was committed atomically:
1. **Task 1: Extend admin-queries.ts with LeadWithTags and LeadFieldOptions** - `5b583bc` (feat)
2. **Task 2: Add updateLeadField + tag CRUD server actions to leads/actions.ts** - `7d98b27` (feat)
_Note: Task 2 had `tdd="true"` but the plan's `<behavior>` block explicitly specifies manual-trace verification (no dedicated test file convention in this codebase, matching Phase 11's equivalent actions) — verified via line-by-line trace against all 11 documented behaviors, all pass._
## Files Created/Modified
- `src/lib/admin-queries.ts` - Added `desc` to drizzle-orm import, `LEAD_STAGES` import, `LEADS_TAG_ENTITY` const, `LeadWithTags`/`LeadFieldOptions` types, `getLeadsWithTags()`, `getLeadFieldOptions()`
- `src/app/admin/leads/actions.ts` - Added `tags` to schema import, `and` to drizzle-orm import, `LEAD_STAGES` import, `getServerSession`/`authOptions` imports, `requireAdmin()` helper, `updateLeadField`, `addLeadTag`, `removeLeadTag`, `renameLeadTag`
## Decisions Made
- Followed the plan's interfaces exactly (1:1 replication of Phase 11's `getAllServices`/`getCatalogFieldOptions` and `catalog/actions.ts` tag-CRUD pattern)
- Pre-existing lint issues unrelated to this plan's changes were logged to `deferred-items.md` rather than fixed (Scope Boundary rule) — see Deviations below
## Deviations from Plan
### Deferred (out of scope, logged not fixed)
**1. [Scope Boundary] Pre-existing `no-explicit-any` lint error in `updateLead`**
- **Found during:** Task 2 verification (`npx eslint src/app/admin/leads/actions.ts`)
- **Issue:** Line 54, `const updateData: Record<string, any> = { updated_at: new Date() };` inside the pre-existing `updateLead()` function (untouched by this plan) causes `npx eslint src/app/admin/leads/actions.ts` to exit 1
- **Resolution:** Confirmed via `git show` that this line existed before Task 2's edits (predates Phase 14). Logged to `.planning/phases/14-crm-attio-style-fix/deferred-items.md` per Scope Boundary rule — not fixed. The four new actions added in this plan introduce zero new lint errors/warnings (verified: only line 54 is flagged).
- **Files modified:** `.planning/phases/14-crm-attio-style-fix/deferred-items.md` (new)
- **Verification:** `npx eslint src/app/admin/leads/actions.ts --format stylish` shows only line 54:38 flagged, pre-dating this commit
- **Committed in:** `7d98b27`
**2. [Scope Boundary] Pre-existing unused-import warnings in admin-queries.ts**
- **Found during:** Task 1 verification (`npx eslint src/lib/admin-queries.ts`)
- **Issue:** 4x `@typescript-eslint/no-unused-vars` warnings for `settings`, `ServiceCatalog`, `ProjectOffer`, `OfferPhaseService` — pre-existing imports unrelated to the new `getLeadsWithTags`/`getLeadFieldOptions` code, which was appended at end-of-file
- **Resolution:** Logged to `deferred-items.md`, not fixed (out of scope)
- **Files modified:** `.planning/phases/14-crm-attio-style-fix/deferred-items.md` (new)
- **Committed in:** `7d98b27`
---
**Total deviations:** 2 deferred (both Scope Boundary — pre-existing issues unrelated to this plan's changes)
**Impact on plan:** None — `npx tsc --noEmit` exits 0 cleanly across the whole project; the eslint exit-1 on `actions.ts` is caused entirely by pre-existing code this plan does not touch. All plan-specified grep/type checks pass.
## Issues Encountered
None.
## User Setup Required
None - no external service configuration required. No schema migration created or required (confirmed: `tags.entity_type` is unconstrained text, already supports "leads" in production).
## Next Phase Readiness
- `LeadWithTags`, `LeadFieldOptions`, `getLeadsWithTags()`, `getLeadFieldOptions()` are ready for Plan 14-02 (LeadTable rewrite, LeadsSearch, page wiring)
- `updateLeadField`, `addLeadTag`, `removeLeadTag`, `renameLeadTag` server actions are ready for Plan 14-02's inline-edit UI wiring
- Pre-existing actions (`createLead`, `updateLead`, `deleteLead`, `logActivity`, `assignQuoteToLead`) remain unchanged, as required by out-of-scope note in RESEARCH.md A5 / Open Question 4
- Two pre-existing lint issues logged in `deferred-items.md` for future cleanup — do not block Plan 14-02
---
*Phase: 14-crm-attio-style-fix*
*Completed: 2026-06-14*
## Self-Check: PASSED
- FOUND: src/lib/admin-queries.ts
- FOUND: src/app/admin/leads/actions.ts
- FOUND: .planning/phases/14-crm-attio-style-fix/14-01-SUMMARY.md
- FOUND: .planning/phases/14-crm-attio-style-fix/deferred-items.md
- FOUND commit: 5b583bc
- FOUND commit: 7d98b27
- FOUND commit: d1acfe9
@@ -0,0 +1,20 @@
# Deferred Items — Phase 14
Items discovered during execution that are out of scope for the current task
(per Scope Boundary rule — pre-existing issues in unrelated code paths).
## 14-01
- **`src/app/admin/leads/actions.ts:54`** — pre-existing `@typescript-eslint/no-explicit-any`
error on `const updateData: Record<string, any> = { updated_at: new Date() };` inside
`updateLead()`. This function predates Phase 14 and is unchanged by 14-01 (Task 2 only
appends `updateLeadField`/`addLeadTag`/`removeLeadTag`/`renameLeadTag` after it).
`npx eslint src/app/admin/leads/actions.ts` exits 1 solely due to this pre-existing line;
the four new actions added in 14-01 introduce zero new lint errors/warnings.
Fix: replace `Record<string, any>` with a properly typed partial update object
(e.g. `Partial<typeof leads.$inferInsert>` or an explicit interface).
- **`src/lib/admin-queries.ts`** — pre-existing `@typescript-eslint/no-unused-vars` warnings
(4x) for imports `settings`, `ServiceCatalog`, `ProjectOffer`, `OfferPhaseService` — these
predate Phase 14 and are unrelated to the `getLeadsWithTags`/`getLeadFieldOptions` additions
in Task 1.
+102 -3
View File
@@ -2,11 +2,18 @@
import { z } from "zod"; import { z } from "zod";
import { db } from "@/db"; import { db } from "@/db";
import { leads, quotes } from "@/db/schema"; import { leads, quotes, tags } from "@/db/schema";
import { eq } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { createLeadSchema, updateLeadSchema, createActivitySchema } from "@/lib/lead-validators"; import {
createLeadSchema,
updateLeadSchema,
createActivitySchema,
LEAD_STAGES,
} from "@/lib/lead-validators";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { createActivity, updateLeadStage } from "@/lib/lead-service"; import { createActivity, updateLeadStage } from "@/lib/lead-service";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export async function createLead(data: z.infer<typeof createLeadSchema>) { export async function createLead(data: z.infer<typeof createLeadSchema>) {
const parsed = createLeadSchema.safeParse(data); const parsed = createLeadSchema.safeParse(data);
@@ -159,3 +166,95 @@ export async function assignQuoteToLead(data: z.infer<typeof assignQuoteSchema>)
return { success: false, error: "Errore nell'assegnazione" }; return { success: false, error: "Errore nell'assegnazione" };
} }
} }
async function requireAdmin() {
const session = await getServerSession(authOptions);
if (!session) throw new Error("Non autorizzato");
}
// ── 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}`);
}
// ── 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");
}
+63 -1
View File
@@ -23,7 +23,8 @@ import {
leads, leads,
tags, tags,
} from "@/db/schema"; } from "@/db/schema";
import { eq, inArray, asc, isNull, sql, and } from "drizzle-orm"; import { eq, inArray, asc, desc, isNull, sql, and } from "drizzle-orm";
import { LEAD_STAGES } from "@/lib/lead-validators";
import type { import type {
Client, Client,
Project, Project,
@@ -878,4 +879,65 @@ export async function getAllOfferMacrosWithMicros(): Promise<
.filter((micro) => micro.macro_id === macro.id) .filter((micro) => micro.macro_id === macro.id)
.sort((a, b) => a.sort_order - b.sort_order), .sort((a, b) => a.sort_order - b.sort_order),
})); }));
}
// ── 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")),
};
} }