Files
housing-helper/server/applicationsStore.mjs
Dr Jones 697185c0a1 first commit
Made-with: Cursor
2026-04-25 06:56:23 +00:00

186 lines
4.6 KiB
JavaScript

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<object | null>}
*/
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,
})
}