You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq/src/repos-modules.ts

256 lines
10 KiB
TypeScript

import { uuidv7 } from '@sims/domain'
import { writeAudit } from './audit'
import type { DB } from './db'
/** Module catalog + dated price book — plain functions over the handle (D12 portable-repo pattern). */
export type Kind = 'one_time' | 'monthly' | 'yearly' | 'usage'
const ALL_KINDS: Kind[] = ['one_time', 'monthly', 'yearly', 'usage']
export interface Module {
id: string; code: string; name: string; sac: string
allowedKinds: Kind[]; multiSubscription: boolean; active: boolean
quoteContent: string[] // optional "what's included" lines that flow onto quotes
}
interface ModuleRow {
id: string; code: string; name: string; sac: string
allowed_kinds: string; multi_subscription: number; active: number
quote_content: string
}
function toModule(r: ModuleRow): Module {
return {
id: r.id, code: r.code, name: r.name, sac: r.sac,
allowedKinds: JSON.parse(r.allowed_kinds) as Kind[],
multiSubscription: r.multi_subscription === 1,
active: r.active === 1,
quoteContent: JSON.parse(r.quote_content) as string[],
}
}
export interface ModuleInput {
code: string; name: string; sac?: string
allowedKinds?: Kind[]; multiSubscription?: boolean; quoteContent?: string[]
}
export function getModule(db: DB, id: string): Module | null {
const row = db.prepare(`SELECT * FROM module WHERE id=?`).get(id) as ModuleRow | undefined
return row === undefined ? null : toModule(row)
}
export function listModules(db: DB): Module[] {
const rows = db.prepare(`SELECT * FROM module ORDER BY code`).all() as ModuleRow[]
return rows.map(toModule)
}
export function createModule(db: DB, userId: string, input: ModuleInput): Module {
const kinds = input.allowedKinds ?? ALL_KINDS
for (const k of kinds) {
if (!ALL_KINDS.includes(k)) throw new Error(`Unknown kind: ${k}`)
}
const id = uuidv7()
db.prepare(
`INSERT INTO module (id, code, name, sac, allowed_kinds, multi_subscription, quote_content)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
).run(
id, input.code, input.name, input.sac ?? '998313',
JSON.stringify(kinds), input.multiSubscription === true ? 1 : 0,
JSON.stringify(input.quoteContent ?? []),
)
const mod = getModule(db, id)!
writeAudit(db, userId, 'create', 'module', id, undefined, mod)
return mod
}
export interface ModulePatch {
name?: string; sac?: string; allowedKinds?: Kind[]
multiSubscription?: boolean; active?: boolean; quoteContent?: string[]
}
export function updateModule(db: DB, userId: string, id: string, patch: ModulePatch): Module {
const before = getModule(db, id)
if (before === null) throw new Error('Module not found')
if (patch.allowedKinds !== undefined) {
for (const k of patch.allowedKinds) {
if (!ALL_KINDS.includes(k)) throw new Error(`Unknown kind: ${k}`)
}
}
const sets: string[] = []
const args: unknown[] = []
if (patch.name !== undefined) { sets.push('name=?'); args.push(patch.name) }
if (patch.sac !== undefined) { sets.push('sac=?'); args.push(patch.sac) }
if (patch.allowedKinds !== undefined) { sets.push('allowed_kinds=?'); args.push(JSON.stringify(patch.allowedKinds)) }
if (patch.multiSubscription !== undefined) { sets.push('multi_subscription=?'); args.push(patch.multiSubscription ? 1 : 0) }
if (patch.active !== undefined) { sets.push('active=?'); args.push(patch.active ? 1 : 0) }
if (patch.quoteContent !== undefined) { sets.push('quote_content=?'); args.push(JSON.stringify(patch.quoteContent)) }
if (sets.length > 0) {
args.push(id)
db.prepare(`UPDATE module SET ${sets.join(', ')} WHERE id=?`).run(...args)
}
const after = getModule(db, id)!
writeAudit(db, userId, 'update', 'module', id, before, after)
return after
}
export interface ModulePrice {
id: string; moduleId: string; edition: string; kind: Kind
pricePaise: number; effectiveFrom: string
}
interface PriceRow {
id: string; module_id: string; edition: string; kind: string
price_paise: number; effective_from: string
}
function toPrice(r: PriceRow): ModulePrice {
return {
id: r.id, moduleId: r.module_id, edition: r.edition,
kind: r.kind as Kind, pricePaise: r.price_paise, effectiveFrom: r.effective_from,
}
}
export interface PriceInput {
moduleId: string; edition?: string; kind: Kind; pricePaise: number; effectiveFrom: string
}
export function setPrice(db: DB, userId: string, input: PriceInput): void {
if (!ALL_KINDS.includes(input.kind)) throw new Error(`Unknown kind: ${input.kind}`)
if (!Number.isInteger(input.pricePaise) || input.pricePaise < 0) {
throw new Error('pricePaise must be a non-negative integer (paise)')
}
if (getModule(db, input.moduleId) === null) throw new Error('Module not found')
const id = uuidv7()
const edition = input.edition ?? 'standard'
db.prepare(
`INSERT INTO module_price_book (id, module_id, edition, kind, price_paise, effective_from)
VALUES (?, ?, ?, ?, ?, ?)`,
).run(id, input.moduleId, edition, input.kind, input.pricePaise, input.effectiveFrom)
writeAudit(db, userId, 'create', 'module_price_book', id, undefined, {
moduleId: input.moduleId, edition, kind: input.kind,
pricePaise: input.pricePaise, effectiveFrom: input.effectiveFrom,
})
}
export function listPrices(db: DB, moduleId: string): ModulePrice[] {
const rows = db.prepare(
`SELECT * FROM module_price_book WHERE module_id=? ORDER BY edition, kind, effective_from`,
).all(moduleId) as PriceRow[]
return rows.map(toPrice)
}
/** Latest effective_from <= onDate wins — the D5 dated-rows philosophy. */
export function priceOn(db: DB, moduleId: string, kind: Kind, edition: string, onDate: string): number | null {
const row = db.prepare(
`SELECT price_paise FROM module_price_book
WHERE module_id=? AND kind=? AND edition=? AND effective_from <= ?
ORDER BY effective_from DESC LIMIT 1`,
).get(moduleId, kind, edition, onDate) as { price_paise: number } | undefined
return row === undefined ? null : row.price_paise
}
// ---------- client-module tracking (lifecycle + multi-subscription rule) ----------
export type ClientModuleStatus =
| 'quoted' | 'ordered' | 'installing' | 'installed'
| 'trained' | 'live' | 'expired' | 'cancelled'
const ALL_STATUSES: ClientModuleStatus[] = [
'quoted', 'ordered', 'installing', 'installed', 'trained', 'live', 'expired', 'cancelled',
]
export interface ClientModule {
id: string; clientId: string; moduleId: string; status: ClientModuleStatus
kind: Kind; edition: string
installedOn: string | null; completedOn: string | null; trainedOn: string | null
nextRenewal: string | null; active: boolean
}
interface ClientModuleRow {
id: string; client_id: string; module_id: string; status: string
kind: string; edition: string
installed_on: string | null; completed_on: string | null; trained_on: string | null
next_renewal: string | null; active: number
}
function toClientModule(r: ClientModuleRow): ClientModule {
return {
id: r.id, clientId: r.client_id, moduleId: r.module_id,
status: r.status as ClientModuleStatus, kind: r.kind as Kind, edition: r.edition,
installedOn: r.installed_on, completedOn: r.completed_on, trainedOn: r.trained_on,
nextRenewal: r.next_renewal, active: r.active === 1,
}
}
export function getClientModule(db: DB, id: string): ClientModule | null {
const row = db.prepare(`SELECT * FROM client_module WHERE id=?`).get(id) as ClientModuleRow | undefined
return row === undefined ? null : toClientModule(row)
}
export function listClientModules(db: DB, clientId: string): ClientModule[] {
const rows = db.prepare(
`SELECT * FROM client_module WHERE client_id=? ORDER BY id`,
).all(clientId) as ClientModuleRow[]
return rows.map(toClientModule)
}
export interface AssignModuleInput {
clientId: string; moduleId: string; kind: Kind
edition?: string; status?: ClientModuleStatus
}
export function assignModule(db: DB, userId: string, input: AssignModuleInput): ClientModule {
const mod = getModule(db, input.moduleId)
if (mod === null) throw new Error('Module not found')
if (!mod.allowedKinds.includes(input.kind)) {
throw new Error(`Module ${mod.code} does not allow kind '${input.kind}' (allowed: ${mod.allowedKinds.join(', ')})`)
}
const status = input.status ?? 'quoted'
if (!ALL_STATUSES.includes(status)) throw new Error(`Unknown status: ${status}`)
if (!mod.multiSubscription) {
const existing = db.prepare(
`SELECT COUNT(*) AS n FROM client_module WHERE client_id=? AND module_id=? AND active=1`,
).get(input.clientId, input.moduleId) as { n: number }
if (existing.n > 0) {
throw new Error(`Client already has an active subscription for module ${mod.code}`)
}
}
const id = uuidv7()
db.prepare(
`INSERT INTO client_module (id, client_id, module_id, status, kind, edition)
VALUES (?, ?, ?, ?, ?, ?)`,
).run(id, input.clientId, input.moduleId, status, input.kind, input.edition ?? 'standard')
const cm = getClientModule(db, id)!
writeAudit(db, userId, 'create', 'client_module', id, undefined, cm)
return cm
}
export interface ClientModulePatch {
status?: ClientModuleStatus; installedOn?: string | null; completedOn?: string | null
trainedOn?: string | null; nextRenewal?: string | null; active?: boolean
}
export function updateClientModule(db: DB, userId: string, id: string, patch: ClientModulePatch): ClientModule {
const before = getClientModule(db, id)
if (before === null) throw new Error('Client module not found')
if (patch.status !== undefined && !ALL_STATUSES.includes(patch.status)) {
throw new Error(`Unknown status: ${patch.status}`)
}
const sets: string[] = []
const args: unknown[] = []
if (patch.status !== undefined) { sets.push('status=?'); args.push(patch.status) }
if (patch.installedOn !== undefined) { sets.push('installed_on=?'); args.push(patch.installedOn) }
if (patch.completedOn !== undefined) { sets.push('completed_on=?'); args.push(patch.completedOn) }
if (patch.trainedOn !== undefined) { sets.push('trained_on=?'); args.push(patch.trainedOn) }
if (patch.nextRenewal !== undefined) { sets.push('next_renewal=?'); args.push(patch.nextRenewal) }
if (patch.active !== undefined) { sets.push('active=?'); args.push(patch.active ? 1 : 0) }
if (sets.length > 0) {
args.push(id)
db.prepare(`UPDATE client_module SET ${sets.join(', ')} WHERE id=?`).run(...args)
}
const after = getClientModule(db, id)!
writeAudit(db, userId, 'update', 'client_module', id, before, after)
return after
}