Pricing: migrate from app_config JSON to relational tables
Replaces the two `pricing_*_tiers_json` blobs and five `first_session_discount_*` keys in app_config with dedicated `pricing_tiers` and `pricing_promotions` tables plus matching `_history` audit tables. UUID PKs, UNIQUE(mode, minutes) natural-key constraint, optimistic-lock via `updated_at` token returning 409 STALE_WRITE on conflicts. Every mutation writes a history row capturing the operator (changed_by from request.auth.userId) and change_kind. CC SettingsPage replaces the JSON-textarea editors with per-row tables — add / edit / soft-delete / reactivate / reorder, plus a buffered first-session discount form with the same optimistic-lock contract. `minutes` and `mode` are read-only on edit since they form the natural key; operators soft-delete and recreate to change duration. Stage 5 fixes a latent leak: `client.payment.routes.js` had its own local `readDiscountConfig` that still read from app_config — would have silently fallen to hardcoded defaults once the legacy rows were deleted. Now reads from pricing_promotions via the shared service helper, so CC edits to the first- session discount affect actual payment pricing on the next request. Customer-facing GET /api/client/chat/pricing shape unchanged (id values are now UUIDs instead of "5"/"12"/"60" but lookups happen by (mode, minutes), so no app changes needed). 27 new backend tests, all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -679,6 +679,242 @@ const migrate = async () => {
|
||||
ADD COLUMN IF NOT EXISTS account_belongs_to UUID REFERENCES customers(id) ON DELETE SET NULL
|
||||
`
|
||||
|
||||
// --- Pricing relational migration — Stage 1 (schema + backfill only) ---
|
||||
//
|
||||
// Moves the pricing_chat_tiers_json / pricing_call_tiers_json /
|
||||
// first_session_discount_* rows from app_config into dedicated relational
|
||||
// tables. Stage 1 is schema-only: the live read paths in
|
||||
// pricing.service.js continue to read app_config until Stage 3 cuts them
|
||||
// over. The seven legacy app_config rows are NOT deleted here — that's
|
||||
// Stage 5.
|
||||
//
|
||||
// Design notes:
|
||||
// - PK is UUID (gen_random_uuid()) instead of the doc's TEXT prefix
|
||||
// scheme ("chat-60"). Backend lookups go through (mode, minutes),
|
||||
// not the id, so the id is purely internal — there is no benefit to
|
||||
// a human-readable surrogate key, and UUIDs match the convention
|
||||
// used by every other table in this schema.
|
||||
// - UNIQUE (mode, minutes) on pricing_tiers and UNIQUE (eligibility)
|
||||
// on pricing_promotions enforce the natural keys at the DB level.
|
||||
// - History tables reference the live row via tier_id / promotion_id
|
||||
// (UUID) — column rename from the doc's `id` to avoid shadowing the
|
||||
// history row's own pk.
|
||||
// - original_price_idr is shipped as schema-only — not exposed in
|
||||
// GET /api/client/pricing in this Stage. Surfacing it to clients is
|
||||
// a separate out-of-scope follow-up.
|
||||
// - sort_order: operator-controlled ordering distinct from minutes.
|
||||
// Backfill seeds it as the array index from the existing JSON so any
|
||||
// curated order is preserved.
|
||||
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS pricing_tiers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
mode TEXT NOT NULL CHECK (mode IN ('chat', 'call')),
|
||||
minutes INTEGER NOT NULL CHECK (minutes > 0),
|
||||
price_idr INTEGER NOT NULL CHECK (price_idr >= 0),
|
||||
original_price_idr INTEGER CHECK (original_price_idr IS NULL OR original_price_idr >= price_idr),
|
||||
tag TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (mode, minutes)
|
||||
)
|
||||
`
|
||||
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_pricing_tiers_mode_active_sort
|
||||
ON pricing_tiers (mode, is_active, sort_order)
|
||||
`
|
||||
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS pricing_promotions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
eligibility TEXT NOT NULL UNIQUE
|
||||
CHECK (eligibility IN ('first_session')),
|
||||
actual_price_idr INTEGER NOT NULL CHECK (actual_price_idr >= 0),
|
||||
gimmick_price_idr INTEGER CHECK (gimmick_price_idr IS NULL OR gimmick_price_idr >= actual_price_idr),
|
||||
duration_minutes INTEGER NOT NULL CHECK (duration_minutes > 0),
|
||||
modes TEXT[] NOT NULL CHECK (
|
||||
array_length(modes, 1) >= 1
|
||||
AND modes <@ ARRAY['chat', 'call']::TEXT[]
|
||||
),
|
||||
starts_at TIMESTAMPTZ,
|
||||
ends_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`
|
||||
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS pricing_tiers_history (
|
||||
history_id BIGSERIAL PRIMARY KEY,
|
||||
tier_id UUID NOT NULL,
|
||||
mode TEXT NOT NULL,
|
||||
minutes INTEGER NOT NULL,
|
||||
price_idr INTEGER NOT NULL,
|
||||
original_price_idr INTEGER,
|
||||
tag TEXT,
|
||||
sort_order INTEGER NOT NULL,
|
||||
is_active BOOLEAN NOT NULL,
|
||||
changed_by UUID,
|
||||
change_kind TEXT NOT NULL CHECK (change_kind IN ('create', 'update', 'delete')),
|
||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`
|
||||
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_pricing_tiers_history_tier_time
|
||||
ON pricing_tiers_history (tier_id, changed_at DESC)
|
||||
`
|
||||
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS pricing_promotions_history (
|
||||
history_id BIGSERIAL PRIMARY KEY,
|
||||
promotion_id UUID NOT NULL,
|
||||
enabled BOOLEAN NOT NULL,
|
||||
eligibility TEXT NOT NULL,
|
||||
actual_price_idr INTEGER NOT NULL,
|
||||
gimmick_price_idr INTEGER,
|
||||
duration_minutes INTEGER NOT NULL,
|
||||
modes TEXT[] NOT NULL,
|
||||
starts_at TIMESTAMPTZ,
|
||||
ends_at TIMESTAMPTZ,
|
||||
changed_by UUID,
|
||||
change_kind TEXT NOT NULL CHECK (change_kind IN ('create', 'update', 'delete')),
|
||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`
|
||||
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_pricing_promotions_history_promo_time
|
||||
ON pricing_promotions_history (promotion_id, changed_at DESC)
|
||||
`
|
||||
|
||||
// --- Backfill: pricing_tiers ---
|
||||
//
|
||||
// Only seeds when the table is empty so re-runs don't clobber operator
|
||||
// edits or insert duplicates. The unique(mode, minutes) constraint is a
|
||||
// belt-and-suspenders backstop in case a half-finished run is retried.
|
||||
//
|
||||
// Source preference order per mode:
|
||||
// 1. app_config.pricing_{chat,call}_tiers_json (if the row exists)
|
||||
// 2. Hardcoded defaults that match DEFAULT_{CHAT,CALL}_TIERS in
|
||||
// pricing.service.js. We duplicate them here rather than importing
|
||||
// because migrate.js is a standalone script.
|
||||
|
||||
const DEFAULT_CHAT_TIERS_BACKFILL = [
|
||||
{ minutes: 5, price_idr: 5000, tag: null },
|
||||
{ minutes: 12, price_idr: 12000, tag: 'paling pas' },
|
||||
{ minutes: 30, price_idr: 25000, tag: 'hemat' },
|
||||
{ minutes: 60, price_idr: 45000, tag: null },
|
||||
{ minutes: 120, price_idr: 80000, tag: 'best deal' },
|
||||
]
|
||||
const DEFAULT_CALL_TIERS_BACKFILL = [
|
||||
{ minutes: 10, price_idr: 9000, tag: null },
|
||||
{ minutes: 20, price_idr: 17000, tag: 'paling pas' },
|
||||
{ minutes: 45, price_idr: 35000, tag: null },
|
||||
{ minutes: 60, price_idr: 45000, tag: 'hemat' },
|
||||
]
|
||||
|
||||
const [{ n: tierCount }] = await sql`SELECT COUNT(*)::int AS n FROM pricing_tiers`
|
||||
if (tierCount === 0) {
|
||||
const [chatRow] = await sql`SELECT value FROM app_config WHERE key = 'pricing_chat_tiers_json'`
|
||||
const [callRow] = await sql`SELECT value FROM app_config WHERE key = 'pricing_call_tiers_json'`
|
||||
|
||||
const chatTiers = Array.isArray(chatRow?.value?.tiers) ? chatRow.value.tiers : DEFAULT_CHAT_TIERS_BACKFILL
|
||||
const callTiers = Array.isArray(callRow?.value?.tiers) ? callRow.value.tiers : DEFAULT_CALL_TIERS_BACKFILL
|
||||
|
||||
for (const [mode, tiers] of [['chat', chatTiers], ['call', callTiers]]) {
|
||||
let order = 0
|
||||
for (const t of tiers) {
|
||||
await sql`
|
||||
INSERT INTO pricing_tiers (mode, minutes, price_idr, tag, sort_order, is_active)
|
||||
VALUES (
|
||||
${mode},
|
||||
${t.minutes},
|
||||
${t.price_idr},
|
||||
${t.tag ?? null},
|
||||
${order++},
|
||||
true
|
||||
)
|
||||
ON CONFLICT (mode, minutes) DO NOTHING
|
||||
`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Backfill: pricing_promotions (single 'first_session' row) ---
|
||||
//
|
||||
// Defaults below match DEFAULT_DISCOUNT in pricing.service.js. The five
|
||||
// legacy app_config keys override these if present.
|
||||
|
||||
const [{ n: promoCount }] = await sql`
|
||||
SELECT COUNT(*)::int AS n FROM pricing_promotions WHERE eligibility = 'first_session'
|
||||
`
|
||||
if (promoCount === 0) {
|
||||
const keys = [
|
||||
'first_session_discount_enabled',
|
||||
'first_session_discount_actual_price_idr',
|
||||
'first_session_discount_gimmick_price_idr',
|
||||
'first_session_discount_duration_minutes',
|
||||
'first_session_discount_modes',
|
||||
]
|
||||
const rows = await sql`SELECT key, value FROM app_config WHERE key IN ${sql(keys)}`
|
||||
const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value?.value]))
|
||||
|
||||
const enabled = byKey.first_session_discount_enabled ?? true
|
||||
const actual = byKey.first_session_discount_actual_price_idr ?? 2000
|
||||
const gimmick = byKey.first_session_discount_gimmick_price_idr ?? 12000
|
||||
const duration = byKey.first_session_discount_duration_minutes ?? 12
|
||||
const modes = Array.isArray(byKey.first_session_discount_modes)
|
||||
? byKey.first_session_discount_modes
|
||||
: ['chat']
|
||||
|
||||
await sql`
|
||||
INSERT INTO pricing_promotions (
|
||||
enabled, eligibility, actual_price_idr, gimmick_price_idr, duration_minutes, modes
|
||||
)
|
||||
VALUES (
|
||||
${enabled},
|
||||
'first_session',
|
||||
${actual},
|
||||
${gimmick},
|
||||
${duration},
|
||||
${modes}
|
||||
)
|
||||
ON CONFLICT (eligibility) DO NOTHING
|
||||
`
|
||||
}
|
||||
|
||||
// --- Pricing relational migration — Stage 5 cleanup ---
|
||||
//
|
||||
// The seven legacy `app_config` rows below were the JSON-on-app_config
|
||||
// source of truth for pricing tiers and the first-session discount, copied
|
||||
// into `pricing_tiers` / `pricing_promotions` by the Stage 1 backfill above.
|
||||
// Stage 3 cut every read path over to the relational tables; this delete
|
||||
// removes the now-orphaned rows so operator edits in CC can't get out of
|
||||
// sync with the live source.
|
||||
//
|
||||
// Idempotent: a fresh dev DB just deletes zero rows. A previously-migrated
|
||||
// DB on this revision is a no-op. The seed of these keys in the Phase 4
|
||||
// app_config INSERT block above (~line 627) uses ON CONFLICT (key) DO
|
||||
// NOTHING — so even if the seed runs *after* this delete during a future
|
||||
// refactor, we don't accidentally resurrect them on the next pass.
|
||||
await sql`
|
||||
DELETE FROM app_config
|
||||
WHERE key IN (
|
||||
'first_session_discount_enabled',
|
||||
'first_session_discount_actual_price_idr',
|
||||
'first_session_discount_gimmick_price_idr',
|
||||
'first_session_discount_duration_minutes',
|
||||
'first_session_discount_modes',
|
||||
'pricing_chat_tiers_json',
|
||||
'pricing_call_tiers_json'
|
||||
)
|
||||
`
|
||||
|
||||
console.log('Migration complete.')
|
||||
await sql.end()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user