chore: merge executor worktree (worktree-agent-a6c4f07a973e2df91)
This commit is contained in:
@@ -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.
|
||||
@@ -2,11 +2,18 @@
|
||||
|
||||
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 { leads, quotes, tags } from "@/db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import {
|
||||
createLeadSchema,
|
||||
updateLeadSchema,
|
||||
createActivitySchema,
|
||||
LEAD_STAGES,
|
||||
} from "@/lib/lead-validators";
|
||||
import { revalidatePath } from "next/cache";
|
||||
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>) {
|
||||
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" };
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
leads,
|
||||
tags,
|
||||
} 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 {
|
||||
Client,
|
||||
Project,
|
||||
@@ -878,4 +879,65 @@ export async function getAllOfferMacrosWithMicros(): Promise<
|
||||
.filter((micro) => micro.macro_id === macro.id)
|
||||
.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")),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user