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:
@@ -52,8 +52,13 @@ export const resetDbHard = async () => {
|
||||
|
||||
/**
|
||||
* Drop and re-seed the configurable app_config rows back to their canonical defaults.
|
||||
* Tests that mutate config (e.g. flipping first_session_discount_enabled) call this
|
||||
* in afterEach.
|
||||
* Tests that mutate config (e.g. flipping pricing_promotions.enabled) call this in
|
||||
* afterEach.
|
||||
*
|
||||
* Note: the first-session discount config no longer lives in app_config (Stage 5
|
||||
* deleted those legacy keys). It now lives in the `pricing_promotions` table, which
|
||||
* is also reset here back to the seed defaults that match migrate.js + the
|
||||
* DEFAULT_DISCOUNT in pricing.service.js.
|
||||
*/
|
||||
export const resetAppConfig = async () => {
|
||||
const sql = db()
|
||||
@@ -69,12 +74,6 @@ export const resetAppConfig = async () => {
|
||||
['returning_chat_confirmation_timeout_seconds', { value: 20 }],
|
||||
['extension_default_action_on_timeout', { value: 'auto_approve' }],
|
||||
['pairing_blast_timeout_seconds', { value: 60 }],
|
||||
// Phase 4
|
||||
['first_session_discount_enabled', { value: true }],
|
||||
['first_session_discount_actual_price_idr', { value: 2000 }],
|
||||
['first_session_discount_gimmick_price_idr', { value: 12000 }],
|
||||
['first_session_discount_duration_minutes', { value: 12 }],
|
||||
['first_session_discount_modes', { value: ['chat'] }],
|
||||
['three_minute_warning_enabled', { value: true }],
|
||||
]
|
||||
for (const [key, value] of defaults) {
|
||||
@@ -84,4 +83,18 @@ export const resetAppConfig = async () => {
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||
`
|
||||
}
|
||||
|
||||
// Reset pricing_promotions to canonical Phase 4 defaults. The Stage 1 backfill
|
||||
// gates on "table empty" so we can't rely on migrate.js to restore values after
|
||||
// a test mutates them — this UPDATE is the test-side reset hook.
|
||||
await sql`
|
||||
UPDATE pricing_promotions
|
||||
SET enabled = true,
|
||||
actual_price_idr = 2000,
|
||||
gimmick_price_idr = 12000,
|
||||
duration_minutes = 12,
|
||||
modes = ${['chat']},
|
||||
updated_at = NOW()
|
||||
WHERE eligibility = 'first_session'
|
||||
`
|
||||
}
|
||||
|
||||
@@ -57,6 +57,44 @@ export const createMitra = async ({
|
||||
*/
|
||||
export const seedDefaultConfig = () => resetAppConfig()
|
||||
|
||||
/**
|
||||
* Insert (or fetch) a control-center user with full `config` permissions. Used by
|
||||
* /internal/config/* route tests that need a JWT subject that survives the
|
||||
* `attachCcUser` + `requirePermission('config', …)` preHandler chain.
|
||||
*
|
||||
* Idempotent: re-runs return the same row by email. We do NOT truncate cc_user / roles
|
||||
* between tests (db.js documents the rationale), so subsequent test files inherit
|
||||
* whatever this seeded.
|
||||
*/
|
||||
export const createCcUser = async ({
|
||||
email = `cc-test-${randomUUID().slice(0, 8)}@halobestie.test`,
|
||||
displayName = 'CC Test User',
|
||||
permissions = {
|
||||
mitra: ['create', 'read', 'update', 'delete'],
|
||||
control_center_users: ['create', 'read', 'update', 'delete'],
|
||||
config: ['read', 'update'],
|
||||
roles: ['create', 'read', 'update', 'delete'],
|
||||
},
|
||||
} = {}) => {
|
||||
const sql = db()
|
||||
// One role per test invocation, named after a slice of the email so re-runs don't
|
||||
// collide with the seeded `super_admin` role from seed.js.
|
||||
const roleName = `cc-test-role-${email.slice(0, 16)}`
|
||||
const [role] = await sql`
|
||||
INSERT INTO roles (name, permissions)
|
||||
VALUES (${roleName}, ${sql.json(permissions)})
|
||||
ON CONFLICT (name) DO UPDATE SET permissions = EXCLUDED.permissions
|
||||
RETURNING id
|
||||
`
|
||||
const [user] = await sql`
|
||||
INSERT INTO control_center_users (email, display_name, role_id, password_hash)
|
||||
VALUES (${email}, ${displayName}, ${role.id}, 'unused-for-jwt-tests')
|
||||
ON CONFLICT (email) DO UPDATE SET role_id = EXCLUDED.role_id
|
||||
RETURNING id, email, display_name, role_id, created_at
|
||||
`
|
||||
return { ...user, role: { id: role.id, permissions } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: full reset between tests. Truncates Phase 3.7 tables, restores
|
||||
* default config rows.
|
||||
|
||||
Reference in New Issue
Block a user