chore: riorganizzazione e pulizia della cartella di progetto
Rimossi i doppioni e gli artefatti accumulati, senza cancellare nulla di definitivo: tutto cio' che serviva una revisione e' parcheggiato in cestino/ (gitignored), documentato in cestino/LEGGIMI.md. - .planning/phases/01-10: 10 cartelle identiche byte-per-byte alle copie in .planning/milestones/v1.0-phases e v2.0-phases. HANDOFF.md:33 documentava che furono copiate e non spostate, lasciando la pulizia 'facoltativa in futuro'. Verificata l'identita' con diff -rq prima di spostare ciascuna. - scripts/: 13 script one-off gia' eseguiti (push-*, migrate-*, validate-*, verify-12-03-*) piu' reset-and-import-services.ts, che cancella dati. Restano i 3 riutilizzabili: seed, import-services-notion, import-service-offer-tags. - CLAUDE-SECURITY-20260727-210226/: cartella di lavoro della run interrotta. Cancellati subito, senza revisione: 6 .DS_Store, le due cache .impeccable/ (una era dentro src/) e tsconfig.tsbuildinfo. .gitignore: aggiunti cestino/, .impeccable/ e CLAUDE-SECURITY-*/ per evitare che si riformino. src/ non e' stato toccato: la struttura e' dettata dall'App Router di Next. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,86 +0,0 @@
|
||||
import { db } from "@/db";
|
||||
import { service_catalog, offer_services, services } from "@/db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
async function migrate() {
|
||||
console.log("Starting services unification migration...\n");
|
||||
|
||||
// 1. Backfill from service_catalog (operational pricing, used by quote_items)
|
||||
const catalogRows = await db.select().from(service_catalog);
|
||||
let catalogInserted = 0;
|
||||
let catalogSkipped = 0;
|
||||
|
||||
for (const row of catalogRows) {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(services)
|
||||
.where(and(eq(services.migrated_from, "service_catalog"), eq(services.migrated_id, row.id)))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
catalogSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await db.insert(services).values({
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
unit_price: row.unit_price,
|
||||
category: "catalog",
|
||||
active: row.active,
|
||||
migrated_from: "service_catalog",
|
||||
migrated_id: row.id,
|
||||
});
|
||||
catalogInserted++;
|
||||
}
|
||||
console.log(`service_catalog: ${catalogInserted} inserted, ${catalogSkipped} skipped (already migrated)`);
|
||||
|
||||
// 2. Backfill from offer_services (marketing pricing, used by offer_micro_services)
|
||||
const offerRows = await db.select().from(offer_services);
|
||||
let offerInserted = 0;
|
||||
let offerSkipped = 0;
|
||||
let renamed = 0;
|
||||
|
||||
for (const row of offerRows) {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(services)
|
||||
.where(and(eq(services.migrated_from, "offer_services"), eq(services.migrated_id, row.id)))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
offerSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Name collision check against ALL services rows inserted so far (both sources)
|
||||
const collision = await db
|
||||
.select()
|
||||
.from(services)
|
||||
.where(eq(services.name, row.name))
|
||||
.limit(1);
|
||||
|
||||
const finalName = collision.length > 0 ? `${row.name} (Offer)` : row.name;
|
||||
if (collision.length > 0) renamed++;
|
||||
|
||||
await db.insert(services).values({
|
||||
name: finalName,
|
||||
description: row.transformation_description,
|
||||
unit_price: row.price,
|
||||
category: "offer",
|
||||
active: row.active,
|
||||
migrated_from: "offer_services",
|
||||
migrated_id: row.id,
|
||||
});
|
||||
offerInserted++;
|
||||
}
|
||||
console.log(`offer_services: ${offerInserted} inserted, ${offerSkipped} skipped (already migrated), ${renamed} renamed for collision`);
|
||||
|
||||
console.log("\nMigration complete. Run scripts/validate-services-migration.ts next.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
migrate().catch((err) => {
|
||||
console.error("Migration failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import { db } from "@/db";
|
||||
import { services, tags } from "@/db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
async function migrate() {
|
||||
console.log("Starting tags migration: assigning 'Offerta' tag to services migrated from offer_services...\n");
|
||||
|
||||
const offerServices = await db
|
||||
.select({ id: services.id })
|
||||
.from(services)
|
||||
.where(eq(services.migrated_from, "offer_services"));
|
||||
|
||||
let taggedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
for (const service of offerServices) {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(tags)
|
||||
.where(
|
||||
and(
|
||||
eq(tags.entity_type, "services"),
|
||||
eq(tags.entity_id, service.id),
|
||||
eq(tags.name, "Offerta")
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await db.insert(tags).values({
|
||||
entity_type: "services",
|
||||
entity_id: service.id,
|
||||
name: "Offerta",
|
||||
});
|
||||
taggedCount++;
|
||||
}
|
||||
|
||||
console.log(`Assigned 'Offerta' tag: ${taggedCount} services, ${skippedCount} already tagged`);
|
||||
console.log("\nMigration complete. Run scripts/validate-tags-migration.ts next.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
migrate().catch((err) => {
|
||||
console.error("Migration failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
// PRODUCTION DEPLOY NOTE: This migration is additive-only (CREATE TABLE IF NOT EXISTS +
|
||||
// 2 indexes, no drops/truncates). Per CLAUDE.md Data Safety, apply to production via
|
||||
// SSH+docker exec BEFORE pushing Phase 11 schema-dependent code (Plans 02-04).
|
||||
// Run: npx tsx scripts/push-11-tags-migration.ts (with prod DATABASE_URL)
|
||||
import postgres from "postgres";
|
||||
|
||||
async function push() {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) {
|
||||
console.error("DATABASE_URL environment variable is required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = postgres(databaseUrl);
|
||||
|
||||
try {
|
||||
console.log("Pushing tags table migration...");
|
||||
|
||||
await client`
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id text PRIMARY KEY,
|
||||
entity_type text NOT NULL,
|
||||
entity_id text NOT NULL,
|
||||
name text NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
)
|
||||
`;
|
||||
|
||||
await client`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS tags_entity_name_unique
|
||||
ON tags (entity_type, entity_id, name)
|
||||
`;
|
||||
|
||||
await client`
|
||||
CREATE INDEX IF NOT EXISTS tags_entity_idx
|
||||
ON tags (entity_type, entity_id)
|
||||
`;
|
||||
|
||||
console.log("✓ tags table created successfully");
|
||||
process.exit(0);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
if (err.message.includes("already exists")) {
|
||||
console.log("✓ tags table already exists (skipped)");
|
||||
process.exit(0);
|
||||
}
|
||||
console.error("Error pushing migration:", err.message);
|
||||
} else {
|
||||
console.error("Unknown error:", err);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
push();
|
||||
@@ -1,27 +0,0 @@
|
||||
// PRODUCTION DEPLOY NOTE: additive-only (ADD COLUMN IF NOT EXISTS, no drops/truncates).
|
||||
// Per CLAUDE.md Data Safety, apply to the live DB via SSH tunnel / docker exec BEFORE
|
||||
// deploying the schema-dependent catalog code. Idempotent — safe to re-run.
|
||||
// Run: npx tsx scripts/push-11b-fase-column.ts (with DATABASE_URL pointing at the DB)
|
||||
import postgres from "postgres";
|
||||
|
||||
async function push() {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) {
|
||||
console.error("DATABASE_URL environment variable is required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = postgres(databaseUrl);
|
||||
|
||||
try {
|
||||
console.log("Adding services.fase column...");
|
||||
await client`ALTER TABLE services ADD COLUMN IF NOT EXISTS fase text`;
|
||||
console.log("✓ services.fase column ready");
|
||||
process.exit(0);
|
||||
} catch (err: unknown) {
|
||||
console.error("Error pushing migration:", err instanceof Error ? err.message : err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
push();
|
||||
@@ -1,142 +0,0 @@
|
||||
// PRODUCTION DEPLOY NOTE: This migration (0008_offer_tier_schema.sql) is additive-only
|
||||
// (ADD COLUMN IF NOT EXISTS, a guarded DO-block CHECK constraint, CREATE TABLE IF NOT
|
||||
// EXISTS, and CREATE INDEX IF NOT EXISTS — no drops/truncates). Per CLAUDE.md Data
|
||||
// Safety, this script MUST be run against PRODUCTION via SSH+docker exec BEFORE Phase
|
||||
// 12 Wave 3 code (query layer reading offer_tier_services/tier_letter/public_price/
|
||||
// category/ticket) is exercised against production data. This is the BLOCKING step
|
||||
// for Plan 02. Idempotent — safe to re-run.
|
||||
// Run: npx tsx scripts/push-12-offer-tier-schema.ts (with prod DATABASE_URL)
|
||||
import postgres from "postgres";
|
||||
|
||||
async function push() {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) {
|
||||
console.error("DATABASE_URL environment variable is required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = postgres(databaseUrl);
|
||||
|
||||
try {
|
||||
console.log("Pushing offer tier schema migration (0008)...");
|
||||
|
||||
// offer_macros: archive flag + short description + category/ticket dimensions +
|
||||
// structured transformation promise
|
||||
const offerMacrosColumns: Array<[string, string]> = [
|
||||
["description", "text"],
|
||||
["category", "text"],
|
||||
["ticket", "text"],
|
||||
["is_archived", "boolean NOT NULL DEFAULT false"],
|
||||
["cliente_ideale", "text"],
|
||||
["risultato", "text"],
|
||||
["tempo", "text"],
|
||||
["pain", "text"],
|
||||
["metodo", "text"],
|
||||
];
|
||||
|
||||
for (const [column, type] of offerMacrosColumns) {
|
||||
try {
|
||||
await client.unsafe(
|
||||
`ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS ${column} ${type}`
|
||||
);
|
||||
console.log(` ✓ offer_macros.${column} ready`);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.message.includes("already exists")) {
|
||||
console.log(` ✓ offer_macros.${column} already exists (skipped)`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// offer_micros: tier designation (A/B/C) + manual public price
|
||||
const offerMicrosColumns: Array<[string, string]> = [
|
||||
["tier_letter", "text"],
|
||||
["public_price", "numeric(10, 2)"],
|
||||
];
|
||||
|
||||
for (const [column, type] of offerMicrosColumns) {
|
||||
try {
|
||||
await client.unsafe(
|
||||
`ALTER TABLE offer_micros ADD COLUMN IF NOT EXISTS ${column} ${type}`
|
||||
);
|
||||
console.log(` ✓ offer_micros.${column} ready`);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.message.includes("already exists")) {
|
||||
console.log(` ✓ offer_micros.${column} already exists (skipped)`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CHECK constraint for tier_letter, guarded so it's safe to re-run.
|
||||
try {
|
||||
await client.unsafe(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'offer_micros_tier_letter_check'
|
||||
) THEN
|
||||
ALTER TABLE offer_micros
|
||||
ADD CONSTRAINT offer_micros_tier_letter_check
|
||||
CHECK (tier_letter IS NULL OR tier_letter IN ('A', 'B', 'C'));
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
console.log(" ✓ offer_micros_tier_letter_check constraint ready");
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.message.includes("already exists")) {
|
||||
console.log(" ✓ offer_micros_tier_letter_check already exists (skipped)");
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// New junction table: tier (offer_micros) <-> unified services catalog.
|
||||
try {
|
||||
await client.unsafe(`
|
||||
CREATE TABLE IF NOT EXISTS offer_tier_services (
|
||||
tier_id text NOT NULL REFERENCES offer_micros(id) ON DELETE CASCADE,
|
||||
service_id text NOT NULL REFERENCES services(id) ON DELETE CASCADE,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
PRIMARY KEY (tier_id, service_id)
|
||||
)
|
||||
`);
|
||||
console.log(" ✓ offer_tier_services table ready");
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.message.includes("already exists")) {
|
||||
console.log(" ✓ offer_tier_services table already exists (skipped)");
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await client.unsafe(`
|
||||
CREATE INDEX IF NOT EXISTS offer_tier_services_tier_idx ON offer_tier_services USING btree (tier_id)
|
||||
`);
|
||||
console.log(" ✓ offer_tier_services_tier_idx index ready");
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.message.includes("already exists")) {
|
||||
console.log(" ✓ offer_tier_services_tier_idx already exists (skipped)");
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("✓ Migration 0008 (offer tier schema) applied successfully");
|
||||
process.exit(0);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
console.error("Error pushing migration:", err.message);
|
||||
} else {
|
||||
console.error("Unknown error:", err);
|
||||
}
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
push();
|
||||
@@ -1,79 +0,0 @@
|
||||
import postgres from "postgres";
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
// Applies 0005_phase_10_crm_leads_activities_reminders.sql (additive-only:
|
||||
// ALTER TABLE leads ADD COLUMN x8, CREATE TABLE activities/reminders, indexes).
|
||||
// Idempotent: skips if already applied. Never drops or truncates anything.
|
||||
|
||||
async function push() {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) {
|
||||
console.error("DATABASE_URL environment variable is required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sql = postgres(databaseUrl, { max: 1 });
|
||||
|
||||
try {
|
||||
const cols = await sql`
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'leads' ORDER BY ordinal_position
|
||||
`;
|
||||
const tables = await sql`
|
||||
SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name IN ('activities', 'reminders')
|
||||
`;
|
||||
const colNames = cols.map((c) => c.column_name);
|
||||
console.log("Pre-state — leads columns:", colNames.join(", "));
|
||||
console.log(
|
||||
"Pre-state — CRM tables:",
|
||||
tables.map((t) => t.table_name).join(", ") || "none"
|
||||
);
|
||||
|
||||
if (colNames.includes("status") && tables.length === 2) {
|
||||
console.log("✓ Migration 0005 already applied (skipped)");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const migrationSql = readFileSync(
|
||||
join(
|
||||
__dirname,
|
||||
"../src/db/migrations/0005_phase_10_crm_leads_activities_reminders.sql"
|
||||
),
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
console.log("Applying migration 0005 in a transaction...");
|
||||
await sql.begin(async (tx) => {
|
||||
await tx.unsafe(migrationSql);
|
||||
});
|
||||
|
||||
const postCols = await sql`
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'leads' ORDER BY ordinal_position
|
||||
`;
|
||||
const postTables = await sql`
|
||||
SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name IN ('activities', 'reminders')
|
||||
`;
|
||||
console.log(
|
||||
"Post-state — leads columns:",
|
||||
postCols.map((c) => c.column_name).join(", ")
|
||||
);
|
||||
console.log(
|
||||
"Post-state — CRM tables:",
|
||||
postTables.map((t) => t.table_name).join(", ")
|
||||
);
|
||||
console.log("✓ Migration 0005 applied successfully");
|
||||
process.exit(0);
|
||||
} catch (err: unknown) {
|
||||
console.error(
|
||||
"Error applying migration:",
|
||||
err instanceof Error ? err.message : err
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
push();
|
||||
@@ -1,48 +0,0 @@
|
||||
import postgres from "postgres";
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
async function push() {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) {
|
||||
console.error("DATABASE_URL environment variable is required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = postgres(databaseUrl);
|
||||
|
||||
try {
|
||||
console.log("Pushing services table migration...");
|
||||
|
||||
// Create the services table
|
||||
await client`
|
||||
CREATE TABLE IF NOT EXISTS services (
|
||||
id text PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
unit_price numeric(10, 2) NOT NULL,
|
||||
category text,
|
||||
active boolean DEFAULT true NOT NULL,
|
||||
migrated_from text,
|
||||
migrated_id text,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
)
|
||||
`;
|
||||
|
||||
console.log("✓ services table created successfully");
|
||||
process.exit(0);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
if (err.message.includes("already exists")) {
|
||||
console.log("✓ services table already exists (skipped)");
|
||||
process.exit(0);
|
||||
}
|
||||
console.error("Error pushing migration:", err.message);
|
||||
} else {
|
||||
console.error("Unknown error:", err);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
push();
|
||||
@@ -1,134 +0,0 @@
|
||||
// One-shot: deletes 3 legacy test services, then imports 55 from Notion CSV.
|
||||
// offer_tier_services rows cascade-delete automatically (ON DELETE CASCADE).
|
||||
// Run via SSH tunnel:
|
||||
// DATABASE_URL=$(node --env-file=.env.local -e 'const u=new URL(process.env.DATABASE_URL); u.host="127.0.0.1:54321"; process.stdout.write(u.toString())') \
|
||||
// npx tsx scripts/reset-and-import-services.ts
|
||||
import postgres from "postgres";
|
||||
import { customAlphabet } from "nanoid";
|
||||
|
||||
const nanoid = customAlphabet("0123456789abcdefghijklmnopqrstuvwxyz", 12);
|
||||
|
||||
type ServiceRow = {
|
||||
name: string;
|
||||
unit_price: number;
|
||||
category: string;
|
||||
fase: string | null;
|
||||
};
|
||||
|
||||
const SERVICES: ServiceRow[] = [
|
||||
{ name: "Raccolta e Mappatura Materiali", unit_price: 100, category: "Signature Offer", fase: "Fase 1 → Onboarding / Setup" },
|
||||
{ name: "Audit iniziale (UX/UI, struttura, conversione)", unit_price: 500, category: "Signature Offer", fase: "Fase 1 → Onboarding / Setup" },
|
||||
{ name: "Workshop (1° fase)", unit_price: 900, category: "Signature Offer", fase: "Fase 1 → Onboarding / Setup" },
|
||||
{ name: "Analisi Competitor", unit_price: 400, category: "Signature Offer", fase: "Fase 2 → Analisi / Strategia" },
|
||||
{ name: "Documento di restituzione (problemi + lista ottimizzazioni)", unit_price: 500, category: "Entry Offer", fase: "Fase 3 → Esecuzione / Core" },
|
||||
{ name: "Redesign visivo dell'above-the-fold / hero (il 'prima → dopo')", unit_price: 700, category: "Entry Offer", fase: "Fase 3 → Esecuzione / Core" },
|
||||
{ name: "Call di restituzione", unit_price: 200, category: "Entry Offer", fase: "Fase 5 → Offboarding / Consegna" },
|
||||
{ name: "Call di presentazione Prima/Dopo", unit_price: 200, category: "Entry Offer", fase: "Fase 5 → Offboarding / Consegna" },
|
||||
{ name: "Roadmap / istruzioni operative + mini kit", unit_price: 600, category: "Entry Offer", fase: "Fase 5 → Offboarding / Consegna" },
|
||||
{ name: "UX Research", unit_price: 1200, category: "Signature Offer", fase: "Fase 2 → Analisi / Strategia" },
|
||||
{ name: "Customer Journey", unit_price: 900, category: "Signature Offer", fase: "Fase 2 → Analisi / Strategia" },
|
||||
{ name: "Architettura (sitemap)", unit_price: 500, category: "Signature Offer", fase: "Fase 2 → Analisi / Strategia" },
|
||||
{ name: "Brand Identity - Visual identity", unit_price: 2000, category: "Signature Offer", fase: "Fase 2 → Analisi / Strategia" },
|
||||
{ name: "Brand Identity - Voice identity", unit_price: 800, category: "Signature Offer", fase: "Fase 2 → Analisi / Strategia" },
|
||||
{ name: "Direzione Creative (moodboard)", unit_price: 600, category: "Signature Offer", fase: "Fase 2 → Analisi / Strategia" },
|
||||
{ name: "Workshop (2° fase)", unit_price: 900, category: "Signature Offer", fase: "Fase 2 → Analisi / Strategia" },
|
||||
{ name: "Settaggio CMS (Wordpress Webflow ecc)", unit_price: 500, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" },
|
||||
{ name: "UX - UI", unit_price: 1000, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" },
|
||||
{ name: "Wireframe (Low-Mid Fidelity)", unit_price: 600, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" },
|
||||
{ name: "Homepage (Figma + Dev)", unit_price: 1500, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" },
|
||||
{ name: "Revisione #1", unit_price: 400, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" },
|
||||
{ name: "- Chi siamo", unit_price: 600, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" },
|
||||
{ name: "- Contatti", unit_price: 400, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" },
|
||||
{ name: "- Blog (Archivio)", unit_price: 300, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" },
|
||||
{ name: "- - Blog post (Template Singolo)", unit_price: 600, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" },
|
||||
{ name: "- Case Study (Archivio)", unit_price: 300, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" },
|
||||
{ name: "- - Case Study (Template Singolo)", unit_price: 800, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" },
|
||||
{ name: "- Landing Page (Metodo o Differenziante)", unit_price: 1000, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" },
|
||||
{ name: "- - - Thank You Page", unit_price: 300, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" },
|
||||
{ name: "- - - 404", unit_price: 150, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" },
|
||||
{ name: "Responsive (inclusa nelle pagine?)", unit_price: 800, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" },
|
||||
{ name: "UX writing (Revisione testi)", unit_price: 400, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" },
|
||||
{ name: "Seo Setup (basic on-page)", unit_price: 400, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" },
|
||||
{ name: "Seo Avanzato (+ keyword + ricerca + blog post dentro retainer)", unit_price: 400, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" },
|
||||
{ name: "Revisione #2 finale", unit_price: 400, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" },
|
||||
{ name: "Web Core Vitals (Optimization)", unit_price: 500, category: "Signature Offer", fase: "Fase 4 → Raffinamento / Extra" },
|
||||
{ name: "QA - Test Cross Browser e responsive", unit_price: 400, category: "Signature Offer", fase: "Fase 4 → Raffinamento / Extra" },
|
||||
{ name: "Setting GA4 - Tag Manager - Hotjar/Clarify", unit_price: 500, category: "Signature Offer", fase: "Fase 4 → Raffinamento / Extra" },
|
||||
{ name: "Raccolta Feedback Post Lancio", unit_price: 200, category: "Signature Offer", fase: "Fase 5 → Offboarding / Consegna" },
|
||||
{ name: "Go-live / messa online & accessi", unit_price: 300, category: "Signature Offer", fase: "Fase 5 → Offboarding / Consegna" },
|
||||
{ name: "Audit uscita", unit_price: 500, category: "Signature Offer", fase: "Fase 5 → Offboarding / Consegna" },
|
||||
{ name: "Real User Testing", unit_price: 1200, category: "Signature Offer", fase: "Fase 5 → Offboarding / Consegna" },
|
||||
{ name: "Follow Up Handover", unit_price: 200, category: "Signature Offer", fase: "Fase 5 → Offboarding / Consegna" },
|
||||
{ name: "Video Tutorial per micro modifiche in autonomia", unit_price: 400, category: "Signature Offer", fase: "Fase 5 → Offboarding / Consegna" },
|
||||
{ name: "Revisioni Extra illimitate (sicuri illimitati???)", unit_price: 1200, category: "Signature Offer", fase: "Fase 5 → Offboarding / Consegna" },
|
||||
{ name: "Brand Kit (youtube - linkedin - insta)", unit_price: 600, category: "Signature Offer", fase: "Fase 5 → Offboarding / Consegna" },
|
||||
{ name: "Mantenimento tecnico (sito sempre online, bello, funzionante)", unit_price: 200, category: "Retainer Offer", fase: null },
|
||||
{ name: "Monitoraggio dati (GA4 / Hotjar-Clarity)", unit_price: 100, category: "Retainer Offer", fase: null },
|
||||
{ name: "Report mensile", unit_price: 100, category: "Retainer Offer", fase: null },
|
||||
{ name: "CRO - analisi UX (hotmap, punti di drop)", unit_price: 200, category: "Retainer Offer", fase: null },
|
||||
{ name: "CRO - implementazione miglioramenti", unit_price: 300, category: "Retainer Offer", fase: null },
|
||||
{ name: "Call di Mentorship/Consulenza/allineamento/revisione", unit_price: 800, category: "Retainer Offer", fase: null },
|
||||
{ name: "Art direction su direzione da prendere (consulente strategico interno disponibile 24/7)", unit_price: 2000, category: "Retainer Offer", fase: null },
|
||||
{ name: "Extra landing (prezzo singolo per pompare prezzo)", unit_price: 3000, category: "Retainer Offer", fase: null },
|
||||
{ name: "SEO avanzato (Blog post)", unit_price: 400, category: "Retainer Offer", fase: null },
|
||||
];
|
||||
|
||||
const LEGACY_IDS = [
|
||||
"LLi-DynaQ_Y13vgREFtSB", // Audit Iniziale (test)
|
||||
"MlhY34V7M_ylSPekeuIsz", // Raccolta e Mappatura Materiali (test, wrong category)
|
||||
"lDZkctWhcXqsYVDy2nac6", // Analisi Competitor (test, wrong category)
|
||||
];
|
||||
|
||||
async function run() {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) {
|
||||
console.error("DATABASE_URL environment variable is required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = postgres(databaseUrl);
|
||||
|
||||
try {
|
||||
// Step 1: delete legacy test services (offer_tier_services cascade-deletes)
|
||||
console.log("Deleting 3 legacy test services...");
|
||||
const deleted = await client`
|
||||
DELETE FROM services WHERE id = ANY(${LEGACY_IDS}) RETURNING name
|
||||
`;
|
||||
for (const row of deleted) console.log(` ✗ deleted: ${row.name}`);
|
||||
console.log();
|
||||
|
||||
// Step 2: import 55 Notion services
|
||||
console.log(`Importing ${SERVICES.length} services...`);
|
||||
let inserted = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const svc of SERVICES) {
|
||||
const existing = await client`
|
||||
SELECT id FROM services WHERE name = ${svc.name} LIMIT 1
|
||||
`;
|
||||
if (existing.length > 0) {
|
||||
console.log(` ↷ exists: ${svc.name}`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const id = nanoid();
|
||||
await client`
|
||||
INSERT INTO services (id, name, unit_price, category, fase, active, migrated_from)
|
||||
VALUES (${id}, ${svc.name}, ${svc.unit_price}, ${svc.category}, ${svc.fase}, true, 'notion-csv-2026-06-18')
|
||||
`;
|
||||
console.log(` ✓ ${svc.name}`);
|
||||
inserted++;
|
||||
}
|
||||
|
||||
const [{ count }] = await client`SELECT count(*) FROM services`;
|
||||
console.log(`\n✓ Done — ${inserted} inserted, ${skipped} skipped. Total in DB: ${count}`);
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error("Error:", err instanceof Error ? err.message : err);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -1,262 +0,0 @@
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
offer_phases,
|
||||
offer_phase_services,
|
||||
quotes,
|
||||
quote_items,
|
||||
projects,
|
||||
phases,
|
||||
leads,
|
||||
} from "@/db/schema";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Validation script for Phase 8 migration
|
||||
*
|
||||
* Checks that all new tables/columns exist and FKs are intact
|
||||
* Exit code 0 if all checks pass, 1 if any fail
|
||||
*/
|
||||
|
||||
type ValidationResult = {
|
||||
name: string;
|
||||
passed: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const results: ValidationResult[] = [];
|
||||
|
||||
async function runChecks() {
|
||||
console.log("=== Phase 8 Migration Validation ===\n");
|
||||
|
||||
// Check 1: offer_phases table exists
|
||||
try {
|
||||
const phaseCount = await db
|
||||
.select({ count: sql<number>`COUNT(*)` })
|
||||
.from(offer_phases);
|
||||
results.push({
|
||||
name: "offer_phases table exists",
|
||||
passed: true,
|
||||
error: `(${phaseCount[0].count} rows)`,
|
||||
});
|
||||
} catch (e) {
|
||||
results.push({
|
||||
name: "offer_phases table exists",
|
||||
passed: false,
|
||||
error: (e as Error).message,
|
||||
});
|
||||
}
|
||||
|
||||
// Check 2: offer_phase_services table exists
|
||||
try {
|
||||
const serviceCount = await db
|
||||
.select({ count: sql<number>`COUNT(*)` })
|
||||
.from(offer_phase_services);
|
||||
results.push({
|
||||
name: "offer_phase_services table exists",
|
||||
passed: true,
|
||||
error: `(${serviceCount[0].count} rows)`,
|
||||
});
|
||||
} catch (e) {
|
||||
results.push({
|
||||
name: "offer_phase_services table exists",
|
||||
passed: false,
|
||||
error: (e as Error).message,
|
||||
});
|
||||
}
|
||||
|
||||
// Check 3: quotes table exists
|
||||
try {
|
||||
const quoteCount = await db
|
||||
.select({ count: sql<number>`COUNT(*)` })
|
||||
.from(quotes);
|
||||
results.push({
|
||||
name: "quotes table exists",
|
||||
passed: true,
|
||||
error: `(${quoteCount[0].count} rows)`,
|
||||
});
|
||||
} catch (e) {
|
||||
results.push({
|
||||
name: "quotes table exists",
|
||||
passed: false,
|
||||
error: (e as Error).message,
|
||||
});
|
||||
}
|
||||
|
||||
// Check 4: leads table exists
|
||||
try {
|
||||
const leadCount = await db
|
||||
.select({ count: sql<number>`COUNT(*)` })
|
||||
.from(leads);
|
||||
results.push({
|
||||
name: "leads table exists",
|
||||
passed: true,
|
||||
error: `(${leadCount[0].count} rows)`,
|
||||
});
|
||||
} catch (e) {
|
||||
results.push({
|
||||
name: "leads table exists",
|
||||
passed: false,
|
||||
error: (e as Error).message,
|
||||
});
|
||||
}
|
||||
|
||||
// Check 5: quote_items.quote_id column exists
|
||||
try {
|
||||
const col = await db
|
||||
.select({ column_name: sql<string>`column_name` })
|
||||
.from(
|
||||
sql`information_schema.columns`
|
||||
)
|
||||
.where(
|
||||
sql`table_name = 'quote_items' AND column_name = 'quote_id'`
|
||||
);
|
||||
results.push({
|
||||
name: "quote_items.quote_id column exists",
|
||||
passed: col.length > 0,
|
||||
error: col.length > 0 ? undefined : "Column not found",
|
||||
});
|
||||
} catch (e) {
|
||||
results.push({
|
||||
name: "quote_items.quote_id column exists",
|
||||
passed: false,
|
||||
error: (e as Error).message,
|
||||
});
|
||||
}
|
||||
|
||||
// Check 6: quote_items.offer_micro_id column exists
|
||||
try {
|
||||
const col = await db
|
||||
.select({ column_name: sql<string>`column_name` })
|
||||
.from(
|
||||
sql`information_schema.columns`
|
||||
)
|
||||
.where(
|
||||
sql`table_name = 'quote_items' AND column_name = 'offer_micro_id'`
|
||||
);
|
||||
results.push({
|
||||
name: "quote_items.offer_micro_id column exists",
|
||||
passed: col.length > 0,
|
||||
error: col.length > 0 ? undefined : "Column not found",
|
||||
});
|
||||
} catch (e) {
|
||||
results.push({
|
||||
name: "quote_items.offer_micro_id column exists",
|
||||
passed: false,
|
||||
error: (e as Error).message,
|
||||
});
|
||||
}
|
||||
|
||||
// Check 7: quote_items.offer_phase_id column exists
|
||||
try {
|
||||
const col = await db
|
||||
.select({ column_name: sql<string>`column_name` })
|
||||
.from(
|
||||
sql`information_schema.columns`
|
||||
)
|
||||
.where(
|
||||
sql`table_name = 'quote_items' AND column_name = 'offer_phase_id'`
|
||||
);
|
||||
results.push({
|
||||
name: "quote_items.offer_phase_id column exists",
|
||||
passed: col.length > 0,
|
||||
error: col.length > 0 ? undefined : "Column not found",
|
||||
});
|
||||
} catch (e) {
|
||||
results.push({
|
||||
name: "quote_items.offer_phase_id column exists",
|
||||
passed: false,
|
||||
error: (e as Error).message,
|
||||
});
|
||||
}
|
||||
|
||||
// Check 8: projects.offer_id column exists
|
||||
try {
|
||||
const col = await db
|
||||
.select({ column_name: sql<string>`column_name` })
|
||||
.from(
|
||||
sql`information_schema.columns`
|
||||
)
|
||||
.where(
|
||||
sql`table_name = 'projects' AND column_name = 'offer_id'`
|
||||
);
|
||||
results.push({
|
||||
name: "projects.offer_id column exists",
|
||||
passed: col.length > 0,
|
||||
error: col.length > 0 ? undefined : "Column not found",
|
||||
});
|
||||
} catch (e) {
|
||||
results.push({
|
||||
name: "projects.offer_id column exists",
|
||||
passed: false,
|
||||
error: (e as Error).message,
|
||||
});
|
||||
}
|
||||
|
||||
// Check 9: projects.created_from_lead_id column exists
|
||||
try {
|
||||
const col = await db
|
||||
.select({ column_name: sql<string>`column_name` })
|
||||
.from(
|
||||
sql`information_schema.columns`
|
||||
)
|
||||
.where(
|
||||
sql`table_name = 'projects' AND column_name = 'created_from_lead_id'`
|
||||
);
|
||||
results.push({
|
||||
name: "projects.created_from_lead_id column exists",
|
||||
passed: col.length > 0,
|
||||
error: col.length > 0 ? undefined : "Column not found",
|
||||
});
|
||||
} catch (e) {
|
||||
results.push({
|
||||
name: "projects.created_from_lead_id column exists",
|
||||
passed: false,
|
||||
error: (e as Error).message,
|
||||
});
|
||||
}
|
||||
|
||||
// Check 10: phases.offer_phase_id column exists
|
||||
try {
|
||||
const col = await db
|
||||
.select({ column_name: sql<string>`column_name` })
|
||||
.from(
|
||||
sql`information_schema.columns`
|
||||
)
|
||||
.where(
|
||||
sql`table_name = 'phases' AND column_name = 'offer_phase_id'`
|
||||
);
|
||||
results.push({
|
||||
name: "phases.offer_phase_id column exists",
|
||||
passed: col.length > 0,
|
||||
error: col.length > 0 ? undefined : "Column not found",
|
||||
});
|
||||
} catch (e) {
|
||||
results.push({
|
||||
name: "phases.offer_phase_id column exists",
|
||||
passed: false,
|
||||
error: (e as Error).message,
|
||||
});
|
||||
}
|
||||
|
||||
// Print results
|
||||
let allPassed = true;
|
||||
console.log("Checks:\n");
|
||||
for (const result of results) {
|
||||
const status = result.passed ? "✓" : "✗";
|
||||
const msg = result.error
|
||||
? ` ${result.error}`
|
||||
: " passed";
|
||||
console.log(`${status} ${result.name}${msg}`);
|
||||
if (!result.passed) allPassed = false;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\n=== Result: ${allPassed ? "ALL CHECKS PASSED" : "SOME CHECKS FAILED"} ===`
|
||||
);
|
||||
process.exit(allPassed ? 0 : 1);
|
||||
}
|
||||
|
||||
runChecks().catch((e) => {
|
||||
console.error("Validation script error:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
import { db } from "@/db";
|
||||
import { service_catalog, offer_services, services, quote_items, offer_micro_services } from "@/db/schema";
|
||||
import { sql, eq } from "drizzle-orm";
|
||||
|
||||
async function validate() {
|
||||
let failures = 0;
|
||||
|
||||
// Check 1: row counts match
|
||||
const [catalogCount] = await db.select({ n: sql<number>`count(*)::int` }).from(service_catalog);
|
||||
const [offerCount] = await db.select({ n: sql<number>`count(*)::int` }).from(offer_services);
|
||||
const [migratedCatalog] = await db
|
||||
.select({ n: sql<number>`count(*)::int` })
|
||||
.from(services)
|
||||
.where(eq(services.migrated_from, "service_catalog"));
|
||||
const [migratedOffer] = await db
|
||||
.select({ n: sql<number>`count(*)::int` })
|
||||
.from(services)
|
||||
.where(eq(services.migrated_from, "offer_services"));
|
||||
|
||||
console.log(`service_catalog rows: ${catalogCount.n} | migrated: ${migratedCatalog.n}`);
|
||||
console.log(`offer_services rows: ${offerCount.n} | migrated: ${migratedOffer.n}`);
|
||||
|
||||
if (catalogCount.n !== migratedCatalog.n) {
|
||||
console.log("FAIL: service_catalog row count does not match migrated count");
|
||||
failures++;
|
||||
} else {
|
||||
console.log("PASS: service_catalog fully migrated");
|
||||
}
|
||||
|
||||
if (offerCount.n !== migratedOffer.n) {
|
||||
console.log("FAIL: offer_services row count does not match migrated count");
|
||||
failures++;
|
||||
} else {
|
||||
console.log("PASS: offer_services fully migrated");
|
||||
}
|
||||
|
||||
// Check 2: no orphaned migrated_id (every migrated_id from service_catalog still exists in service_catalog)
|
||||
const orphanedCatalog = await db.execute(sql`
|
||||
SELECT COUNT(*)::int AS n FROM services s
|
||||
WHERE s.migrated_from = 'service_catalog'
|
||||
AND NOT EXISTS (SELECT 1 FROM service_catalog sc WHERE sc.id = s.migrated_id)
|
||||
`);
|
||||
const orphanedCatalogCount = (orphanedCatalog as unknown as Array<{ n: number }>)[0]?.n ?? 0;
|
||||
if (orphanedCatalogCount > 0) {
|
||||
console.log(`FAIL: ${orphanedCatalogCount} services rows reference missing service_catalog ids`);
|
||||
failures++;
|
||||
} else {
|
||||
console.log("PASS: no orphaned service_catalog references");
|
||||
}
|
||||
|
||||
const orphanedOffer = await db.execute(sql`
|
||||
SELECT COUNT(*)::int AS n FROM services s
|
||||
WHERE s.migrated_from = 'offer_services'
|
||||
AND NOT EXISTS (SELECT 1 FROM offer_services os WHERE os.id = s.migrated_id)
|
||||
`);
|
||||
const orphanedOfferCount = (orphanedOffer as unknown as Array<{ n: number }>)[0]?.n ?? 0;
|
||||
if (orphanedOfferCount > 0) {
|
||||
console.log(`FAIL: ${orphanedOfferCount} services rows reference missing offer_services ids`);
|
||||
failures++;
|
||||
} else {
|
||||
console.log("PASS: no orphaned offer_services references");
|
||||
}
|
||||
|
||||
// Check 3: existing quote_items.service_id still resolve in service_catalog (untouched FK — must remain valid)
|
||||
const orphanedQuoteItems = await db.execute(sql`
|
||||
SELECT COUNT(*)::int AS n FROM quote_items qi
|
||||
WHERE qi.service_id IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM service_catalog sc WHERE sc.id = qi.service_id)
|
||||
`);
|
||||
const orphanedQuoteItemsCount = (orphanedQuoteItems as unknown as Array<{ n: number }>)[0]?.n ?? 0;
|
||||
if (orphanedQuoteItemsCount > 0) {
|
||||
console.log(`FAIL: ${orphanedQuoteItemsCount} quote_items reference missing service_catalog ids (pre-existing FK broken!)`);
|
||||
failures++;
|
||||
} else {
|
||||
console.log("PASS: quote_items.service_id -> service_catalog FK intact (unchanged by this migration)");
|
||||
}
|
||||
|
||||
// Check 4: existing offer_micro_services.service_id still resolve in offer_services (untouched FK — must remain valid)
|
||||
const orphanedMicroServices = await db.execute(sql`
|
||||
SELECT COUNT(*)::int AS n FROM offer_micro_services oms
|
||||
WHERE NOT EXISTS (SELECT 1 FROM offer_services os WHERE os.id = oms.service_id)
|
||||
`);
|
||||
const orphanedMicroServicesCount = (orphanedMicroServices as unknown as Array<{ n: number }>)[0]?.n ?? 0;
|
||||
if (orphanedMicroServicesCount > 0) {
|
||||
console.log(`FAIL: ${orphanedMicroServicesCount} offer_micro_services reference missing offer_services ids (pre-existing FK broken!)`);
|
||||
failures++;
|
||||
} else {
|
||||
console.log("PASS: offer_micro_services.service_id -> offer_services FK intact (unchanged by this migration)");
|
||||
}
|
||||
|
||||
// Check 5: name collision report (informational)
|
||||
const collisions = await db.execute(sql`
|
||||
SELECT name, COUNT(*)::int AS n FROM services GROUP BY name HAVING COUNT(*) > 1
|
||||
`);
|
||||
const collisionRows = collisions as unknown as Array<{ name: string; n: number }>;
|
||||
if (collisionRows.length > 0) {
|
||||
console.log(`WARNING: ${collisionRows.length} duplicate names remain in services (review):`, collisionRows);
|
||||
} else {
|
||||
console.log("PASS: no duplicate names in services");
|
||||
}
|
||||
|
||||
// Check 6: informational — count of net-new (non-migrated) services
|
||||
const [netNew] = await db
|
||||
.select({ n: sql<number>`count(*)::int` })
|
||||
.from(services)
|
||||
.where(sql`migrated_from IS NULL`);
|
||||
console.log(`INFO: ${netNew.n} net-new services created post-migration (migrated_from IS NULL)`);
|
||||
|
||||
console.log(`\n${failures === 0 ? "ALL CHECKS PASSED" : `${failures} CHECK(S) FAILED`}`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
validate().catch((err) => {
|
||||
console.error("Validation failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import { db } from "@/db";
|
||||
import { services, tags } from "@/db/schema";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
|
||||
async function validate() {
|
||||
let failures = 0;
|
||||
|
||||
const [offerServicesCount] = await db
|
||||
.select({ n: sql<number>`count(*)::int` })
|
||||
.from(services)
|
||||
.where(eq(services.migrated_from, "offer_services"));
|
||||
|
||||
const [offerTagCount] = await db
|
||||
.select({ n: sql<number>`count(*)::int` })
|
||||
.from(tags)
|
||||
.where(and(eq(tags.entity_type, "services"), eq(tags.name, "Offerta")));
|
||||
|
||||
console.log(`offer_services-derived services: ${offerServicesCount.n} | Offerta tags: ${offerTagCount.n}`);
|
||||
|
||||
if (offerServicesCount.n !== offerTagCount.n) {
|
||||
console.log("FAIL: Offerta tag count does not match offer_services-derived services count");
|
||||
failures++;
|
||||
} else {
|
||||
console.log("PASS: all offer_services-derived services have the Offerta tag");
|
||||
}
|
||||
|
||||
const orphanedTags = await db.execute(sql`
|
||||
SELECT COUNT(*)::int AS n FROM tags t
|
||||
WHERE t.entity_type = 'services'
|
||||
AND NOT EXISTS (SELECT 1 FROM services s WHERE s.id = t.entity_id)
|
||||
`);
|
||||
const orphanedTagsCount = (orphanedTags as unknown as Array<{ n: number }>)[0]?.n ?? 0;
|
||||
if (orphanedTagsCount > 0) {
|
||||
console.log(`FAIL: ${orphanedTagsCount} tags reference non-existent services`);
|
||||
failures++;
|
||||
} else {
|
||||
console.log("PASS: no orphaned tag references");
|
||||
}
|
||||
|
||||
const tagCounts = await db.execute(sql`
|
||||
SELECT entity_type, COUNT(*)::int AS n FROM tags GROUP BY entity_type
|
||||
`);
|
||||
const counts = tagCounts as unknown as Array<{ entity_type: string; n: number }>;
|
||||
for (const row of counts) {
|
||||
console.log(`INFO: ${row.n} tags for entity_type=${row.entity_type}`);
|
||||
}
|
||||
|
||||
console.log(`\n${failures === 0 ? "ALL CHECKS PASSED" : `${failures} CHECK(S) FAILED`}`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
validate().catch((err) => {
|
||||
console.error("Validation failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,242 +0,0 @@
|
||||
// Phase 12 Plan 03 — Task 2 verification script for actions.ts additions.
|
||||
//
|
||||
// This project has no test runner configured (no `scripts.test` in package.json),
|
||||
// and `.env.local`'s DATABASE_URL points at PRODUCTION (per project memory) — so
|
||||
// this script does NOT execute any DB calls or server actions (server actions
|
||||
// also require an Auth.js session, unavailable outside a request context). It
|
||||
// typechecks against the real exports/types from
|
||||
// src/app/admin/offers/actions.ts and documents the 9 expected behaviors from
|
||||
// the plan's <behavior> block, for manual run against a dev DB later if desired.
|
||||
//
|
||||
// Run (manual, dev DB only, inside a request context with admin session):
|
||||
// npx tsx scripts/verify-12-03-actions.ts
|
||||
|
||||
import type { SaveOfferEditorPayload } from "@/app/admin/offers/actions";
|
||||
import {
|
||||
saveOfferEditor,
|
||||
toggleOfferArchived,
|
||||
addOfferTag,
|
||||
removeOfferTag,
|
||||
renameOfferOption,
|
||||
createOfferMacro,
|
||||
} from "@/app/admin/offers/actions";
|
||||
|
||||
// ── Test 1: tier_letter Zod validation ───────────────────────────────────────
|
||||
// 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.
|
||||
async function test1_invalidTierLetter(macroId: string) {
|
||||
const badPayload = {
|
||||
internal_name: "Test",
|
||||
public_name: "Test",
|
||||
tiers: [
|
||||
{
|
||||
tier_letter: "D", // invalid — not in ["A","B","C"]
|
||||
internal_name: "Tier X",
|
||||
public_name: "Tier X",
|
||||
duration_months: 3,
|
||||
assignedServiceIds: [],
|
||||
},
|
||||
],
|
||||
tipoTags: [],
|
||||
obiettivoTags: [],
|
||||
} as unknown as SaveOfferEditorPayload;
|
||||
// Expected: saveOfferEditor(macroId, badPayload) throws (Zod enum mismatch
|
||||
// surfaces as parsed.error.issues[0].message before any DB write).
|
||||
return () => saveOfferEditor(macroId, badPayload);
|
||||
}
|
||||
|
||||
// ── Test 2: macro scalar field update ────────────────────────────────────────
|
||||
// saveOfferEditor updates offer_macros scalars (internal_name, description,
|
||||
// category, ticket, cliente_ideale, risultato, tempo, pain, metodo) in one
|
||||
// db.update(offer_macros)...where(eq(id, macroId)).
|
||||
async function test2_macroScalars(macroId: string) {
|
||||
const payload: SaveOfferEditorPayload = {
|
||||
internal_name: "Offerta Test",
|
||||
public_name: "Offerta Pubblica",
|
||||
description: "Una descrizione",
|
||||
category: "Signature Offer",
|
||||
ticket: "Mid Ticket",
|
||||
cliente_ideale: "Coach",
|
||||
risultato: "Lead qualificati",
|
||||
tempo: "90 giorni",
|
||||
pain: "Mancanza di processo",
|
||||
metodo: "Sistema X",
|
||||
tiers: [],
|
||||
tipoTags: [],
|
||||
obiettivoTags: [],
|
||||
};
|
||||
// Expected: after call, offer_macros row for macroId has all 9 scalar
|
||||
// fields set to the payload values.
|
||||
return () => saveOfferEditor(macroId, payload);
|
||||
}
|
||||
|
||||
// ── Test 3: tier upsert (update existing id, insert when no id) ──────────────
|
||||
// If tier.id is provided -> db.update(offer_micros).set({...}); if no id ->
|
||||
// db.insert(offer_micros).values({..., macro_id: macroId}).
|
||||
async function test3_tierUpsert(macroId: string, existingTierId: string) {
|
||||
const payload: SaveOfferEditorPayload = {
|
||||
internal_name: "Offerta Test",
|
||||
public_name: "Offerta Pubblica",
|
||||
tiers: [
|
||||
{
|
||||
id: existingTierId, // -> update path
|
||||
tier_letter: "A",
|
||||
internal_name: "Tier A",
|
||||
public_name: "Tier A Pubblico",
|
||||
duration_months: 3,
|
||||
public_price: 1500,
|
||||
assignedServiceIds: [],
|
||||
},
|
||||
{
|
||||
// no id -> insert path (supports macros with < 3 tiers)
|
||||
tier_letter: "B",
|
||||
internal_name: "Tier B",
|
||||
public_name: "Tier B Pubblico",
|
||||
duration_months: 6,
|
||||
public_price: 2500,
|
||||
assignedServiceIds: [],
|
||||
},
|
||||
],
|
||||
tipoTags: [],
|
||||
obiettivoTags: [],
|
||||
};
|
||||
// Expected: existingTierId row updated in place; a new offer_micros row
|
||||
// created for tier B with macro_id = macroId.
|
||||
return () => saveOfferEditor(macroId, payload);
|
||||
}
|
||||
|
||||
// ── Test 4: offer_tier_services delete-then-reinsert ─────────────────────────
|
||||
// A tier with assignedServiceIds: ["svc1","svc2"] -> after call,
|
||||
// offer_tier_services has exactly 2 rows for that tier_id, both svc1/svc2.
|
||||
async function test4_tierServicesReplace(macroId: string, tierId: string) {
|
||||
const payload: SaveOfferEditorPayload = {
|
||||
internal_name: "Offerta Test",
|
||||
public_name: "Offerta Pubblica",
|
||||
tiers: [
|
||||
{
|
||||
id: tierId,
|
||||
tier_letter: "A",
|
||||
internal_name: "Tier A",
|
||||
public_name: "Tier A Pubblico",
|
||||
duration_months: 3,
|
||||
assignedServiceIds: ["svc1", "svc2"],
|
||||
},
|
||||
],
|
||||
tipoTags: [],
|
||||
obiettivoTags: [],
|
||||
};
|
||||
// Expected: offer_tier_services has exactly 2 rows where tier_id === tierId,
|
||||
// service_id IN ("svc1", "svc2").
|
||||
return () => saveOfferEditor(macroId, payload);
|
||||
}
|
||||
|
||||
// ── Test 5: Tipo/Obiettivo tags delete-then-reinsert ─────────────────────────
|
||||
// saveOfferEditor replaces tags where entity_type IN
|
||||
// ("offer_macros.tipo","offer_macros.obiettivo") and entity_id = macroId.
|
||||
async function test5_tagsReplace(macroId: string) {
|
||||
const payload: SaveOfferEditorPayload = {
|
||||
internal_name: "Offerta Test",
|
||||
public_name: "Offerta Pubblica",
|
||||
tiers: [],
|
||||
tipoTags: ["Audit", "Coaching"],
|
||||
obiettivoTags: ["Lead Generation"],
|
||||
};
|
||||
// Expected: tags table has 2 rows entity_type="offer_macros.tipo" (Audit,
|
||||
// Coaching) and 1 row entity_type="offer_macros.obiettivo" (Lead
|
||||
// Generation), all entity_id = macroId; any prior rows for this macroId +
|
||||
// these two entity_types are removed first.
|
||||
return () => saveOfferEditor(macroId, payload);
|
||||
}
|
||||
|
||||
// ── Test 6: toggleOfferArchived ──────────────────────────────────────────────
|
||||
// toggleOfferArchived(macroId, archived: boolean) sets offer_macros.is_archived = archived.
|
||||
async function test6_toggleArchived(macroId: string) {
|
||||
// Expected: offer_macros.is_archived === true after toggleOfferArchived(macroId, true);
|
||||
// === false after toggleOfferArchived(macroId, false).
|
||||
return [
|
||||
() => toggleOfferArchived(macroId, true),
|
||||
() => toggleOfferArchived(macroId, false),
|
||||
];
|
||||
}
|
||||
|
||||
// ── Test 7: addOfferTag / removeOfferTag ─────────────────────────────────────
|
||||
// addOfferTag(dimension, macroId, value) / removeOfferTag(dimension, macroId,
|
||||
// value) work for dimension in ("tipo","obiettivo"). Reject any other
|
||||
// dimension value.
|
||||
async function test7_tagCrud(macroId: string) {
|
||||
// Expected: addOfferTag("tipo", macroId, "Audit") inserts a tags row
|
||||
// (entity_type="offer_macros.tipo", entity_id=macroId, name="Audit");
|
||||
// removeOfferTag("tipo", macroId, "Audit") deletes it.
|
||||
// addOfferTag("invalid" as any, macroId, "x") throws.
|
||||
return {
|
||||
addTipo: () => addOfferTag("tipo", macroId, "Audit"),
|
||||
removeTipo: () => removeOfferTag("tipo", macroId, "Audit"),
|
||||
addObiettivo: () => addOfferTag("obiettivo", macroId, "Lead Generation"),
|
||||
removeObiettivo: () => removeOfferTag("obiettivo", macroId, "Lead Generation"),
|
||||
// @ts-expect-error -- intentional invalid dimension to verify the throw path
|
||||
invalid: () => addOfferTag("invalid", macroId, "x"),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Test 8: renameOfferOption ────────────────────────────────────────────────
|
||||
// renameOfferOption(field, oldValue, newValue) for field in ("categoria",
|
||||
// "ticket") updates offer_macros.category/ticket for all matching rows; for
|
||||
// field in ("tipo","obiettivo") updates tags.name.
|
||||
async function test8_renameOption() {
|
||||
// Expected:
|
||||
// renameOfferOption("categoria", "Entry Offer", "Offerta Base") updates
|
||||
// all offer_macros rows where category = "Entry Offer" -> "Offerta Base"
|
||||
// renameOfferOption("ticket", "Low Ticket", "Ticket Basso") updates
|
||||
// all offer_macros rows where ticket = "Low Ticket" -> "Ticket Basso"
|
||||
// renameOfferOption("tipo", "Audit", "Diagnosi") updates all tags rows
|
||||
// where entity_type="offer_macros.tipo" and name="Audit" -> "Diagnosi"
|
||||
// renameOfferOption("obiettivo", "Lead Generation", "Acquisizione Lead")
|
||||
// updates tags rows where entity_type="offer_macros.obiettivo"
|
||||
return {
|
||||
renameCategoria: () => renameOfferOption("categoria", "Entry Offer", "Offerta Base"),
|
||||
renameTicket: () => renameOfferOption("ticket", "Low Ticket", "Ticket Basso"),
|
||||
renameTipo: () => renameOfferOption("tipo", "Audit", "Diagnosi"),
|
||||
renameObiettivo: () => renameOfferOption("obiettivo", "Lead Generation", "Acquisizione Lead"),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Test 9: createOfferMacro ─────────────────────────────────────────────────
|
||||
// createOfferMacro(formData) creates a new offer_macros row from internal_name
|
||||
// (required) + optional public_name/description/category. If public_name is
|
||||
// omitted, defaults to internal_name.
|
||||
async function test9_createOfferMacro() {
|
||||
const fd1 = new FormData();
|
||||
fd1.append("internal_name", "Nuova Offerta Interna");
|
||||
fd1.append("description", "Descrizione breve");
|
||||
fd1.append("category", "Entry Offer");
|
||||
// Expected: new offer_macros row with internal_name="Nuova Offerta Interna",
|
||||
// public_name="Nuova Offerta Interna" (defaulted), description set,
|
||||
// category="Entry Offer".
|
||||
|
||||
const fd2 = new FormData();
|
||||
fd2.append("internal_name", "Altra Offerta");
|
||||
fd2.append("public_name", "Nome Pubblico Esplicito");
|
||||
// Expected: new offer_macros row with public_name="Nome Pubblico Esplicito"
|
||||
// (not defaulted, since explicitly provided).
|
||||
|
||||
return {
|
||||
withDefaultPublicName: () => createOfferMacro(fd1),
|
||||
withExplicitPublicName: () => createOfferMacro(fd2),
|
||||
};
|
||||
}
|
||||
|
||||
// Not executed automatically (production DB + no request context for
|
||||
// requireAdmin) — typecheck-only verification.
|
||||
|
||||
export {
|
||||
test1_invalidTierLetter,
|
||||
test2_macroScalars,
|
||||
test3_tierUpsert,
|
||||
test4_tierServicesReplace,
|
||||
test5_tagsReplace,
|
||||
test6_toggleArchived,
|
||||
test7_tagCrud,
|
||||
test8_renameOption,
|
||||
test9_createOfferMacro,
|
||||
};
|
||||
@@ -1,112 +0,0 @@
|
||||
// Phase 12 Plan 03 — Task 1 verification script for offer-queries.ts additions.
|
||||
//
|
||||
// This project has no test runner configured (no `scripts.test` in package.json),
|
||||
// and `.env.local`'s DATABASE_URL points at PRODUCTION (per project memory) — so
|
||||
// this script does NOT execute any DB calls. It typechecks against the real
|
||||
// exports/types from src/lib/offer-queries.ts and documents the 5 expected
|
||||
// behaviors from the plan's <behavior> block, for manual run against a dev DB
|
||||
// later if desired.
|
||||
//
|
||||
// Run (manual, dev DB only): npx tsx scripts/verify-12-03-queries.ts
|
||||
|
||||
import {
|
||||
getOfferEditorData,
|
||||
getOfferListCards,
|
||||
getOfferFieldOptions,
|
||||
type OfferEditorData,
|
||||
type OfferListCard,
|
||||
type OfferFieldOptions,
|
||||
type OfferTierData,
|
||||
} from "@/lib/offer-queries";
|
||||
|
||||
// ── Test 1: getOfferListCards() ──────────────────────────────────────────────
|
||||
// Input: 2 offer_macros rows (1 with is_archived=true, 1 with is_archived=false).
|
||||
// Expected: array of length 2, both present, ordered by sort_order, each row
|
||||
// shaped { id, internal_name, description, category, is_archived }. Archived
|
||||
// rows ARE returned — filtering is client-side (UI-SPEC "Mostra offerte archiviate").
|
||||
async function test1_getOfferListCards() {
|
||||
const cards: OfferListCard[] = await getOfferListCards();
|
||||
// Expected assertions (manual):
|
||||
// cards.length === 2
|
||||
// cards.some(c => c.is_archived === true)
|
||||
// cards.some(c => c.is_archived === false)
|
||||
return cards;
|
||||
}
|
||||
|
||||
// ── Test 2: getOfferEditorData(macroId) — macro with 0 tiers ─────────────────
|
||||
// Input: macro with category = "Signature Offer", 0 offer_micros rows, 3
|
||||
// services with category="Signature Offer" + 2 with a different category.
|
||||
// Expected: { macro: {...}, tiers: [], availableServices: [...3 matching...],
|
||||
// tipoTags: [], obiettivoTags: [] }
|
||||
async function test2_emptyTiers(macroId: string) {
|
||||
const data: OfferEditorData | null = await getOfferEditorData(macroId);
|
||||
// Expected assertions (manual):
|
||||
// data !== null
|
||||
// data.tiers.length === 0
|
||||
// data.availableServices.length === 3 (only services.category === macro.category)
|
||||
// data.tipoTags.length === 0 && data.obiettivoTags.length === 0
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── Test 3: getOfferEditorData(macroId) — 3 tiers, tier A has 2 services ─────
|
||||
// Input: macro with 3 offer_micros rows (tier_letter A/B/C) and 2
|
||||
// offer_tier_services rows assigned to tier A.
|
||||
// Expected: tiers sorted A->B->C; each tier has
|
||||
// { id, tier_letter, public_price, assignedServiceIds, servicesTotal };
|
||||
// tier A's servicesTotal === sum(unit_price) of its 2 assigned services;
|
||||
// tiers B/C have assignedServiceIds === [] and servicesTotal === "0".
|
||||
async function test3_tiersWithServices(macroId: string) {
|
||||
const data: OfferEditorData | null = await getOfferEditorData(macroId);
|
||||
// Expected assertions (manual):
|
||||
// data.tiers.map(t => t.tier_letter) === ["A", "B", "C"]
|
||||
// data.tiers[0].assignedServiceIds.length === 2
|
||||
// Number(data.tiers[0].servicesTotal) === sum of the 2 services' unit_price
|
||||
// data.tiers[1].assignedServiceIds === [] && data.tiers[1].servicesTotal === "0"
|
||||
// data.tiers[2].assignedServiceIds === [] && data.tiers[2].servicesTotal === "0"
|
||||
const tierA: OfferTierData | undefined = data?.tiers[0];
|
||||
return { data, tierA };
|
||||
}
|
||||
|
||||
// ── Test 4: getOfferEditorData(macroId) — tipoTags/obiettivoTags ─────────────
|
||||
// Input: 2 "tipo" tags + 1 "obiettivo" tag exist for the macro (entity_type =
|
||||
// "offer_macros.tipo" / "offer_macros.obiettivo", entity_id = macroId).
|
||||
// Expected: tipoTags.length === 2, obiettivoTags.length === 1.
|
||||
async function test4_tags(macroId: string) {
|
||||
const data: OfferEditorData | null = await getOfferEditorData(macroId);
|
||||
// Expected assertions (manual):
|
||||
// data?.tipoTags.length === 2
|
||||
// data?.obiettivoTags.length === 1
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── Test 5: getOfferFieldOptions() ───────────────────────────────────────────
|
||||
// Input: 2 macros with category "Entry Offer"/"Signature Offer", 1 "tipo" tag
|
||||
// "Audit", 1 "obiettivo" tag "Lead Generation".
|
||||
// Expected: categoria contains both values; tipo === ["Audit"];
|
||||
// obiettivo === ["Lead Generation"].
|
||||
async function test5_fieldOptions() {
|
||||
const options: OfferFieldOptions = await getOfferFieldOptions();
|
||||
// Expected assertions (manual):
|
||||
// options.categoria includes "Entry Offer" and "Signature Offer"
|
||||
// options.tipo includes "Audit"
|
||||
// options.obiettivo includes "Lead Generation"
|
||||
return options;
|
||||
}
|
||||
|
||||
// Not executed automatically (production DB) — typecheck-only verification.
|
||||
// To run manually against a dev DB: uncomment the call below.
|
||||
// void (async () => {
|
||||
// await test1_getOfferListCards();
|
||||
// await test2_emptyTiers("macro-id");
|
||||
// await test3_tiersWithServices("macro-id");
|
||||
// await test4_tags("macro-id");
|
||||
// await test5_fieldOptions();
|
||||
// })();
|
||||
|
||||
export {
|
||||
test1_getOfferListCards,
|
||||
test2_emptyTiers,
|
||||
test3_tiersWithServices,
|
||||
test4_tags,
|
||||
test5_fieldOptions,
|
||||
};
|
||||
Reference in New Issue
Block a user