186 lines
4.6 KiB
JavaScript
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,
|
|
})
|
|
}
|