first commit
Made-with: Cursor
This commit is contained in:
185
server/applicationsStore.mjs
Normal file
185
server/applicationsStore.mjs
Normal file
@@ -0,0 +1,185 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user