Files
famdone/frontend/src/lib/server/access.ts
T
2026-08-22 19:49:46 +01:00

101 lines
3.9 KiB
TypeScript

import { pbAdmin } from '$lib/server/pocketbase';
// How a family has access. 'none' = signed up with no code/sub yet (gated).
export type PaymentMode = 'none' | 'code' | 'sub' | 'canceled';
export interface FamAccess {
disabled: boolean;
mode: PaymentMode;
reason: '' | 'no_access' | 'canceled' | 'subscription_inactive' | 'code_disabled' | 'code_expired';
}
// UTC timestamp `months` months after `iso`. Used for the code's global expiry
// (from the code's createdAt) and the duration clock (from entry date).
export function addMonthsUTC(iso: string | Date, months: number): number {
const d = new Date(iso);
d.setUTCMonth(d.getUTCMonth() + months);
return d.getTime();
}
// A code is usable iff it exists, is not globally disabled, is not past its own
// createdAt+expiry window (expiry 0 = never), and the duration clock from the
// fam's entry date hasn't run out (duration 0 = continuous).
export function codeIsValid(code: any, enteredAt?: string): boolean {
if (!code) return false;
if (code.active === false) return false;
const now = Date.now();
const expiryMonths = Number(code.expiry) || 0;
if (expiryMonths > 0 && now >= addMonthsUTC(code.createdAt, expiryMonths)) return false;
const durationMonths = Number(code.duration) || 0;
if (durationMonths > 0 && enteredAt) {
if (now >= addMonthsUTC(enteredAt, durationMonths)) return false;
}
return true;
}
// Deterministic decision from a fam + its linked code (no I/O). `active` on the
// fam is the single source of truth for "usable right now".
export function computeFamAccess(fam: any, code: any): FamAccess {
const mode: PaymentMode = fam.paymentMode || 'none';
switch (mode) {
case 'code': {
if (code?.active === false) return { disabled: true, mode, reason: 'code_disabled' };
return codeIsValid(code, fam.accessCodeEnteredAt)
? { disabled: false, mode, reason: '' }
: { disabled: true, mode, reason: 'code_expired' };
}
case 'sub':
return fam.active === false
? { disabled: true, mode, reason: 'subscription_inactive' }
: { disabled: false, mode, reason: '' };
case 'canceled':
return { disabled: true, mode, reason: 'canceled' };
case 'none':
default:
return { disabled: true, mode, reason: 'no_access' };
}
}
// Read the fam + linked code and persist `active` if it drifted. Called on every
// [fam] layout load (both roles) and wherever access state must be re-evaluated.
export async function ensureFamAccess(famId: string): Promise<{ fam: any; access: FamAccess }> {
const fam = await pbAdmin.getOne('fams', famId);
if (!fam) return { fam: null, access: { disabled: true, mode: 'none', reason: 'no_access' } };
let code: any = null;
if (fam.accessCodeId) {
try {
code = await pbAdmin.getOne('accesscodes', fam.accessCodeId);
} catch {
code = null;
}
}
const access = computeFamAccess(fam, code);
if (fam.active !== !access.disabled) {
await pbAdmin.update('fams', famId, { active: !access.disabled });
}
return { fam: { ...fam, active: !access.disabled }, access };
}
// Apply an access code value to a fam. Automatic (no approval) — validates
// against the accesscodes collection and sets paymentMode='code' on success.
export async function applyAccessCode(famId: string, value: string) {
const val = String(value || '').trim();
if (!val) return { error: 'Enter an access code' };
const list = await pbAdmin.getList('accesscodes', `value = '${val}'`);
const code = list?.[0];
if (!code) return { error: 'That access code is not recognised' };
if (code.active === false) return { error: 'That access code is disabled' };
const now = new Date().toISOString();
const valid = codeIsValid(code, now);
await pbAdmin.update('fams', famId, {
paymentMode: 'code',
accessCodeId: code.id,
accessCodeEnteredAt: now,
active: valid
});
return {
ok: true,
active: valid,
code: { name: code.name, value: code.value, duration: code.duration, expiry: code.expiry }
};
}