|
|
import { uuidv7 } from '@sims/domain'
|
|
|
import { writeAudit } from './audit'
|
|
|
import { decrypt, encrypt } from './crypto'
|
|
|
import { ensureProjectMilestones } from './repos-milestones'
|
|
|
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']
|
|
|
|
|
|
/**
|
|
|
* A field a module declares (D21). `secret: true` fields are stored encrypted
|
|
|
* (client_module.secrets_enc) and are reveal-only; everything else lands in
|
|
|
* client_module.field_values. `type` drives the UI input + light validation.
|
|
|
*/
|
|
|
export type FieldType = 'text' | 'secret' | 'date' | 'number' | 'select' | 'boolean'
|
|
|
export interface FieldDef {
|
|
|
key: string; label: string; type: FieldType
|
|
|
options?: string[] // for type 'select'
|
|
|
required?: boolean; help?: string; sort?: number
|
|
|
}
|
|
|
const FIELD_TYPES: FieldType[] = ['text', 'secret', 'date', 'number', 'select', 'boolean']
|
|
|
const KEY_RE = /^[a-z][a-z0-9_]*$/
|
|
|
|
|
|
/** Validate a module's declared field list — keys unique + slug-shaped, types known. */
|
|
|
export function validateFieldSpec(spec: unknown): FieldDef[] {
|
|
|
if (!Array.isArray(spec)) throw new Error('field_spec must be an array of field definitions')
|
|
|
const seen = new Set<string>()
|
|
|
return spec.map((raw, i) => {
|
|
|
const f = raw as Partial<FieldDef>
|
|
|
if (typeof f.key !== 'string' || !KEY_RE.test(f.key)) {
|
|
|
throw new Error(`field #${i + 1}: key must be lower_snake_case (got ${String(f.key)})`)
|
|
|
}
|
|
|
if (seen.has(f.key)) throw new Error(`duplicate field key: ${f.key}`)
|
|
|
seen.add(f.key)
|
|
|
if (typeof f.label !== 'string' || f.label.trim() === '') throw new Error(`field ${f.key}: label required`)
|
|
|
if (typeof f.type !== 'string' || !FIELD_TYPES.includes(f.type)) {
|
|
|
throw new Error(`field ${f.key}: type must be one of ${FIELD_TYPES.join(', ')}`)
|
|
|
}
|
|
|
if (f.type === 'select' && (!Array.isArray(f.options) || f.options.length === 0)) {
|
|
|
throw new Error(`field ${f.key}: select needs a non-empty options list`)
|
|
|
}
|
|
|
return {
|
|
|
key: f.key, label: f.label.trim(), type: f.type,
|
|
|
...(f.options !== undefined ? { options: f.options } : {}),
|
|
|
...(f.required !== undefined ? { required: !!f.required } : {}),
|
|
|
...(f.help !== undefined ? { help: String(f.help) } : {}),
|
|
|
...(f.sort !== undefined ? { sort: Number(f.sort) } : {}),
|
|
|
}
|
|
|
})
|
|
|
}
|
|
|
|
|
|
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
|
|
|
fieldSpec: FieldDef[] // D21: the operational fields this module declares
|
|
|
sortOrder: number // D25: fixed display order (lower first); owner-editable
|
|
|
}
|
|
|
|
|
|
interface ModuleRow {
|
|
|
id: string; code: string; name: string; sac: string
|
|
|
allowed_kinds: string; multi_subscription: number; active: number
|
|
|
quote_content: string; field_spec: string; sort_order: number
|
|
|
}
|
|
|
|
|
|
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[],
|
|
|
fieldSpec: JSON.parse(r.field_spec ?? '[]') as FieldDef[],
|
|
|
sortOrder: r.sort_order ?? 100,
|
|
|
}
|
|
|
}
|
|
|
|
|
|
export interface ModuleInput {
|
|
|
code: string; name: string; sac?: string
|
|
|
allowedKinds?: Kind[]; multiSubscription?: boolean; quoteContent?: string[]
|
|
|
fieldSpec?: FieldDef[]; sortOrder?: number
|
|
|
}
|
|
|
|
|
|
export async function getModule(db: DB, id: string): Promise<Module | null> {
|
|
|
const row = await db.get<ModuleRow>(`SELECT * FROM module WHERE id=?`, id)
|
|
|
return row === undefined ? null : toModule(row)
|
|
|
}
|
|
|
|
|
|
export async function listModules(db: DB): Promise<Module[]> {
|
|
|
// D25: fixed business display order (sort_order), code as the stable tiebreak.
|
|
|
const rows = await db.all<ModuleRow>(`SELECT * FROM module ORDER BY sort_order, code`)
|
|
|
return rows.map(toModule)
|
|
|
}
|
|
|
|
|
|
export async function createModule(db: DB, userId: string, input: ModuleInput): Promise<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()
|
|
|
const fieldSpec = input.fieldSpec !== undefined ? validateFieldSpec(input.fieldSpec) : []
|
|
|
await db.run(
|
|
|
`INSERT INTO module (id, code, name, sac, allowed_kinds, multi_subscription, quote_content, field_spec, sort_order)
|
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
|
id, input.code, input.name, input.sac ?? '998313',
|
|
|
JSON.stringify(kinds), input.multiSubscription === true ? 1 : 0,
|
|
|
JSON.stringify(input.quoteContent ?? []), JSON.stringify(fieldSpec),
|
|
|
input.sortOrder ?? 100,
|
|
|
)
|
|
|
const mod = (await getModule(db, id))!
|
|
|
await 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[]
|
|
|
fieldSpec?: FieldDef[]; sortOrder?: number
|
|
|
}
|
|
|
|
|
|
export async function updateModule(db: DB, userId: string, id: string, patch: ModulePatch): Promise<Module> {
|
|
|
const before = await 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 (patch.fieldSpec !== undefined) { sets.push('field_spec=?'); args.push(JSON.stringify(validateFieldSpec(patch.fieldSpec))) }
|
|
|
if (patch.sortOrder !== undefined) { sets.push('sort_order=?'); args.push(patch.sortOrder) }
|
|
|
if (sets.length > 0) {
|
|
|
args.push(id)
|
|
|
await db.run(`UPDATE module SET ${sets.join(', ')} WHERE id=?`, ...args)
|
|
|
}
|
|
|
const after = (await getModule(db, id))!
|
|
|
await 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 async function setPrice(db: DB, userId: string, input: PriceInput): Promise<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 ((await getModule(db, input.moduleId)) === null) throw new Error('Module not found')
|
|
|
const id = uuidv7()
|
|
|
const edition = input.edition ?? 'standard'
|
|
|
await db.run(
|
|
|
`INSERT INTO module_price_book (id, module_id, edition, kind, price_paise, effective_from)
|
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
|
id, input.moduleId, edition, input.kind, input.pricePaise, input.effectiveFrom,
|
|
|
)
|
|
|
await writeAudit(db, userId, 'create', 'module_price_book', id, undefined, {
|
|
|
moduleId: input.moduleId, edition, kind: input.kind,
|
|
|
pricePaise: input.pricePaise, effectiveFrom: input.effectiveFrom,
|
|
|
})
|
|
|
}
|
|
|
|
|
|
export async function listPrices(db: DB, moduleId: string): Promise<ModulePrice[]> {
|
|
|
const rows = await db.all<PriceRow>(
|
|
|
`SELECT * FROM module_price_book WHERE module_id=? ORDER BY edition, kind, effective_from`,
|
|
|
moduleId,
|
|
|
)
|
|
|
return rows.map(toPrice)
|
|
|
}
|
|
|
|
|
|
/** Latest effective_from <= onDate wins — the D5 dated-rows philosophy. */
|
|
|
export async function priceOn(db: DB, moduleId: string, kind: Kind, edition: string, onDate: string): Promise<number | null> {
|
|
|
const row = await db.get<{ price_paise: number }>(
|
|
|
`SELECT price_paise FROM module_price_book
|
|
|
WHERE module_id=? AND kind=? AND edition=? AND effective_from <= ?
|
|
|
ORDER BY effective_from DESC LIMIT 1`,
|
|
|
moduleId, kind, edition, onDate,
|
|
|
)
|
|
|
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',
|
|
|
]
|
|
|
|
|
|
/** Free-form labeled value shown on the module's service card (D20 APEX parity). */
|
|
|
export interface ServiceDetail { label: string; value: string }
|
|
|
|
|
|
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
|
|
|
/** D20 per-service operational data. The portal password is NOT here — it is
|
|
|
* encrypted at rest and readable only via revealModulePassword (audited). */
|
|
|
provider: string | null; username: string | null
|
|
|
details: ServiceDetail[]; remark: string | null
|
|
|
/** True when an encrypted portal password is stored (UI shows •••• + Reveal). */
|
|
|
hasPassword: boolean
|
|
|
/** D21 module-defined field values (non-secret), keyed by the module's field keys. */
|
|
|
fieldValues: Record<string, string>
|
|
|
/** D21: which secret fields have a value stored (the ciphertext never leaves the repo). */
|
|
|
secretKeys: string[]
|
|
|
}
|
|
|
|
|
|
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
|
|
|
provider: string | null; username: string | null; password_enc: string | null
|
|
|
details: string; remark: string | null
|
|
|
field_values: string; secrets_enc: string | null
|
|
|
}
|
|
|
|
|
|
function toClientModule(r: ClientModuleRow): ClientModule {
|
|
|
// secretKeys is derived WITHOUT decrypting: the blob is a JSON map whose keys we
|
|
|
// never need the value of here, but the ciphertext is opaque — so we track set-ness
|
|
|
// via a plaintext key index kept alongside. To avoid a second column, the encrypted
|
|
|
// map is prefixed with its key list in clear (keys are not secret; values are).
|
|
|
let secretKeys: string[] = []
|
|
|
if (r.secrets_enc !== null) {
|
|
|
const sep = r.secrets_enc.indexOf('|')
|
|
|
if (sep > 0) secretKeys = r.secrets_enc.slice(0, sep).split(',').filter((k) => k !== '')
|
|
|
}
|
|
|
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,
|
|
|
provider: r.provider, username: r.username,
|
|
|
details: JSON.parse(r.details) as ServiceDetail[], remark: r.remark,
|
|
|
hasPassword: r.password_enc !== null, // the ciphertext itself never leaves the repo
|
|
|
fieldValues: JSON.parse(r.field_values ?? '{}') as Record<string, string>,
|
|
|
secretKeys,
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function assertDetails(details: ServiceDetail[]): void {
|
|
|
if (!Array.isArray(details)) throw new Error('details must be an array of { label, value }')
|
|
|
for (const d of details) {
|
|
|
if (typeof d?.label !== 'string' || typeof d?.value !== 'string' || d.label.trim() === '') {
|
|
|
throw new Error('Each detail needs a non-empty label and a string value')
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
export async function getClientModule(db: DB, id: string): Promise<ClientModule | null> {
|
|
|
const row = await db.get<ClientModuleRow>(`SELECT * FROM client_module WHERE id=?`, id)
|
|
|
return row === undefined ? null : toClientModule(row)
|
|
|
}
|
|
|
|
|
|
export async function listClientModules(db: DB, clientId: string): Promise<ClientModule[]> {
|
|
|
const rows = await db.all<ClientModuleRow>(
|
|
|
`SELECT * FROM client_module WHERE client_id=? ORDER BY id`, clientId,
|
|
|
)
|
|
|
return rows.map(toClientModule)
|
|
|
}
|
|
|
|
|
|
// ---------- module directory (D28): every client on a module × its service fields ----------
|
|
|
|
|
|
export interface ModuleDirectoryRow {
|
|
|
cmId: string; clientId: string; clientName: string; clientCode: string
|
|
|
status: string; provider: string | null; username: string | null; hasPassword: boolean
|
|
|
/** Lifecycle / install details (D20) so the roster shows when each client went live. */
|
|
|
kind: string; edition: string
|
|
|
installedOn: string | null; completedOn: string | null; trainedOn: string | null; nextRenewal: string | null
|
|
|
/** The module's declared field values for this client (non-secret; secrets stay server-side). */
|
|
|
fieldValues: Record<string, string>
|
|
|
}
|
|
|
export interface ModuleDirectory {
|
|
|
moduleId: string; code: string; name: string; fieldSpec: FieldDef[]
|
|
|
rows: ModuleDirectoryRow[]; total: number
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* The operational roster for ONE module: every active client on it with that module's
|
|
|
* field values, so the whole book (all RTGS IPs, all CloudBackup buckets, …) is reviewable
|
|
|
* at once instead of one client at a time. Secret field VALUES never leave the repo — the
|
|
|
* spec tells the UI which are secret; reveal stays on Client 360. Returns all rows (bounded
|
|
|
* by active subscriptions per module — no silent cap). Read-only, writes no audit.
|
|
|
*/
|
|
|
export async function moduleDirectory(db: DB, moduleId: string): Promise<ModuleDirectory | null> {
|
|
|
const mod = await getModule(db, moduleId)
|
|
|
if (mod === null) return null
|
|
|
const rows = await db.all<{
|
|
|
cm_id: string; client_id: string; client_name: string; client_code: string
|
|
|
status: string; provider: string | null; username: string | null; password_enc: string | null
|
|
|
kind: string; edition: string
|
|
|
installed_on: string | null; completed_on: string | null; trained_on: string | null; next_renewal: string | null
|
|
|
field_values: string
|
|
|
}>(
|
|
|
`SELECT cm.id AS cm_id, cm.client_id, c.name AS client_name, c.code AS client_code,
|
|
|
cm.status, cm.provider, cm.username, cm.password_enc,
|
|
|
cm.kind, cm.edition, cm.installed_on, cm.completed_on, cm.trained_on, cm.next_renewal,
|
|
|
cm.field_values
|
|
|
FROM client_module cm JOIN client c ON c.id = cm.client_id
|
|
|
WHERE cm.module_id=? AND cm.active=1
|
|
|
ORDER BY c.name, cm.id`,
|
|
|
moduleId,
|
|
|
)
|
|
|
const out: ModuleDirectoryRow[] = rows.map((r) => {
|
|
|
let fv: Record<string, string> = {}
|
|
|
try { fv = JSON.parse(r.field_values) as Record<string, string> } catch { fv = {} }
|
|
|
return {
|
|
|
cmId: r.cm_id, clientId: r.client_id, clientName: r.client_name, clientCode: r.client_code,
|
|
|
status: r.status, provider: r.provider, username: r.username, hasPassword: r.password_enc !== null,
|
|
|
kind: r.kind, edition: r.edition,
|
|
|
installedOn: r.installed_on, completedOn: r.completed_on, trainedOn: r.trained_on, nextRenewal: r.next_renewal,
|
|
|
fieldValues: fv,
|
|
|
}
|
|
|
})
|
|
|
return { moduleId, code: mod.code, name: mod.name, fieldSpec: mod.fieldSpec, rows: out, total: out.length }
|
|
|
}
|
|
|
|
|
|
// ---------- portals quick-list (D28): every stored login/portal across all modules ----------
|
|
|
|
|
|
export interface PortalRow {
|
|
|
cmId: string; clientId: string; clientName: string; clientCode: string
|
|
|
moduleCode: string; moduleName: string; provider: string | null
|
|
|
username: string | null; hasPassword: boolean
|
|
|
/** URL-valued fields of this subscription (portal/console shortcuts). */
|
|
|
links: { label: string; url: string }[]
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Every subscription that has a way in — a stored username, a portal password, or a
|
|
|
* URL-valued field — across ALL modules, so support can reach any client's panel from
|
|
|
* one place. Passwords never leave the repo (hasPassword only); reveal stays on Client
|
|
|
* 360. Read-only, no audit. No silent cap (bounded by active subscriptions).
|
|
|
*/
|
|
|
export async function listPortals(db: DB): Promise<PortalRow[]> {
|
|
|
const rows = await db.all<{
|
|
|
cm_id: string; client_id: string; client_name: string; client_code: string
|
|
|
mcode: string; mname: string; field_spec: string; provider: string | null
|
|
|
username: string | null; password_enc: string | null; field_values: string
|
|
|
}>(
|
|
|
`SELECT cm.id AS cm_id, cm.client_id, c.name AS client_name, c.code AS client_code,
|
|
|
m.code AS mcode, m.name AS mname, m.field_spec, cm.provider,
|
|
|
cm.username, cm.password_enc, cm.field_values
|
|
|
FROM client_module cm
|
|
|
JOIN client c ON c.id = cm.client_id
|
|
|
JOIN module m ON m.id = cm.module_id
|
|
|
WHERE cm.active = 1
|
|
|
ORDER BY c.name, m.code`,
|
|
|
)
|
|
|
const out: PortalRow[] = []
|
|
|
for (const r of rows) {
|
|
|
let fv: Record<string, string> = {}
|
|
|
let spec: FieldDef[] = []
|
|
|
try { fv = JSON.parse(r.field_values) as Record<string, string> } catch { fv = {} }
|
|
|
try { spec = JSON.parse(r.field_spec ?? '[]') as FieldDef[] } catch { spec = [] }
|
|
|
const links = spec
|
|
|
.filter((f) => f.type !== 'secret')
|
|
|
.map((f) => ({ label: f.label, url: (fv[f.key] ?? '').trim() }))
|
|
|
.filter((l) => /^https?:\/\//i.test(l.url))
|
|
|
const hasLogin = (r.username !== null && r.username !== '') || r.password_enc !== null || links.length > 0
|
|
|
if (!hasLogin) continue
|
|
|
out.push({
|
|
|
cmId: r.cm_id, clientId: r.client_id, clientName: r.client_name, clientCode: r.client_code,
|
|
|
moduleCode: r.mcode, moduleName: r.mname, provider: r.provider,
|
|
|
username: r.username, hasPassword: r.password_enc !== null, links,
|
|
|
})
|
|
|
}
|
|
|
return out
|
|
|
}
|
|
|
|
|
|
// ---------- module → client roster (spec §10, D-ROSTER) ----------
|
|
|
|
|
|
export interface ModuleClientRow {
|
|
|
/** client_module id — the handle the roster's inline edit/unassign writes through. */
|
|
|
cmId: string
|
|
|
clientId: string; clientName: string; clientCode: string
|
|
|
status: ClientModuleStatus; kind: Kind; edition: string
|
|
|
nextRenewal: string | null
|
|
|
/** Price-book rate for this link's kind/edition on the roster date; null = no price row. */
|
|
|
pricePaise: number | null
|
|
|
}
|
|
|
|
|
|
export interface ModuleClientsPage {
|
|
|
clients: ModuleClientRow[]
|
|
|
total: number; page: number; pageSize: number
|
|
|
/** Sum of price-book rates across ALL active links (not just this page). */
|
|
|
totalPricePaise: number
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* The reverse lookup: every client on a module (support/impact + revenue lens).
|
|
|
* ACTIVE links only — `active=0` assignments are historical and deliberately
|
|
|
* excluded (stated here, not silently dropped). Paginated with a true total
|
|
|
* (rule 8); price per row via the dated price book on `onDate`.
|
|
|
*/
|
|
|
export async function listClientsByModule(
|
|
|
db: DB, moduleId: string,
|
|
|
opts: { page?: number; pageSize?: number; onDate?: string } = {},
|
|
|
): Promise<ModuleClientsPage> {
|
|
|
const page = Math.max(1, opts.page ?? 1)
|
|
|
const pageSize = Math.min(200, Math.max(1, opts.pageSize ?? 50))
|
|
|
const onDate = opts.onDate ?? new Date().toISOString().slice(0, 10)
|
|
|
const total = ((await db.get<{ n: number }>(
|
|
|
`SELECT COUNT(*) AS n FROM client_module WHERE module_id=? AND active=1`, moduleId,
|
|
|
))!).n
|
|
|
const rows = await db.all<{
|
|
|
cm_id: string; client_id: string; client_name: string; client_code: string
|
|
|
status: string; kind: string; edition: string; next_renewal: string | null
|
|
|
}>(
|
|
|
`SELECT cm.id AS cm_id, cm.client_id, c.name AS client_name, c.code AS client_code,
|
|
|
cm.status, cm.kind, cm.edition, cm.next_renewal
|
|
|
FROM client_module cm JOIN client c ON c.id = cm.client_id
|
|
|
WHERE cm.module_id=? AND cm.active=1
|
|
|
ORDER BY c.name, cm.id
|
|
|
LIMIT ? OFFSET ?`,
|
|
|
moduleId, pageSize, (page - 1) * pageSize,
|
|
|
)
|
|
|
const clients: ModuleClientRow[] = []
|
|
|
for (const r of rows) {
|
|
|
clients.push({
|
|
|
cmId: r.cm_id,
|
|
|
clientId: r.client_id, clientName: r.client_name, clientCode: r.client_code,
|
|
|
status: r.status as ClientModuleStatus, kind: r.kind as Kind, edition: r.edition,
|
|
|
nextRenewal: r.next_renewal,
|
|
|
pricePaise: await priceOn(db, moduleId, r.kind as Kind, r.edition, onDate),
|
|
|
})
|
|
|
}
|
|
|
// Revenue total spans ALL active links, not the visible page (the footer figure).
|
|
|
const allLinks = await db.all<{ kind: string; edition: string }>(
|
|
|
`SELECT kind, edition FROM client_module WHERE module_id=? AND active=1`, moduleId,
|
|
|
)
|
|
|
let totalPricePaise = 0
|
|
|
for (const l of allLinks) {
|
|
|
totalPricePaise += (await priceOn(db, moduleId, l.kind as Kind, l.edition, onDate)) ?? 0
|
|
|
}
|
|
|
return { clients, total, page, pageSize, totalPricePaise }
|
|
|
}
|
|
|
|
|
|
export interface AssignModuleInput {
|
|
|
clientId: string; moduleId: string; kind: Kind
|
|
|
edition?: string; status?: ClientModuleStatus
|
|
|
}
|
|
|
|
|
|
export async function assignModule(db: DB, userId: string, input: AssignModuleInput): Promise<ClientModule> {
|
|
|
const mod = await 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 = (await db.get<{ n: number }>(
|
|
|
`SELECT COUNT(*) AS n FROM client_module WHERE client_id=? AND module_id=? AND active=1`,
|
|
|
input.clientId, input.moduleId,
|
|
|
))!
|
|
|
if (existing.n > 0) {
|
|
|
throw new Error(`Client already has an active subscription for module ${mod.code}`)
|
|
|
}
|
|
|
}
|
|
|
const id = uuidv7()
|
|
|
await db.run(
|
|
|
// created_at (FIX D — stalled-onboarding false positives): real assignment time, so a
|
|
|
// brand-new project with zero ticks is measured from here, not treated as ancient.
|
|
|
`INSERT INTO client_module (id, client_id, module_id, status, kind, edition, created_at)
|
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
|
id, input.clientId, input.moduleId, status, input.kind, input.edition ?? 'standard',
|
|
|
new Date().toISOString(),
|
|
|
)
|
|
|
await ensureProjectMilestones(db, id) // D22: seed the onboarding checklist for the new project
|
|
|
const cm = (await getClientModule(db, id))!
|
|
|
await 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
|
|
|
/** D18 WS-G (roster full-edit): kind must be one the module allows; edition free text. */
|
|
|
kind?: Kind; edition?: string
|
|
|
/** D20 service data ('' clears the text fields). The portal password is NOT
|
|
|
* patchable here — setModulePassword is its own gated, audited action. */
|
|
|
provider?: string; username?: string; remark?: string
|
|
|
details?: ServiceDetail[]
|
|
|
}
|
|
|
|
|
|
export async function updateClientModule(db: DB, userId: string, id: string, patch: ClientModulePatch): Promise<ClientModule> {
|
|
|
const before = await 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}`)
|
|
|
}
|
|
|
if (patch.kind !== undefined) {
|
|
|
const mod = await getModule(db, before.moduleId)
|
|
|
if (mod === null) throw new Error('Module not found')
|
|
|
if (!mod.allowedKinds.includes(patch.kind)) {
|
|
|
throw new Error(`Module ${mod.code} does not allow kind '${patch.kind}'`)
|
|
|
}
|
|
|
}
|
|
|
if (patch.edition !== undefined && patch.edition.trim() === '') {
|
|
|
throw new Error('Edition cannot be empty')
|
|
|
}
|
|
|
const sets: string[] = []
|
|
|
const args: unknown[] = []
|
|
|
if (patch.kind !== undefined) { sets.push('kind=?'); args.push(patch.kind) }
|
|
|
if (patch.edition !== undefined) { sets.push('edition=?'); args.push(patch.edition.trim()) }
|
|
|
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) }
|
|
|
const optText = (col: string, v: string | undefined) => {
|
|
|
if (v !== undefined) { sets.push(`${col}=?`); args.push(v.trim() === '' ? null : v.trim()) }
|
|
|
}
|
|
|
optText('provider', patch.provider)
|
|
|
optText('username', patch.username)
|
|
|
optText('remark', patch.remark)
|
|
|
if (patch.details !== undefined) {
|
|
|
assertDetails(patch.details)
|
|
|
sets.push('details=?'); args.push(JSON.stringify(patch.details))
|
|
|
}
|
|
|
if (sets.length > 0) {
|
|
|
args.push(id)
|
|
|
await db.run(`UPDATE client_module SET ${sets.join(', ')} WHERE id=?`, ...args)
|
|
|
}
|
|
|
const after = (await getClientModule(db, id))!
|
|
|
await writeAudit(db, userId, 'update', 'client_module', id, before, after)
|
|
|
return after
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Store (or clear, with '') the service portal password — AES-256-GCM at rest
|
|
|
* (D20, same discipline as the client DB password). Owner/manager-gated at the
|
|
|
* route; the plaintext never appears in audit rows. Empty keyHex fails loudly.
|
|
|
*/
|
|
|
export async function setModulePassword(
|
|
|
db: DB, userId: string, id: string, plain: string, keyHex: string,
|
|
|
): Promise<ClientModule> {
|
|
|
return db.transaction(async () => {
|
|
|
const before = await getClientModule(db, id)
|
|
|
if (before === null) throw new Error('Client module not found')
|
|
|
// D31: stored in the clear (encrypt() tags plaintext); no key required.
|
|
|
const enc = plain === '' ? null : encrypt(plain, keyHex)
|
|
|
await db.run(`UPDATE client_module SET password_enc=? WHERE id=?`, enc, id)
|
|
|
await writeAudit(db, userId, 'set_module_password', 'client_module', id, undefined, { set: enc !== null })
|
|
|
return (await getClientModule(db, id))!
|
|
|
})
|
|
|
}
|
|
|
|
|
|
/** Decrypt-and-return the stored portal password — every reveal writes an audit row. */
|
|
|
export async function revealModulePassword(db: DB, userId: string, id: string, keyHex: string): Promise<string> {
|
|
|
const row = await db.get<{ password_enc: string | null }>(
|
|
|
`SELECT password_enc FROM client_module WHERE id=?`, id,
|
|
|
)
|
|
|
if (row === undefined) throw new Error('Client module not found')
|
|
|
if (row.password_enc === null) throw new Error('No portal password stored for this module')
|
|
|
// D31: plaintext returns without a key; a legacy row still needs the key (decrypt throws).
|
|
|
const plain = decrypt(row.password_enc, keyHex)
|
|
|
await writeAudit(db, userId, 'reveal_module_password', 'client_module', id)
|
|
|
return plain
|
|
|
}
|
|
|
|
|
|
// ---------- D21: module-defined field values + multi-secret store ----------
|
|
|
|
|
|
// secrets_enc is stored as `keylist|ciphertext`: the clear comma-joined key list (keys are
|
|
|
// not secret) followed by the AES-GCM blob over the JSON { key: plaintext } map. The prefix
|
|
|
// lets the UI show which secrets are set without ever decrypting.
|
|
|
function packSecrets(map: Record<string, string>, keyHex: string): string | null {
|
|
|
const keys = Object.keys(map)
|
|
|
if (keys.length === 0) return null
|
|
|
return `${keys.join(',')}|${encrypt(JSON.stringify(map), keyHex)}`
|
|
|
}
|
|
|
function unpackSecrets(blob: string | null, keyHex: string): Record<string, string> {
|
|
|
if (blob === null) return {}
|
|
|
const sep = blob.indexOf('|')
|
|
|
if (sep < 0) return {}
|
|
|
return JSON.parse(decrypt(blob.slice(sep + 1), keyHex)) as Record<string, string>
|
|
|
}
|
|
|
|
|
|
/** The module's field definition for a key (or undefined). */
|
|
|
async function fieldDefFor(db: DB, moduleId: string, key: string): Promise<FieldDef | undefined> {
|
|
|
const mod = await getModule(db, moduleId)
|
|
|
return mod?.fieldSpec.find((f) => f.key === key)
|
|
|
}
|
|
|
|
|
|
/** Light per-type coercion/validation; returns the value to store (string). */
|
|
|
function coerceFieldValue(def: FieldDef, value: string): string {
|
|
|
const v = value.trim()
|
|
|
if (v === '') return ''
|
|
|
if (def.type === 'number' && !Number.isFinite(Number(v))) throw new Error(`${def.label}: must be a number`)
|
|
|
if (def.type === 'boolean' && !['true', 'false'].includes(v)) throw new Error(`${def.label}: must be true or false`)
|
|
|
if (def.type === 'select' && !(def.options ?? []).includes(v)) throw new Error(`${def.label}: must be one of ${(def.options ?? []).join(', ')}`)
|
|
|
if (def.type === 'date' && !/^\d{4}-\d{2}-\d{2}$/.test(v)) throw new Error(`${def.label}: must be YYYY-MM-DD`)
|
|
|
return v
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Merge module-defined NON-secret field values (D21). Keys must be declared in the
|
|
|
* module's field_spec and not be secret fields; '' clears a value. Audited.
|
|
|
*/
|
|
|
export async function setModuleFieldValues(
|
|
|
db: DB, userId: string, id: string, values: Record<string, string>,
|
|
|
): Promise<ClientModule> {
|
|
|
return db.transaction(async () => {
|
|
|
const before = await getClientModule(db, id)
|
|
|
if (before === null) throw new Error('Client module not found')
|
|
|
const merged = { ...before.fieldValues }
|
|
|
for (const [key, raw] of Object.entries(values)) {
|
|
|
const def = await fieldDefFor(db, before.moduleId, key)
|
|
|
if (def === undefined) throw new Error(`Unknown field '${key}' for this module`)
|
|
|
if (def.type === 'secret') throw new Error(`Field '${key}' is a secret — use the reveal/set-secret path`)
|
|
|
const v = coerceFieldValue(def, raw)
|
|
|
if (v === '') delete merged[key]
|
|
|
else merged[key] = v
|
|
|
}
|
|
|
await db.run(`UPDATE client_module SET field_values=? WHERE id=?`, JSON.stringify(merged), id)
|
|
|
const after = (await getClientModule(db, id))!
|
|
|
await writeAudit(db, userId, 'update', 'client_module', id, before, after)
|
|
|
return after
|
|
|
})
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Set (or clear, with '') one secret field's value — AES-256-GCM at rest (D21).
|
|
|
* Key must be a `secret` field in the module's field_spec. Owner/manager-gated at the
|
|
|
* route; the plaintext never appears in audit rows. Empty keyHex fails loudly.
|
|
|
*/
|
|
|
export async function setModuleSecret(
|
|
|
db: DB, userId: string, id: string, key: string, plain: string, keyHex: string,
|
|
|
): Promise<ClientModule> {
|
|
|
return db.transaction(async () => {
|
|
|
const beforeRow = await db.get<{ module_id: string; secrets_enc: string | null }>(
|
|
|
`SELECT module_id, secrets_enc FROM client_module WHERE id=?`, id,
|
|
|
)
|
|
|
if (beforeRow === undefined) throw new Error('Client module not found')
|
|
|
const def = await fieldDefFor(db, beforeRow.module_id, key)
|
|
|
if (def === undefined || def.type !== 'secret') throw new Error(`Field '${key}' is not a secret field of this module`)
|
|
|
// D31: stored in the clear; no key required.
|
|
|
const before = (await getClientModule(db, id))!
|
|
|
const map = unpackSecrets(beforeRow.secrets_enc, keyHex)
|
|
|
if (plain.trim() === '') delete map[key]
|
|
|
else map[key] = plain
|
|
|
await db.run(`UPDATE client_module SET secrets_enc=? WHERE id=?`, packSecrets(map, keyHex), id)
|
|
|
await writeAudit(db, userId, 'set_module_secret', 'client_module', id, undefined, { key, set: plain.trim() !== '' })
|
|
|
return (await getClientModule(db, id))!
|
|
|
})
|
|
|
}
|
|
|
|
|
|
/** Decrypt-and-return one secret field's value — every reveal writes an audit row. */
|
|
|
export async function revealModuleSecret(
|
|
|
db: DB, userId: string, id: string, key: string, keyHex: string,
|
|
|
): Promise<string> {
|
|
|
const row = await db.get<{ secrets_enc: string | null }>(`SELECT secrets_enc FROM client_module WHERE id=?`, id)
|
|
|
if (row === undefined) throw new Error('Client module not found')
|
|
|
// D31: plaintext returns without a key; a legacy row still needs the key (unpack throws).
|
|
|
const map = unpackSecrets(row.secrets_enc, keyHex)
|
|
|
if (!(key in map)) throw new Error(`No value stored for secret '${key}'`)
|
|
|
await writeAudit(db, userId, 'reveal_module_secret', 'client_module', id, undefined, { key })
|
|
|
return map[key]!
|
|
|
}
|