feat(12-01): add migration 0008 for offer tier schema + idempotent push script
- Hand-write 0008_offer_tier_schema.sql: additive ALTER TABLE ADD COLUMN IF NOT EXISTS for offer_macros/offer_micros, guarded DO-block CHECK constraint on tier_letter, CREATE TABLE IF NOT EXISTS offer_tier_services + index - Append journal entry for 0008_offer_tier_schema following the 0006 entry shape - Add scripts/push-12-offer-tier-schema.ts: idempotent push script with PRODUCTION DEPLOY NOTE — BLOCKING step for Plan 02 (SSH+docker exec) before Wave 3 query layer
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
// 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();
|
||||
Reference in New Issue
Block a user