diff --git a/src/app/admin/catalog/actions.ts b/src/app/admin/catalog/actions.ts index 0c9f01f..d81ce85 100644 --- a/src/app/admin/catalog/actions.ts +++ b/src/app/admin/catalog/actions.ts @@ -7,6 +7,7 @@ import { eq, and } from "drizzle-orm"; import { z } from "zod"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; +import { addPoolValue, renamePoolValue } from "@/lib/taxonomy"; const serviceSchema = z.object({ name: z.string().min(1, "Nome richiesto"), @@ -95,6 +96,7 @@ export async function updateServiceField( } else if (fieldName === "fase") { const s = String(value).trim(); await db.update(services).set({ fase: s || null }).where(eq(services.id, serviceId)); + if (s) await addPoolValue("service_fase", s); } else if (fieldName === "unit_price") { // Normalize locale-formatted input (WR-04): the cell displays it-IT (€1.234,50), // so an admin may type "1.234,50". When a comma is present, treat "." as thousands @@ -143,6 +145,9 @@ export async function addServiceOption( .values({ entity_type: MULTI_ENTITY[field], entity_id: serviceId, name: trimmed }) .onConflictDoNothing(); + // Register into the persistent pool so it survives unassignment / shows in settings. + await addPoolValue(field === "tag" ? "service_offerta" : "service_pacchetto", trimmed); + revalidatePath("/admin/catalog"); } @@ -187,10 +192,12 @@ export async function renameServiceOption( .update(tags) .set({ name: next }) .where(and(eq(tags.entity_type, MULTI_ENTITY[field]), eq(tags.name, oldValue))); + await renamePoolValue(field === "tag" ? "service_offerta" : "service_pacchetto", oldValue, next); } else if (field === "categoria") { await db.update(services).set({ category: next }).where(eq(services.category, oldValue)); } else if (field === "fase") { await db.update(services).set({ fase: next }).where(eq(services.fase, oldValue)); + await renamePoolValue("service_fase", oldValue, next); } else { throw new Error(`Campo non valido: ${field}`); } @@ -227,14 +234,16 @@ export async function quickAddService(payload: QuickAddPayload | string) { unit_price = num.toFixed(2); } + const fase = data.fase?.trim() || null; await db.insert(services).values({ name, description: data.description?.trim() || null, category: data.category?.trim() || null, - fase: data.fase?.trim() || null, + fase, unit_price, active: true, }); + if (fase) await addPoolValue("service_fase", fase); revalidatePath("/admin/catalog"); } diff --git a/src/app/admin/impostazioni/actions.ts b/src/app/admin/impostazioni/actions.ts index 261587e..81b2031 100644 --- a/src/app/admin/impostazioni/actions.ts +++ b/src/app/admin/impostazioni/actions.ts @@ -1,33 +1,42 @@ "use server"; -import { getJsonPool, updateJsonPool, SETTINGS_KEYS } from "@/lib/settings"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; import { revalidatePath } from "next/cache"; +import { + addPoolValue, + removePoolValue, + TAXONOMY_FIELDS, + type TaxonomyFieldId, +} from "@/lib/taxonomy"; -type PoolName = "tipo" | "obiettivo" | "categoria"; - -function poolKey(pool: PoolName): string { - switch (pool) { - case "tipo": return SETTINGS_KEYS.OFFER_TIPO_POOL; - case "obiettivo": return SETTINGS_KEYS.OFFER_OBIETTIVO_POOL; - case "categoria": return SETTINGS_KEYS.OFFER_CATEGORIA_POOL; - } +async function requireAdmin() { + const session = await getServerSession(authOptions); + if (!session) throw new Error("Non autorizzato"); } -export async function addPoolValue(pool: PoolName, value: string): Promise { - const trimmed = value.trim(); - if (!trimmed) return; - const key = poolKey(pool); - const current = await getJsonPool(key); - if (current.includes(trimmed)) return; - await updateJsonPool(key, [...current, trimmed]); +function assertField(fieldId: string): asserts fieldId is TaxonomyFieldId { + if (!(fieldId in TAXONOMY_FIELDS)) throw new Error(`Tassonomia non valida: ${fieldId}`); +} + +function revalidateAll() { revalidatePath("/admin/impostazioni"); revalidatePath("/admin/offers"); + revalidatePath("/admin/catalog"); } -export async function removePoolValue(pool: PoolName, value: string): Promise { - const key = poolKey(pool); - const current = await getJsonPool(key); - await updateJsonPool(key, current.filter((v) => v !== value)); - revalidatePath("/admin/impostazioni"); - revalidatePath("/admin/offers"); +export async function addTaxonomyValue(fieldId: string, value: string): Promise { + await requireAdmin(); + assertField(fieldId); + await addPoolValue(fieldId, value); + revalidateAll(); +} + +// Global delete: removes the value from the pool AND cascade-strips it from every +// offer/service using it. +export async function removeTaxonomyValue(fieldId: string, value: string): Promise { + await requireAdmin(); + assertField(fieldId); + await removePoolValue(fieldId, value); + revalidateAll(); } diff --git a/src/app/admin/impostazioni/page.tsx b/src/app/admin/impostazioni/page.tsx index a54d3ef..98d2cf9 100644 --- a/src/app/admin/impostazioni/page.tsx +++ b/src/app/admin/impostazioni/page.tsx @@ -1,15 +1,11 @@ -import { getTargetHourlyRate, getJsonPool, updateSetting, SETTINGS_KEYS } from "@/lib/settings"; -import { OfferPoolsSection } from "@/components/admin/impostazioni/OfferPoolsSection"; +import { getTargetHourlyRate, updateSetting, SETTINGS_KEYS } from "@/lib/settings"; +import { getAllPools } from "@/lib/taxonomy"; +import { TaxonomyManager } from "@/components/admin/impostazioni/TaxonomyManager"; export const revalidate = 0; export default async function ImpostazioniPage() { - const [targetRate, tipoPool, obiettivoPool, categoriaPool] = await Promise.all([ - getTargetHourlyRate(), - getJsonPool(SETTINGS_KEYS.OFFER_TIPO_POOL), - getJsonPool(SETTINGS_KEYS.OFFER_OBIETTIVO_POOL), - getJsonPool(SETTINGS_KEYS.OFFER_CATEGORIA_POOL), - ]); + const [targetRate, pools] = await Promise.all([getTargetHourlyRate(), getAllPools()]); async function handleSave(fd: FormData) { "use server"; @@ -62,11 +58,7 @@ export default async function ImpostazioniPage() { - + ); diff --git a/src/app/admin/offers/actions.ts b/src/app/admin/offers/actions.ts index d6147b8..e4f1e25 100644 --- a/src/app/admin/offers/actions.ts +++ b/src/app/admin/offers/actions.ts @@ -14,6 +14,7 @@ import { eq, and, inArray } from "drizzle-orm"; import { z } from "zod"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; +import { addPoolValue, renamePoolValue } from "@/lib/taxonomy"; async function requireAdmin() { const session = await getServerSession(authOptions); @@ -259,6 +260,13 @@ export async function saveOfferEditor(macroId: string, payload: SaveOfferEditorP await db.insert(tags).values(tagRows).onConflictDoNothing(); } + // 4. Register any newly-typed values into the persistent taxonomy pools so they + // survive unassignment and appear in settings + every dropdown. + if (data.category) await addPoolValue("offer_categoria", data.category); + if (data.ticket) await addPoolValue("offer_ticket", data.ticket); + for (const name of data.tipoTags) await addPoolValue("offer_tipo", name); + for (const name of data.obiettivoTags) await addPoolValue("offer_obiettivo", name); + revalidatePath("/admin/offers"); revalidatePath(`/admin/offers/${macroId}/edit`); } @@ -290,6 +298,8 @@ export async function addOfferTag(dimension: OfferTagDimension, macroId: string, .values({ entity_type: OFFER_TAG_ENTITY[dimension], entity_id: macroId, name: trimmed }) .onConflictDoNothing(); + await addPoolValue(dimension === "tipo" ? "offer_tipo" : "offer_obiettivo", trimmed); + revalidatePath("/admin/offers"); } @@ -332,13 +342,16 @@ export async function renameOfferOption( if (field === "categoria") { await db.update(offer_macros).set({ category: next }).where(eq(offer_macros.category, oldValue)); + await renamePoolValue("offer_categoria", oldValue, next); } else if (field === "ticket") { await db.update(offer_macros).set({ ticket: next }).where(eq(offer_macros.ticket, oldValue)); + await renamePoolValue("offer_ticket", oldValue, next); } else if (field === "tipo" || field === "obiettivo") { await db .update(tags) .set({ name: next }) .where(and(eq(tags.entity_type, OFFER_TAG_ENTITY[field]), eq(tags.name, oldValue))); + await renamePoolValue(field === "tipo" ? "offer_tipo" : "offer_obiettivo", oldValue, next); } else { throw new Error(`Campo non valido: ${field}`); } @@ -371,6 +384,7 @@ export async function createOfferMacro(formData: FormData) { description: parsed.data.description || null, category: parsed.data.category || null, }); + if (parsed.data.category) await addPoolValue("offer_categoria", parsed.data.category); revalidatePath("/admin/offers"); } diff --git a/src/components/admin/impostazioni/OfferPoolsSection.tsx b/src/components/admin/impostazioni/OfferPoolsSection.tsx deleted file mode 100644 index 0bf3232..0000000 --- a/src/components/admin/impostazioni/OfferPoolsSection.tsx +++ /dev/null @@ -1,43 +0,0 @@ -"use client"; - -import { PoolManager } from "./PoolManager"; -import { addPoolValue, removePoolValue } from "@/app/admin/impostazioni/actions"; - -export function OfferPoolsSection({ - tipoPool, - obiettivoPool, - categoriaPool, -}: { - tipoPool: string[]; - obiettivoPool: string[]; - categoriaPool: string[]; -}) { - return ( -
-

Categorie Offerta

-

- Valori disponibili nei campi Tipo, Obiettivo e Categoria dell'editor offerte. -

-
- addPoolValue("tipo", v)} - onRemove={(v) => removePoolValue("tipo", v)} - /> - addPoolValue("obiettivo", v)} - onRemove={(v) => removePoolValue("obiettivo", v)} - /> - addPoolValue("categoria", v)} - onRemove={(v) => removePoolValue("categoria", v)} - /> -
-
- ); -} diff --git a/src/components/admin/impostazioni/PoolManager.tsx b/src/components/admin/impostazioni/PoolManager.tsx index ec0e534..cae297c 100644 --- a/src/components/admin/impostazioni/PoolManager.tsx +++ b/src/components/admin/impostazioni/PoolManager.tsx @@ -33,6 +33,10 @@ export function PoolManager({ } function handleRemove(value: string) { + const ok = window.confirm( + `Eliminare "${value}" da «${label}»?\n\nVerrà rimosso ovunque: sparirà dal menu e da tutte le offerte/servizi che lo usano.` + ); + if (!ok) return; startTransition(() => onRemove(value)); } diff --git a/src/components/admin/impostazioni/TaxonomyManager.tsx b/src/components/admin/impostazioni/TaxonomyManager.tsx new file mode 100644 index 0000000..19d7c17 --- /dev/null +++ b/src/components/admin/impostazioni/TaxonomyManager.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { PoolManager } from "./PoolManager"; +import { addTaxonomyValue, removeTaxonomyValue } from "@/app/admin/impostazioni/actions"; + +type FieldDef = { id: string; label: string }; + +const OFFER_FIELDS: FieldDef[] = [ + { id: "offer_categoria", label: "Categoria" }, + { id: "offer_ticket", label: "Ticket" }, + { id: "offer_tipo", label: "Tipo" }, + { id: "offer_obiettivo", label: "Obiettivo" }, +]; + +const CATALOG_FIELDS: FieldDef[] = [ + { id: "service_fase", label: "Fase" }, + { id: "service_offerta", label: "Offerta" }, + { id: "service_pacchetto", label: "Pacchetto" }, +]; + +function Section({ + title, + subtitle, + fields, + pools, +}: { + title: string; + subtitle: string; + fields: FieldDef[]; + pools: Record; +}) { + return ( +
+

{title}

+

{subtitle}

+
+ {fields.map((f) => ( + addTaxonomyValue(f.id, v)} + onRemove={(v) => removeTaxonomyValue(f.id, v)} + /> + ))} +
+
+ ); +} + +export function TaxonomyManager({ pools }: { pools: Record }) { + return ( + <> +
+
+ + ); +} diff --git a/src/lib/admin-queries.ts b/src/lib/admin-queries.ts index a98eb6c..8053fed 100644 --- a/src/lib/admin-queries.ts +++ b/src/lib/admin-queries.ts @@ -24,6 +24,7 @@ import { tags, } from "@/db/schema"; import { eq, inArray, asc, desc, isNull, sql, and } from "drizzle-orm"; +import { getPool } from "@/lib/taxonomy"; import { LEAD_STAGES } from "@/lib/lead-validators"; import type { Client, @@ -420,32 +421,21 @@ export type CatalogFieldOptions = { }; export async function getCatalogFieldOptions(): Promise { - const tagRows = await db - .selectDistinct({ name: tags.name, type: tags.entity_type }) - .from(tags) - .where(inArray(tags.entity_type, [TAG_ENTITY, PACCHETTO_ENTITY])); + // fase / Offerta (tag) / Pacchetto read from the centralized, persistent + // taxonomy pools (src/lib/taxonomy.ts). `categoria` (services.category) is + // hidden in the catalog UI and stays derived from in-use values. + const [tag, pacchetto, fase, catRows] = await Promise.all([ + getPool("service_offerta"), + getPool("service_pacchetto"), + getPool("service_fase"), + db.selectDistinct({ value: services.category }).from(services), + ]); - // Distinct category / fase values (single-select pools); nulls filtered below. - const catRows = await db - .selectDistinct({ value: services.category }) - .from(services); - const faseRows = await db - .selectDistinct({ value: services.fase }) - .from(services); + const categoria = Array.from( + new Set(catRows.map((r) => r.value).filter((v): v is string => !!v && v.trim().length > 0)) + ).sort((a, b) => a.localeCompare(b, "it")); - 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)), - }; + return { tag, pacchetto, categoria, fase }; } // ── ProjectWithPayments — used by /admin/projects list ─────────────────────── diff --git a/src/lib/offer-queries.ts b/src/lib/offer-queries.ts index 697edaf..dbfa8e3 100644 --- a/src/lib/offer-queries.ts +++ b/src/lib/offer-queries.ts @@ -8,7 +8,7 @@ import { services, tags, } from "@/db/schema"; -import { getJsonPool, SETTINGS_KEYS } from "@/lib/settings"; +import { getPool } from "@/lib/taxonomy"; import type { OfferMacro, OfferMicro, OfferService } from "@/db/schema"; import { eq, and, asc, inArray, sql } from "drizzle-orm"; @@ -273,40 +273,14 @@ export type OfferFieldOptions = { }; // Shared option pools for the offer editor's select fields (Notion-style -// dropdowns). Tipo, Obiettivo, and Categoria are merged from the managed -// settings pools + any values already in use (retrocompatibility). +// dropdowns). All four read from the centralized, persistent taxonomy pools +// (src/lib/taxonomy.ts), seeded from existing values on first access. export async function getOfferFieldOptions(): Promise { - const [categoriaRows, ticketRows, tagRows, tipoPool, obiettivoPool, categoriaPool] = - await Promise.all([ - db.selectDistinct({ value: offer_macros.category }).from(offer_macros), - db.selectDistinct({ value: offer_macros.ticket }).from(offer_macros), - db - .selectDistinct({ name: tags.name, type: tags.entity_type }) - .from(tags) - .where(inArray(tags.entity_type, [OFFER_TIPO_ENTITY, OFFER_OBIETTIVO_ENTITY])), - getJsonPool(SETTINGS_KEYS.OFFER_TIPO_POOL), - getJsonPool(SETTINGS_KEYS.OFFER_OBIETTIVO_POOL), - getJsonPool(SETTINGS_KEYS.OFFER_CATEGORIA_POOL), - ]); - - 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 { - categoria: sortUnique([ - ...categoriaPool, - ...categoriaRows.map((r) => r.value), - ]), - ticket: sortUnique(ticketRows.map((r) => r.value)), - tipo: sortUnique([ - ...tipoPool, - ...tagRows.filter((r) => r.type === OFFER_TIPO_ENTITY).map((r) => r.name), - ]), - obiettivo: sortUnique([ - ...obiettivoPool, - ...tagRows.filter((r) => r.type === OFFER_OBIETTIVO_ENTITY).map((r) => r.name), - ]), - }; + const [categoria, ticket, tipo, obiettivo] = await Promise.all([ + getPool("offer_categoria"), + getPool("offer_ticket"), + getPool("offer_tipo"), + getPool("offer_obiettivo"), + ]); + return { categoria, ticket, tipo, obiettivo }; } diff --git a/src/lib/settings.ts b/src/lib/settings.ts index d58a1e2..adbf4b6 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -5,9 +5,6 @@ import { revalidatePath } from "next/cache"; export const SETTINGS_KEYS = { TARGET_HOURLY_RATE: "target_hourly_rate", - OFFER_TIPO_POOL: "offer_tipo_pool", - OFFER_OBIETTIVO_POOL: "offer_obiettivo_pool", - OFFER_CATEGORIA_POOL: "offer_categoria_pool", } as const; export async function getSetting(key: string): Promise { @@ -19,7 +16,10 @@ export async function getSetting(key: string): Promise { return rows[0]?.value ?? null; } -export async function updateSetting(key: string, value: string): Promise { +// Pure upsert — NO revalidatePath. Safe to call during a server-component render +// (e.g. lazy-seeding a taxonomy pool). Use updateSetting when revalidation is +// desired (form submissions). +export async function writeSetting(key: string, value: string): Promise { const existing = await getSetting(key); if (existing !== null) { await db @@ -29,6 +29,10 @@ export async function updateSetting(key: string, value: string): Promise { } else { await db.insert(settings).values({ key, value }); } +} + +export async function updateSetting(key: string, value: string): Promise { + await writeSetting(key, value); revalidatePath("/admin/impostazioni"); } @@ -36,21 +40,3 @@ export async function getTargetHourlyRate(): Promise { const value = await getSetting(SETTINGS_KEYS.TARGET_HOURLY_RATE); return value ? parseFloat(value) : 50; } - -export async function getJsonPool(key: string): Promise { - const raw = await getSetting(key); - if (!raw) return []; - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? (parsed as string[]) : []; - } catch { - return []; - } -} - -export async function updateJsonPool(key: string, values: string[]): Promise { - const sorted = [...new Set(values.filter((v) => v.trim()))].sort((a, b) => - a.localeCompare(b, "it") - ); - await updateSetting(key, JSON.stringify(sorted)); -} \ No newline at end of file diff --git a/src/lib/taxonomy.ts b/src/lib/taxonomy.ts new file mode 100644 index 0000000..06fbab1 --- /dev/null +++ b/src/lib/taxonomy.ts @@ -0,0 +1,199 @@ +import { db } from "@/db"; +import { tags, offer_macros, services } from "@/db/schema"; +import { eq, sql } from "drizzle-orm"; +import { getSetting, writeSetting } from "@/lib/settings"; + +// ── Centralized taxonomy (Notion-style select properties) ─────────────────── +// Each taxonomy field has a PERSISTENT, independent option pool stored as a JSON +// array in the `settings` table (key `tax_pool_`). Pools survive +// unassignment: deselecting a value on a row never touches the pool — only an +// explicit delete from the settings panel does (cascade-strips it everywhere). + +export type TaxonomyFieldId = + | "offer_categoria" + | "offer_ticket" + | "offer_tipo" + | "offer_obiettivo" + | "service_fase" + | "service_offerta" + | "service_pacchetto"; + +export type TaxonomyGroup = "offer" | "catalog"; + +// Where the field's REAL values live — used for seeding (distinct in-use), +// cascade-delete, and rename propagation. +type Storage = + | { kind: "column"; table: "offer_macros" | "services"; column: "category" | "ticket" | "fase" } + | { kind: "tag"; entityType: string }; + +type FieldConfig = { + label: string; + group: TaxonomyGroup; + settingsKey: string; + storage: Storage; +}; + +export const TAXONOMY_FIELDS: Record = { + offer_categoria: { + label: "Categoria", + group: "offer", + settingsKey: "tax_pool_offer_categoria", + storage: { kind: "column", table: "offer_macros", column: "category" }, + }, + offer_ticket: { + label: "Ticket", + group: "offer", + settingsKey: "tax_pool_offer_ticket", + storage: { kind: "column", table: "offer_macros", column: "ticket" }, + }, + offer_tipo: { + label: "Tipo", + group: "offer", + settingsKey: "tax_pool_offer_tipo", + storage: { kind: "tag", entityType: "offer_macros.tipo" }, + }, + offer_obiettivo: { + label: "Obiettivo", + group: "offer", + settingsKey: "tax_pool_offer_obiettivo", + storage: { kind: "tag", entityType: "offer_macros.obiettivo" }, + }, + service_fase: { + label: "Fase", + group: "catalog", + settingsKey: "tax_pool_service_fase", + storage: { kind: "column", table: "services", column: "fase" }, + }, + service_offerta: { + label: "Offerta", + group: "catalog", + settingsKey: "tax_pool_service_offerta", + storage: { kind: "tag", entityType: "services" }, + }, + service_pacchetto: { + label: "Pacchetto", + group: "catalog", + settingsKey: "tax_pool_service_pacchetto", + storage: { kind: "tag", entityType: "services.pacchetto" }, + }, +}; + +export const TAXONOMY_FIELD_IDS = Object.keys(TAXONOMY_FIELDS) as TaxonomyFieldId[]; + +function sortUnique(values: string[]): string[] { + return Array.from(new Set(values.filter((v) => v && v.trim().length > 0))).sort((a, b) => + a.localeCompare(b, "it") + ); +} + +// Resolves a column-storage descriptor to its drizzle column reference. +// Columns are distinct across the config (category/ticket/fase), so switching +// on `column` is unambiguous and keeps drizzle's typing intact. +function columnRef(storage: Extract) { + if (storage.column === "category") return offer_macros.category; + if (storage.column === "ticket") return offer_macros.ticket; + return services.fase; +} + +// Distinct in-use values for the field — used to seed the pool on first read. +async function readInUseValues(storage: Storage): Promise { + if (storage.kind === "tag") { + const rows = await db + .selectDistinct({ name: tags.name }) + .from(tags) + .where(eq(tags.entity_type, storage.entityType)); + return rows.map((r) => r.name); + } + const rows = await db.selectDistinct({ value: columnRef(storage) }).from( + storage.table === "offer_macros" ? offer_macros : services + ); + return rows.map((r) => r.value).filter((v): v is string => v != null); +} + +// Reads the pool. If the settings row is ABSENT (never seeded), lazy-seeds it +// from in-use values and persists — WITHOUT revalidatePath (safe in render). +// A persisted "[]" is respected (intentionally emptied → no reseed). +export async function getPool(fieldId: TaxonomyFieldId): Promise { + const { settingsKey, storage } = TAXONOMY_FIELDS[fieldId]; + const raw = await getSetting(settingsKey); + + if (raw === null) { + const seeded = sortUnique(await readInUseValues(storage)); + await writeSetting(settingsKey, JSON.stringify(seeded)); + return seeded; + } + + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? sortUnique(parsed as string[]) : []; + } catch { + return []; + } +} + +export async function getAllPools(): Promise> { + const entries = await Promise.all( + TAXONOMY_FIELD_IDS.map(async (id) => [id, await getPool(id)] as const) + ); + return Object.fromEntries(entries) as Record; +} + +// Registers a value into the pool (idempotent). Used by the settings "Aggiungi" +// and by inline-create sync across the offer editor / catalog. Never revalidates. +export async function addPoolValue(fieldId: TaxonomyFieldId, value: string): Promise { + const trimmed = value.trim(); + if (!trimmed) return; + const current = await getPool(fieldId); + if (current.includes(trimmed)) return; + await writeSetting(TAXONOMY_FIELDS[fieldId].settingsKey, JSON.stringify(sortUnique([...current, trimmed]))); +} + +// Removes a value from the pool AND cascade-strips it from every row using it +// (delete tag rows / null out the column). Settings-only (global delete). +export async function removePoolValue(fieldId: TaxonomyFieldId, value: string): Promise { + const { settingsKey, storage } = TAXONOMY_FIELDS[fieldId]; + const current = await getPool(fieldId); + await writeSetting(settingsKey, JSON.stringify(current.filter((v) => v !== value))); + + if (storage.kind === "tag") { + await db + .delete(tags) + .where(sql`${tags.entity_type} = ${storage.entityType} and ${tags.name} = ${value}`); + } else if (storage.column === "category") { + await db.update(offer_macros).set({ category: null }).where(eq(offer_macros.category, value)); + } else if (storage.column === "ticket") { + await db.update(offer_macros).set({ ticket: null }).where(eq(offer_macros.ticket, value)); + } else { + await db.update(services).set({ fase: null }).where(eq(services.fase, value)); + } +} + +// Renames a value in the pool AND propagates to every row using it. +export async function renamePoolValue( + fieldId: TaxonomyFieldId, + oldValue: string, + newValue: string +): Promise { + const next = newValue.trim(); + if (!next || next === oldValue) return; + const { settingsKey, storage } = TAXONOMY_FIELDS[fieldId]; + + const current = await getPool(fieldId); + await writeSetting( + settingsKey, + JSON.stringify(sortUnique(current.map((v) => (v === oldValue ? next : v)))) + ); + + if (storage.kind === "tag") { + await db + .update(tags) + .set({ name: next }) + .where(sql`${tags.entity_type} = ${storage.entityType} and ${tags.name} = ${oldValue}`); + } else if (storage.column === "category") { + await db.update(offer_macros).set({ category: next }).where(eq(offer_macros.category, oldValue)); + } else if (storage.column === "ticket") { + await db.update(offer_macros).set({ ticket: next }).where(eq(offer_macros.ticket, oldValue)); + } else { + await db.update(services).set({ fase: next }).where(eq(services.fase, oldValue)); + } +}