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>
103 lines
3.7 KiB
JavaScript
103 lines
3.7 KiB
JavaScript
import { randomUUID } from 'node:crypto'
|
|
import { db, resetAppConfig } from './db.js'
|
|
|
|
/**
|
|
* Insert a customer row. Defaults to the schema after the Phase 3.4 auth rewrite
|
|
* (display_name nullable, is_anonymous defaults true).
|
|
*/
|
|
export const createCustomer = async ({
|
|
id = randomUUID(),
|
|
callName = `TestCust-${id.slice(0, 6)}`,
|
|
phone = null,
|
|
isAnonymous = false,
|
|
} = {}) => {
|
|
const sql = db()
|
|
const [row] = await sql`
|
|
INSERT INTO customers (id, display_name, phone, is_anonymous)
|
|
VALUES (${id}, ${callName}, ${phone}, ${isAnonymous})
|
|
RETURNING id, display_name, phone, is_anonymous, created_at
|
|
`
|
|
return row
|
|
}
|
|
|
|
/**
|
|
* Insert a mitra row. If `isOnline` is true, also creates the mitra_online_status row
|
|
* so pairing.findAvailableMitras includes it.
|
|
*/
|
|
export const createMitra = async ({
|
|
id = randomUUID(),
|
|
callName = `TestMitra-${id.slice(0, 6)}`,
|
|
phone = null,
|
|
isActive = true,
|
|
isOnline = false,
|
|
} = {}) => {
|
|
const sql = db()
|
|
// mitras.phone is NOT NULL UNIQUE — synthesize a unique phone if not given.
|
|
const finalPhone = phone || `+62800${Math.floor(Math.random() * 1e10).toString().padStart(10, '0')}`
|
|
const [row] = await sql`
|
|
INSERT INTO mitras (id, display_name, phone, is_active)
|
|
VALUES (${id}, ${callName}, ${finalPhone}, ${isActive})
|
|
RETURNING id, display_name, phone, is_active, created_at
|
|
`
|
|
if (isOnline) {
|
|
const now = new Date()
|
|
await sql`
|
|
INSERT INTO mitra_online_status (mitra_id, is_online, last_online_at, last_heartbeat_at, updated_at)
|
|
VALUES (${id}, true, ${now}, ${now}, ${now})
|
|
ON CONFLICT (mitra_id) DO UPDATE
|
|
SET is_online = true, last_online_at = ${now}, last_heartbeat_at = ${now}, updated_at = ${now}
|
|
`
|
|
}
|
|
return row
|
|
}
|
|
|
|
/**
|
|
* Reset app_config rows to their canonical defaults. Tests that mutate config call
|
|
* this in afterEach (or rely on the global beforeEach in resetAll).
|
|
*/
|
|
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.
|
|
*/
|
|
export { resetDb, resetDbHard, resetAppConfig } from './db.js'
|