import fs from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' import crypto from 'node:crypto' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const appsDir = process.env.APPLICATIONS_DATA_DIR ? path.resolve(process.env.APPLICATIONS_DATA_DIR) : path.join(__dirname, '..', 'data', 'applications') const REF_RE = /^SV-\d{8}-[A-HJ-NP-Z2-9]{6}$/ export function isValidApplicationRef(ref) { return typeof ref === 'string' && REF_RE.test(ref) } export function makeApplicationRef() { const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' const d = new Date() const y = d.getFullYear() const m = String(d.getMonth() + 1).padStart(2, '0') const day = String(d.getDate()).padStart(2, '0') let suffix = '' for (let i = 0; i < 6; i += 1) { suffix += alphabet[crypto.randomInt(alphabet.length)] } return `SV-${y}${m}${day}-${suffix}` } export function makeApplicationSecret() { return crypto.randomBytes(24).toString('base64url') } export function hashApplicationSecret(secret) { return crypto.createHash('sha256').update(String(secret)).digest('hex') } async function ensureDir() { await fs.mkdir(appsDir, { recursive: true, mode: 0o700 }) await fs.chmod(appsDir, 0o700) } function filePath(ref) { if (!isValidApplicationRef(ref)) { throw new Error('Invalid application reference.') } return path.join(appsDir, `${ref}.json`) } /** * @param {string} ref * @returns {Promise} */ export async function readApplication(ref) { try { const raw = await fs.readFile(filePath(ref), 'utf8') return JSON.parse(raw) } catch { return null } } /** * @param {object} record */ export async function writeApplication(record) { const ref = record.applicationRef await ensureDir() const tmp = `${filePath(ref)}.tmp` const finalPath = filePath(ref) const body = JSON.stringify(record) await fs.writeFile(tmp, body, { encoding: 'utf8', mode: 0o600 }) await fs.rename(tmp, finalPath) await fs.chmod(finalPath, 0o600) } /** * @param {object} patch */ export async function createApplicationRecord({ payload = {} } = {}) { await ensureDir() let ref = makeApplicationRef() for (let attempt = 0; attempt < 8; attempt += 1) { try { await fs.access(filePath(ref)) ref = makeApplicationRef() } catch { break } } const now = new Date().toISOString() const applicationSecret = makeApplicationSecret() const record = { applicationRef: ref, applicationSecretHash: hashApplicationSecret(applicationSecret), createdAt: now, updatedAt: now, status: 'draft', stripeSessionId: null, paymentType: null, applicantEmail: '', applicantPhone: '', payload, } await writeApplication(record) return { ...record, applicationSecret } } /** * @param {string} ref * @param {object} updates */ export async function updateApplication(ref, updates) { const existing = await readApplication(ref) if (!existing) return null const next = { ...existing, ...updates, applicationRef: ref, updatedAt: new Date().toISOString(), } await writeApplication(next) return next } /** * @param {number} limit */ export async function listApplicationSummaries(limit = 80) { await ensureDir() let names try { names = await fs.readdir(appsDir) } catch { return [] } const jsonFiles = names.filter((n) => n.endsWith('.json') && n.startsWith('SV-')) const rows = await Promise.all( jsonFiles.map(async (name) => { const full = path.join(appsDir, name) try { const st = await fs.stat(full) return { full, mtime: st.mtimeMs } } catch { return null } }), ) const sorted = rows.filter(Boolean).sort((a, b) => b.mtime - a.mtime) const out = [] for (const row of sorted.slice(0, limit)) { try { const raw = await fs.readFile(row.full, 'utf8') const doc = JSON.parse(raw) const email = doc.payload?.identity?.email || doc.payload?.identity?.applicantEmail || doc.applicantEmail || '' out.push({ applicationRef: doc.applicationRef, createdAt: doc.createdAt, updatedAt: doc.updatedAt, status: doc.status, paymentType: doc.paymentType ?? doc.payload?.prefs?.paymentPath ?? null, applicantEmail: email, }) } catch { /* skip corrupt */ } } return out } /** * @param {string} ref * @param {string} sessionId */ export async function markPaidFromStripe(ref, sessionId) { return updateApplication(ref, { status: 'paid', stripeSessionId: sessionId, }) }