640f986967
Re-scoped Offer Editor (Tier A/B/C, Tag & Prezzo Pubblico) per 2026-06-14 user decision: 5-plan wave structure (schema migration -> prod apply checkpoint -> query/action layer -> list + detail editor UI), OFFER-12 CSV/Notion import deferred.
478 lines
29 KiB
Markdown
478 lines
29 KiB
Markdown
---
|
|
phase: 12
|
|
plan: 03
|
|
type: execute
|
|
wave: 2
|
|
depends_on: [1]
|
|
files_modified:
|
|
- src/lib/offer-queries.ts
|
|
- src/app/admin/offers/actions.ts
|
|
autonomous: true
|
|
requirements: [OFFER-11, OFFER-15, OFFER-16, OFFER-17, OFFER-18]
|
|
|
|
must_haves:
|
|
truths:
|
|
- "Admin can fetch the full offer-editor data shape for a single offer: macro fields (incl. category, ticket, archive flag, transformation-promise fields), its A/B/C tiers, each tier's assigned service IDs + computed services total, and the offer's Tipo/Obiettivo tag values"
|
|
- "Admin can fetch the offer list (card grid) with category + archive status, filterable client-side"
|
|
- "Admin can save the full editor state in one server action: macro scalar fields, category/ticket, transformation-promise fields, per-tier public_price, per-tier service assignments (offer_tier_services), and Tipo/Obiettivo tags — all validated server-side (requireAdmin + Zod, tier_letter in A/B/C)"
|
|
- "Admin can toggle is_archived on an offer"
|
|
artifacts:
|
|
- path: "src/lib/offer-queries.ts"
|
|
provides: "getOfferEditorData(macroId), getOfferListCards(), getOfferFieldOptions() — query layer for Plans 04/05"
|
|
exports: ["getOfferEditorData", "getOfferListCards", "getOfferFieldOptions"]
|
|
- path: "src/app/admin/offers/actions.ts"
|
|
provides: "saveOfferEditor, toggleOfferArchived, addOfferTag, removeOfferTag, renameOfferOption — server actions for Plans 04/05"
|
|
exports: ["saveOfferEditor", "toggleOfferArchived", "addOfferTag", "removeOfferTag", "renameOfferOption", "createOfferMacro"]
|
|
key_links:
|
|
- from: "src/app/admin/offers/actions.ts (saveOfferEditor)"
|
|
to: "offer_tier_services / offer_micros / offer_macros"
|
|
via: "delete-then-reinsert per tier (mirrors updateMicroOfferServices upsert-replace pattern) + update on offer_macros/offer_micros"
|
|
pattern: "offer_tier_services"
|
|
- from: "src/lib/offer-queries.ts (getOfferEditorData)"
|
|
to: "services (Phase 11 unified catalog)"
|
|
via: "filter by services.category matching offer_macros.category (D-4 pre-filter)"
|
|
pattern: "eq\\(services\\.category"
|
|
---
|
|
|
|
<objective>
|
|
Build the query layer and server actions that power the Phase 12 Offer Editor: a single
|
|
`getOfferEditorData(macroId)` query returning everything Plan 05's editor page needs (macro
|
|
fields including `category`/`ticket`/`is_archived`/transformation-promise, its tiers with
|
|
`tier_letter`/`public_price`/assigned service IDs/computed totals, the category-filtered
|
|
service catalog for the matrix, and Tipo/Obiettivo tag values); `getOfferListCards()` for
|
|
Plan 04's list page; and a `saveOfferEditor` server action that persists the entire editor
|
|
state (macro scalars, tier public prices, tier↔service assignments via
|
|
`offer_tier_services`, and Tipo/Obiettivo tags) in one call, plus smaller actions for archive
|
|
toggling and tag CRUD.
|
|
|
|
Purpose: Give Plans 04/05 (Wave 3, UI) a complete, type-safe, admin-only data contract so they
|
|
can be built without touching the DB layer directly — covers OFFER-11 (matrix composition +
|
|
live totals via computed `services_total`), OFFER-15 (4-dimension tags), OFFER-16 (public
|
|
price), OFFER-17 (transformation promise), OFFER-18 (list filter + archive).
|
|
Output: `src/lib/offer-queries.ts` additions, `src/app/admin/offers/actions.ts` additions.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
@.planning/phases/12-offer-composition-drag-drop-csv-import/12-CONTEXT.md
|
|
@.planning/phases/12-offer-composition-drag-drop-csv-import/12-UI-SPEC.md
|
|
@src/lib/offer-queries.ts
|
|
@src/app/admin/offers/actions.ts
|
|
@src/app/admin/catalog/actions.ts
|
|
</context>
|
|
|
|
<interfaces>
|
|
<!-- Schema additions from Plan 01 (already applied to schema.ts; migration 0008 applied to
|
|
prod by Plan 02). Use these directly — no further exploration needed. -->
|
|
|
|
From src/db/schema.ts (after Plan 01 — offer_macros additive columns):
|
|
```typescript
|
|
// offer_macros now also has (all nullable except is_archived):
|
|
// description: text
|
|
// category: text — single-select "Entry Offer" | "Signature Offer" | "Retainer Offer" (free pool, OFFER-15/18)
|
|
// ticket: text — single-select "Low Ticket" | "Mid Ticket" | "High Ticket" (free pool, OFFER-15)
|
|
// is_archived: boolean — not null, default false (OFFER-18)
|
|
// cliente_ideale, risultato, tempo, pain, metodo: text (OFFER-17)
|
|
```
|
|
|
|
From src/db/schema.ts (after Plan 01 — offer_micros additive columns):
|
|
```typescript
|
|
// offer_micros now also has:
|
|
// tier_letter: text — 'A' | 'B' | 'C' | null, CHECK constraint at DB level
|
|
// public_price: numeric(10,2) | null (OFFER-16, manual, independent of services total)
|
|
```
|
|
|
|
From src/db/schema.ts (after Plan 01 — new junction table + types):
|
|
```typescript
|
|
export const offer_tier_services = pgTable("offer_tier_services", {
|
|
tier_id: text("tier_id").notNull().references(() => offer_micros.id, { onDelete: "cascade" }),
|
|
service_id: text("service_id").notNull().references(() => services.id, { onDelete: "cascade" }),
|
|
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
}, (t) => ({ pk: primaryKey({ columns: [t.tier_id, t.service_id] }), tierIdx: index(...) }));
|
|
|
|
export type OfferTierService = typeof offer_tier_services.$inferSelect;
|
|
export type NewOfferTierService = typeof offer_tier_services.$inferInsert;
|
|
```
|
|
|
|
From src/db/schema.ts (services, Phase 11 unified catalog — unchanged):
|
|
```typescript
|
|
export const services = pgTable("services", {
|
|
id: text("id").primaryKey().$defaultFn(() => nanoid()),
|
|
name: text("name").notNull(),
|
|
description: text("description"),
|
|
unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(),
|
|
category: text("category"),
|
|
fase: text("fase"),
|
|
active: boolean("active").notNull().default(true),
|
|
// ...
|
|
});
|
|
```
|
|
|
|
From src/db/schema.ts (tags, polymorphic — unchanged, reused for Tipo/Obiettivo only):
|
|
```typescript
|
|
export const tags = pgTable("tags", {
|
|
id: text("id").primaryKey().$defaultFn(() => nanoid()),
|
|
entity_type: text("entity_type").notNull(),
|
|
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),
|
|
}));
|
|
```
|
|
|
|
From src/lib/offer-queries.ts (current — DO NOT MODIFY these existing exports, they serve the
|
|
legacy `/admin/offers` page and `offer_services`; this plan ADDS new exports alongside):
|
|
```typescript
|
|
export type MicroWithServices = OfferMicro & { services: Array<{...}>; cumulative_price: string };
|
|
export type MacroWithMicros = OfferMacro & { micros: MicroWithServices[] };
|
|
export async function getCatalogWithMicros(): Promise<MacroWithMicros[]>;
|
|
export async function getAllOfferServices(): Promise<OfferService[]>;
|
|
export async function getMicroAssignedServiceIds(microId: string): Promise<string[]>;
|
|
```
|
|
|
|
From src/app/admin/catalog/actions.ts (pattern reference — Notion-style single/multi-select
|
|
option pools via the polymorphic `tags` table; reuse this EXACT pattern for offer tags):
|
|
```typescript
|
|
// addServiceOption(field, serviceId, value) -> tags.insert().onConflictDoNothing()
|
|
// removeServiceOption(field, serviceId, value) -> tags.delete().where(entity_type, entity_id, name)
|
|
// renameServiceOption(field, oldValue, newValue) -> tags.update({name}).where(entity_type, name=old)
|
|
// OR services.update({category|fase}).where(category|fase = old) for single-select columns
|
|
```
|
|
|
|
From src/app/admin/offers/actions.ts (current — DO NOT MODIFY these existing exports, they
|
|
serve the legacy macro/micro CRUD; this plan ADDS new exports alongside):
|
|
```typescript
|
|
// requireAdmin() — async, throws "Non autorizzato" if no session. Reuse identically.
|
|
export async function createMacro(formData: FormData): Promise<void>;
|
|
export async function deleteMacro(macroId: string): Promise<void>;
|
|
export async function createMicro(formData: FormData): Promise<void>;
|
|
export async function deleteMicro(microId: string): Promise<void>;
|
|
export async function updateMicroOfferServices(microId: string, serviceIds: string[]): Promise<void>;
|
|
// ^ delete-then-reinsert pattern — saveOfferEditor's per-tier service assignment follows this.
|
|
```
|
|
</interfaces>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 1: Query layer — getOfferEditorData, getOfferListCards, getOfferFieldOptions</name>
|
|
<files>src/lib/offer-queries.ts</files>
|
|
<behavior>
|
|
Add to `src/lib/offer-queries.ts` (alongside existing exports, do not remove them):
|
|
|
|
- Test 1: `getOfferListCards()` returns one card per `offer_macros` row with
|
|
`{ id, internal_name, description, category, is_archived }`, ordered by `sort_order`.
|
|
An offer with `is_archived = true` is still returned (filtering happens client-side
|
|
per UI-SPEC "Mostra offerte archiviate" toggle) — input: 2 macros (1 archived, 1 not)
|
|
-> output: array of length 2, both present, `is_archived` flags correct.
|
|
- Test 2: `getOfferEditorData(macroId)` for a macro with 0 `offer_micros` rows returns
|
|
`{ macro: {...all fields incl. category/ticket/transformation promise...}, tiers: [],
|
|
availableServices: [...services filtered by services.category === macro.category...],
|
|
tipoTags: [], obiettivoTags: [] }` — input: macro with no tiers, `category =
|
|
"Signature Offer"`, 3 services exist with `services.category = "Signature Offer"` and
|
|
2 with a different category -> output: `tiers.length === 0`,
|
|
`availableServices.length === 3` (only matching-category services).
|
|
- Test 3: `getOfferEditorData(macroId)` for a macro with 3 `offer_micros` rows
|
|
(`tier_letter` = 'A'/'B'/'C') and 2 `offer_tier_services` rows assigned to tier A
|
|
returns `tiers` sorted A→B→C, each tier has `{ id, tier_letter, public_price,
|
|
assignedServiceIds: string[], servicesTotal: string }`; tier A's `servicesTotal`
|
|
equals the sum of `unit_price` for its 2 assigned services (computed via SQL
|
|
`sum(...)`, matching the `cumulative_price` pattern in `getCatalogWithMicros`); tiers
|
|
B/C have `assignedServiceIds: []` and `servicesTotal: "0"`.
|
|
- Test 4: `getOfferEditorData(macroId)` returns `tipoTags`/`obiettivoTags` as `string[]`
|
|
sourced from the polymorphic `tags` table with `entity_type = "offer_macros.tipo"` /
|
|
`"offer_macros.obiettivo"` and `entity_id = macroId` — input: 2 "tipo" tags + 1
|
|
"obiettivo" tag exist for the macro -> output: `tipoTags.length === 2`,
|
|
`obiettivoTags.length === 1`.
|
|
- Test 5: `getOfferFieldOptions()` returns
|
|
`{ categoria: string[], ticket: string[], tipo: string[], obiettivo: string[] }` —
|
|
distinct `offer_macros.category` / `offer_macros.ticket` values (non-null, like
|
|
`getCatalogFieldOptions`'s `categoria`/`fase` pattern) plus distinct `tags.name` where
|
|
`entity_type IN ("offer_macros.tipo", "offer_macros.obiettivo")`, split by
|
|
`entity_type`. Input: 2 macros with categories "Entry Offer"/"Signature Offer", 1
|
|
"tipo" tag "Audit", 1 "obiettivo" tag "Lead Generation" -> output: `categoria` contains
|
|
both values, `tipo === ["Audit"]`, `obiettivo === ["Lead Generation"]`.
|
|
|
|
Write these as a Vitest/Jest test file if a test runner is configured (`npm test` —
|
|
check `package.json` `scripts.test` first); if no test runner exists in this repo,
|
|
SKIP the dedicated test file (do not introduce a new test framework — out of scope) and
|
|
instead write a one-off `scripts/verify-12-03-queries.ts` that calls each function
|
|
against a temporary in-memory assertion of the SQL shape (or, if `DATABASE_URL` is
|
|
unreachable from this environment per project memory, write the script so it TYPECHECKS
|
|
and documents expected behavior in comments matching the 5 cases above — to be run
|
|
manually by the operator against a dev DB later). Either path satisfies the `tdd="true"`
|
|
intent: behavior is specified before/alongside implementation.
|
|
</behavior>
|
|
<action>
|
|
Implement in `src/lib/offer-queries.ts`:
|
|
|
|
1. **`getOfferListCards()`**: select `id, internal_name, description, category,
|
|
is_archived, sort_order` from `offer_macros`, `orderBy(asc(sort_order),
|
|
asc(created_at))`. Return type `OfferListCard[]` where
|
|
`OfferListCard = Pick<OfferMacro, "id" | "internal_name" | "description" | "category" | "is_archived">`.
|
|
|
|
2. **`getOfferEditorData(macroId: string)`**:
|
|
- Fetch the `offer_macros` row by id (return `null` if not found — Plan 05 handles
|
|
404).
|
|
- Fetch its `offer_micros` rows ordered by `tier_letter` (A, B, C — use
|
|
`sql\`CASE tier_letter WHEN 'A' THEN 1 WHEN 'B' THEN 2 WHEN 'C' THEN 3 ELSE 4 END\``
|
|
for ordering, since plain `asc(tier_letter)` would sort nulls/letters
|
|
alphabetically which happens to match A<B<C but nulls need explicit handling —
|
|
tiers without `tier_letter` set sort last).
|
|
- For each tier, fetch assigned `offer_tier_services` rows (service_id list) and
|
|
compute `servicesTotal` via the same `coalesce(sum(unit_price::numeric), 0)`
|
|
pattern as `getCatalogWithMicros`'s `cumulMap`, joined through
|
|
`offer_tier_services` -> `services`.
|
|
- Fetch `availableServices`: all `services` where `active = true` AND (if
|
|
`macro.category` is set) `services.category = macro.category`; if
|
|
`macro.category` is null, return all active services (no pre-filter — D-4 filter
|
|
is best-effort, not a hard constraint when category is unset). Shape:
|
|
`{ id, name, unit_price, category }[]`.
|
|
- Fetch `tipoTags`/`obiettivoTags`: `select distinct name from tags where entity_type
|
|
in ("offer_macros.tipo","offer_macros.obiettivo") and entity_id = macroId`, split
|
|
by `entity_type` into two `string[]`.
|
|
- Return shape:
|
|
```typescript
|
|
export type OfferTierData = {
|
|
id: string;
|
|
tier_letter: string | null;
|
|
internal_name: string;
|
|
public_name: string;
|
|
duration_months: number;
|
|
public_price: string | null;
|
|
assignedServiceIds: string[];
|
|
servicesTotal: string;
|
|
};
|
|
export type OfferEditorData = {
|
|
macro: OfferMacro; // includes category, ticket, is_archived, cliente_ideale, risultato, tempo, pain, metodo, description
|
|
tiers: OfferTierData[];
|
|
availableServices: Array<{ id: string; name: string; unit_price: string; category: string | null }>;
|
|
tipoTags: string[];
|
|
obiettivoTags: string[];
|
|
};
|
|
export async function getOfferEditorData(macroId: string): Promise<OfferEditorData | null>;
|
|
```
|
|
|
|
3. **`getOfferFieldOptions()`**:
|
|
```typescript
|
|
export type OfferFieldOptions = {
|
|
categoria: string[];
|
|
ticket: string[];
|
|
tipo: string[];
|
|
obiettivo: string[];
|
|
};
|
|
export async function getOfferFieldOptions(): Promise<OfferFieldOptions>;
|
|
```
|
|
`categoria`/`ticket`: `selectDistinct` on `offer_macros.category` /
|
|
`offer_macros.ticket`, filter out nulls, sort alphabetically (mirror
|
|
`getCatalogFieldOptions`'s null-filter pattern for `categoria`/`fase`).
|
|
`tipo`/`obiettivo`: `selectDistinct({ name: tags.name, type: tags.entity_type })` where
|
|
`entity_type in ("offer_macros.tipo", "offer_macros.obiettivo")`, split by type.
|
|
|
|
Use existing imports (`db`, `eq`, `asc`, `inArray`, `sql`, `and`) — add `desc` or others
|
|
only if genuinely needed. Import `offer_tier_services`, `tags`, `services` from
|
|
`@/db/schema` (services/tags likely not yet imported in this file — check current
|
|
imports first).
|
|
</action>
|
|
<verify>
|
|
<automated>cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | tail -30</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `npx tsc --noEmit` passes with zero errors
|
|
- `grep -c "export async function getOfferEditorData\|export async function getOfferListCards\|export async function getOfferFieldOptions" src/lib/offer-queries.ts` == 3
|
|
- `grep -c "export async function getCatalogWithMicros\|export async function getAllOfferServices\|export async function getMicroAssignedServiceIds" src/lib/offer-queries.ts` == 3 (existing exports untouched)
|
|
- `grep -c "offer_tier_services" src/lib/offer-queries.ts` >= 1
|
|
</acceptance_criteria>
|
|
<done>offer-queries.ts exports getOfferEditorData, getOfferListCards, getOfferFieldOptions with the documented shapes; existing exports unchanged; file typechecks.</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 2: Server actions — saveOfferEditor, toggleOfferArchived, offer tag CRUD, createOfferMacro</name>
|
|
<files>src/app/admin/offers/actions.ts</files>
|
|
<behavior>
|
|
Add to `src/app/admin/offers/actions.ts` (alongside existing exports, do not remove
|
|
them):
|
|
|
|
- Test 1: `saveOfferEditor(macroId, payload)` rejects (throws) when `payload.tiers`
|
|
contains a `tier_letter` not in `["A","B","C"]` — Zod enum validation, defense-in-depth
|
|
alongside the DB CHECK constraint from Plan 01.
|
|
- Test 2: `saveOfferEditor(macroId, payload)` updates `offer_macros` scalar fields
|
|
(`internal_name`, `description`, `category`, `ticket`, `cliente_ideale`, `risultato`,
|
|
`tempo`, `pain`, `metodo`) in one `db.update(offer_macros)...where(eq(id, macroId))`.
|
|
- Test 3: `saveOfferEditor` upserts each tier in `payload.tiers`: if a tier `id` is
|
|
provided, `db.update(offer_micros).set({ tier_letter, public_price, internal_name,
|
|
public_name, duration_months })`; if no `id`, `db.insert(offer_micros).values({...,
|
|
macro_id: macroId})` (supports creating tiers for a macro that has fewer than 3).
|
|
- Test 4: `saveOfferEditor` replaces each tier's `offer_tier_services` rows via
|
|
delete-then-reinsert (same pattern as `updateMicroOfferServices`): input tier with
|
|
`assignedServiceIds: ["svc1","svc2"]` -> after call, `offer_tier_services` has exactly
|
|
2 rows for that `tier_id`, both pointing at svc1/svc2.
|
|
- Test 5: `saveOfferEditor` replaces Tipo/Obiettivo tags via delete-then-reinsert against
|
|
`tags` where `entity_type in ("offer_macros.tipo","offer_macros.obiettivo")` and
|
|
`entity_id = macroId`.
|
|
- Test 6: `toggleOfferArchived(macroId, archived: boolean)` sets
|
|
`offer_macros.is_archived = archived`.
|
|
- Test 7: `addOfferTag(dimension, macroId, value)` / `removeOfferTag(dimension, macroId,
|
|
value)` work for `dimension in ("tipo","obiettivo")`, mirroring
|
|
`addServiceOption`/`removeServiceOption` against `entity_type =
|
|
"offer_macros." + dimension`. Reject any other `dimension` value.
|
|
- Test 8: `renameOfferOption(field, oldValue, newValue)` for `field in
|
|
("categoria","ticket")` updates `offer_macros.category`/`offer_macros.ticket` for all
|
|
rows matching `oldValue` (mirrors `renameServiceOption`'s single-select branch); for
|
|
`field in ("tipo","obiettivo")` updates `tags.name` (mirrors the multi-select branch).
|
|
- Test 9: `createOfferMacro(formData)` creates a new `offer_macros` row from
|
|
`internal_name` (required) + optional `public_name`/`description`/`category` —
|
|
needed so Plan 04's "+ Nuova Offerta" button has a target action (UI-SPEC section 1A).
|
|
If `public_name` is omitted, default it to `internal_name` (existing `offer_macros`
|
|
schema requires `public_name` NOT NULL).
|
|
|
|
Same test-runner decision as Plan 03 Task 1: if no test runner configured, write
|
|
`scripts/verify-12-03-actions.ts` (typechecks, documents the 9 cases) instead of a
|
|
dedicated test file — do not introduce a new framework.
|
|
</behavior>
|
|
<action>
|
|
Implement in `src/app/admin/offers/actions.ts`, reusing `requireAdmin()`,
|
|
`revalidatePath("/admin/offers")`, and the `z` (Zod) import already present:
|
|
|
|
1. **`saveOfferEditor(macroId: string, payload: SaveOfferEditorPayload)`** where:
|
|
```typescript
|
|
const tierSchema = z.object({
|
|
id: z.string().optional(),
|
|
tier_letter: z.enum(["A", "B", "C"]),
|
|
internal_name: z.string().min(1),
|
|
public_name: z.string().min(1),
|
|
duration_months: z.coerce.number().int().min(1),
|
|
public_price: z.coerce.number().min(0).optional().nullable(),
|
|
assignedServiceIds: z.array(z.string()),
|
|
});
|
|
const saveOfferEditorSchema = z.object({
|
|
internal_name: z.string().min(1, "Nome interno richiesto"),
|
|
public_name: z.string().min(1, "Nome pubblico richiesto"),
|
|
description: z.string().optional(),
|
|
category: z.string().optional(),
|
|
ticket: z.string().optional(),
|
|
cliente_ideale: z.string().optional(),
|
|
risultato: z.string().optional(),
|
|
tempo: z.string().optional(),
|
|
pain: z.string().optional(),
|
|
metodo: z.string().optional(),
|
|
tiers: z.array(tierSchema).max(3),
|
|
tipoTags: z.array(z.string()),
|
|
obiettivoTags: z.array(z.string()),
|
|
});
|
|
export type SaveOfferEditorPayload = z.infer<typeof saveOfferEditorSchema>;
|
|
```
|
|
Implementation order (sequential `await`s, no transaction wrapper needed — Drizzle
|
|
+ `postgres` driver here doesn't use an interactive tx helper elsewhere in this
|
|
codebase, follow existing per-statement pattern):
|
|
- `safeParse` the payload; throw `parsed.error.issues[0].message` on failure.
|
|
- `db.update(offer_macros).set({...scalars, public_price fields N/A here...}).where(eq(offer_macros.id, macroId))`
|
|
— set `internal_name, public_name, description, category, ticket, cliente_ideale,
|
|
risultato, tempo, pain, metodo` (empty-string -> `null` for optional text fields,
|
|
same `|| null` convention as `createMacro`).
|
|
- For each tier in `parsed.data.tiers`:
|
|
- If `tier.id` exists: `db.update(offer_micros).set({ tier_letter, internal_name,
|
|
public_name, duration_months, public_price: tier.public_price != null ?
|
|
String(tier.public_price) : null }).where(eq(offer_micros.id, tier.id))`.
|
|
- Else: `db.insert(offer_micros).values({ macro_id: macroId, tier_letter,
|
|
internal_name, public_name, duration_months, public_price: ... })` and capture
|
|
the new id (insert `.returning({ id: offer_micros.id })`).
|
|
- Delete-then-reinsert `offer_tier_services` for that tier id (mirror
|
|
`updateMicroOfferServices`): `db.delete(offer_tier_services).where(eq(tier_id,
|
|
...))`, then if `assignedServiceIds.length > 0`,
|
|
`db.insert(offer_tier_services).values(assignedServiceIds.map(service_id => ({
|
|
tier_id, service_id })))`.
|
|
- Delete-then-reinsert Tipo/Obiettivo tags: `db.delete(tags).where(and(eq(entity_id,
|
|
macroId), inArray(entity_type, ["offer_macros.tipo","offer_macros.obiettivo"])))`,
|
|
then insert rows for each `tipoTags`/`obiettivoTags` entry with the corresponding
|
|
`entity_type`, using `onConflictDoNothing()` per row (mirror `addServiceOption`).
|
|
- `revalidatePath("/admin/offers")` and `revalidatePath(\`/admin/offers/${macroId}/edit\`)`.
|
|
|
|
2. **`toggleOfferArchived(macroId: string, archived: boolean)`**: `requireAdmin()`,
|
|
`db.update(offer_macros).set({ is_archived: archived }).where(eq(id, macroId))`,
|
|
`revalidatePath("/admin/offers")`.
|
|
|
|
3. **`addOfferTag(dimension: "tipo" | "obiettivo", macroId: string, value: string)`** /
|
|
**`removeOfferTag(dimension, macroId, value)`**: mirror
|
|
`addServiceOption`/`removeServiceOption` exactly, using `entity_type =
|
|
\`offer_macros.${dimension}\`` and `entity_id = macroId`. Validate `dimension` is one
|
|
of the two allowed values (throw otherwise).
|
|
|
|
4. **`renameOfferOption(field: "categoria" | "ticket" | "tipo" | "obiettivo", oldValue:
|
|
string, newValue: string)`**: mirror `renameServiceOption`. For `"categoria"`:
|
|
`db.update(offer_macros).set({ category: next }).where(eq(category, oldValue))`. For
|
|
`"ticket"`: same on `offer_macros.ticket`. For `"tipo"`/`"obiettivo"`:
|
|
`db.update(tags).set({ name: next }).where(and(eq(entity_type,
|
|
\`offer_macros.${field}\`), eq(name, oldValue)))`.
|
|
|
|
5. **`createOfferMacro(formData: FormData)`**: Zod-validate `internal_name` (required),
|
|
optional `public_name` (default = `internal_name` if empty), `description`,
|
|
`category`. `db.insert(offer_macros).values({...})`, `revalidatePath("/admin/offers")`.
|
|
This is additive alongside the existing `createMacro` (legacy form-based create on
|
|
the old page) — `createOfferMacro` is the Plan 04 "+ Nuova Offerta" target with the
|
|
new fields.
|
|
|
|
Import `offer_tier_services`, `tags` from `@/db/schema` (add to existing import list);
|
|
import `inArray` from `drizzle-orm` if not already imported.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | tail -30</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `npx tsc --noEmit` passes with zero errors
|
|
- `grep -c "export async function saveOfferEditor\|export async function toggleOfferArchived\|export async function addOfferTag\|export async function removeOfferTag\|export async function renameOfferOption\|export async function createOfferMacro" src/app/admin/offers/actions.ts` == 6
|
|
- `grep -c "export async function createMacro\|export async function deleteMacro\|export async function createMicro\|export async function deleteMicro\|export async function updateMicroOfferServices" src/app/admin/offers/actions.ts` == 5 (existing exports untouched)
|
|
- `grep -c "z.enum(\[\"A\", \"B\", \"C\"\]\|z.enum([\"A\",\"B\",\"C\"]" src/app/admin/offers/actions.ts` >= 1 (tier_letter Zod validation present)
|
|
- `grep -c "requireAdmin()" src/app/admin/offers/actions.ts` >= 11 (every action, old + new, calls requireAdmin)
|
|
</acceptance_criteria>
|
|
<done>actions.ts exports saveOfferEditor, toggleOfferArchived, addOfferTag, removeOfferTag, renameOfferOption, createOfferMacro — all requireAdmin-guarded, Zod-validated, revalidating /admin/offers; existing exports unchanged; file typechecks.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<threat_model>
|
|
## Trust Boundaries
|
|
|
|
| Boundary | Description |
|
|
|----------|-------------|
|
|
| Admin browser -> saveOfferEditor server action | Admin-authenticated input (full editor payload: scalars, tier compositions, tags) crosses into DB writes |
|
|
| Admin browser -> offer tag CRUD actions | Free-text tag names (Tipo/Obiettivo/Categoria/Ticket) crosses into `tags`/`offer_macros` |
|
|
|
|
## STRIDE Threat Register
|
|
|
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
|
|-----------|----------|-----------|-------------|-----------------|
|
|
| T-12-06 | Tampering | `saveOfferEditor` tier_letter / numeric fields | mitigate | Zod schema: `tier_letter` restricted to enum `["A","B","C"]`, `public_price`/`duration_months` coerced + min-bounded; DB CHECK constraint (Plan 01) as second layer |
|
|
| T-12-07 | Elevation of Privilege | All new server actions (`saveOfferEditor`, `toggleOfferArchived`, tag CRUD, `createOfferMacro`) | mitigate | Every action calls `requireAdmin()` first (session check via Auth.js), identical to all existing `/admin/offers` and `/admin/catalog` actions |
|
|
| T-12-08 | Tampering | Free-text tag/category/ticket values stored via `onConflictDoNothing`/update | accept | Same trust level as Phase 11 catalog tags (admin-only input, no client-facing exposure); length/content not constrained beyond non-empty, consistent with existing `addServiceOption`/`renameServiceOption` |
|
|
| T-12-09 | Information Disclosure | `getOfferEditorData`/`getOfferListCards`/`getOfferFieldOptions` expose `offer_tier_services`/pricing data | accept | Functions are imported only by `/admin/offers/*` pages (Plans 04/05), which sit behind Auth.js session middleware; no route in this plan is client-facing |
|
|
</threat_model>
|
|
|
|
<verification>
|
|
1. `npx tsc --noEmit` passes for the full project.
|
|
2. `getOfferEditorData`, `getOfferListCards`, `getOfferFieldOptions` exported from `src/lib/offer-queries.ts`, existing exports (`getCatalogWithMicros`, `getAllOfferServices`, `getMicroAssignedServiceIds`) untouched.
|
|
3. `saveOfferEditor`, `toggleOfferArchived`, `addOfferTag`, `removeOfferTag`, `renameOfferOption`, `createOfferMacro` exported from `src/app/admin/offers/actions.ts`, existing exports (`createMacro`, `deleteMacro`, `createMicro`, `deleteMicro`, `updateMicroOfferServices`) untouched.
|
|
4. Every new server action calls `requireAdmin()` before any DB write.
|
|
5. `saveOfferEditor` validates `tier_letter` against `["A","B","C"]` via Zod before any write.
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- Plan 05's editor page can fetch a complete `OfferEditorData` shape and persist all of it via `saveOfferEditor` in one call.
|
|
- Plan 04's list page can fetch `OfferListCard[]` with category + archive status.
|
|
- All four tag dimensions (Categoria, Ticket, Tipo, Obiettivo) are readable/writable via `getOfferFieldOptions`/`addOfferTag`/`removeOfferTag`/`renameOfferOption`.
|
|
- No legacy `offer_micro_services`/`offer_services`/existing-export code paths modified.
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/12-offer-composition-drag-drop-csv-import/12-03-SUMMARY.md`
|
|
</output>
|