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 { 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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user