349 lines
11 KiB
TypeScript
349 lines
11 KiB
TypeScript
import { pbAdmin, createPbClient } from '$lib/server/pocketbase';
|
|
import { redirect, fail } from '@sveltejs/kit';
|
|
import type { RequestEvent } from '@sveltejs/kit';
|
|
import { setPlatformSession, clearPlatformSession } from '$lib/server/session';
|
|
import { getPlatformFlags, setPlatformFlag } from '$lib/server/platform';
|
|
import type { Actions, PageServerLoad } from './$types';
|
|
|
|
function requirePlatform(event: RequestEvent) {
|
|
if (!event.locals.platformAdmin) throw redirect(303, '/admin');
|
|
}
|
|
|
|
// Random human-friendly code value: XXXX-XXXX (unambiguous charset).
|
|
function genCodeValue(): string {
|
|
const chars = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
|
const pick = () => chars[Math.floor(Math.random() * chars.length)];
|
|
return `${Array.from({ length: 4 }, pick).join('')}-${Array.from({ length: 4 }, pick).join('')}`;
|
|
}
|
|
|
|
export const load: PageServerLoad = async (event) => {
|
|
const { cookies } = event;
|
|
if (!event.locals.platformAdmin) {
|
|
return {
|
|
authenticated: false,
|
|
fams: [],
|
|
codes: [],
|
|
choreTemplates: [],
|
|
bonusTemplates: [],
|
|
platformFlags: {},
|
|
totalFams: 0,
|
|
totalMembers: 0,
|
|
totalRewards: 0,
|
|
totalChores: 0,
|
|
subCount: 0,
|
|
gatedCount: 0,
|
|
codeCount: 0,
|
|
loadError: undefined
|
|
};
|
|
}
|
|
|
|
try {
|
|
const [fams, codes, completions] = await Promise.all([
|
|
pbAdmin.getList('fams'),
|
|
pbAdmin.getList('accesscodes'),
|
|
pbAdmin.getList('completions')
|
|
]);
|
|
const famsWithStats = await Promise.all(
|
|
fams.map(async (fam: any) => {
|
|
const [members, rewards, parents] = await Promise.all([
|
|
pbAdmin.getList('users', `famId = '${fam.id}' && role = 'child'`),
|
|
pbAdmin.getList('rewards', `famId = '${fam.id}'`),
|
|
pbAdmin.getList('users', `famId = '${fam.id}' && role = 'parent'`)
|
|
]);
|
|
return {
|
|
id: fam.id,
|
|
name: fam.name,
|
|
slug: fam.slug,
|
|
memberCount: members.length,
|
|
requestedRewards: (rewards as any[]).filter((r: any) => r.status === 'requested').length,
|
|
totalRewards: rewards.length,
|
|
parentEmail: (parents as any[])?.[0]?.email || '',
|
|
paymentMode: fam.paymentMode || 'none',
|
|
active: fam.active !== false
|
|
};
|
|
})
|
|
);
|
|
|
|
// Usage map — which fams applied each code.
|
|
const usage: Record<string, string[]> = {};
|
|
for (const fam of fams as any[]) {
|
|
if (fam.paymentMode === 'code' && fam.accessCodeId) {
|
|
(usage[fam.accessCodeId] ||= []).push(fam.name);
|
|
}
|
|
}
|
|
const codeList = (codes as any[])
|
|
.sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || ''))
|
|
.map((c) => ({
|
|
id: c.id,
|
|
value: c.value,
|
|
name: c.name,
|
|
duration: Number(c.duration) || 0,
|
|
expiry: Number(c.expiry) || 0,
|
|
trialDays: Number(c.trialDays) || 0,
|
|
active: c.active !== false,
|
|
usedBy: usage[c.id] || []
|
|
}));
|
|
|
|
const [choreTemplates, bonusTemplates] = await Promise.all([
|
|
pbAdmin.getList('chore_templates', 'global = true'),
|
|
pbAdmin.getList('bonus_templates', 'global = true')
|
|
]);
|
|
|
|
return {
|
|
authenticated: true,
|
|
fams: famsWithStats,
|
|
codes: codeList,
|
|
choreTemplates: choreTemplates as any[],
|
|
bonusTemplates: bonusTemplates as any[],
|
|
totalFams: fams.length,
|
|
totalMembers: famsWithStats.reduce((s, f) => s + f.memberCount, 0),
|
|
totalRewards: famsWithStats.reduce((s, f) => s + f.totalRewards, 0),
|
|
totalChores: completions.length,
|
|
subCount: famsWithStats.filter((f) => f.paymentMode === 'sub').length,
|
|
gatedCount: famsWithStats.filter(
|
|
(f) => !f.active || f.paymentMode === 'none' || f.paymentMode === 'canceled'
|
|
).length,
|
|
codeCount: codeList.filter((c) => c.active).length
|
|
};
|
|
} catch (e) {
|
|
// Never swallow silently — a failed load must not masquerade as logged-out.
|
|
console.error('[admin] load failed:', e);
|
|
return {
|
|
authenticated: false,
|
|
fams: [],
|
|
codes: [],
|
|
totalFams: 0,
|
|
totalMembers: 0,
|
|
totalRewards: 0,
|
|
totalChores: 0,
|
|
subCount: 0,
|
|
gatedCount: 0,
|
|
codeCount: 0,
|
|
choreTemplates: [],
|
|
bonusTemplates: [],
|
|
loadError: e instanceof Error ? e.message : 'Failed to load platform data'
|
|
};
|
|
}
|
|
};
|
|
|
|
export const actions: Actions = {
|
|
login: async ({ request, cookies }) => {
|
|
const fd = await request.formData();
|
|
const email = fd.get('email') as string;
|
|
const password = fd.get('password') as string;
|
|
|
|
if (!email || !password) return fail(400, { error: 'Email and password required' });
|
|
|
|
// Real PB superuser auth — the minted JWT goes in the cookie and is
|
|
// verified per-request in hooks (authRefresh). Forging the cookie
|
|
// value gains nothing.
|
|
try {
|
|
const auth = await createPbClient()
|
|
.collection('_superusers')
|
|
.authWithPassword(email, password);
|
|
setPlatformSession(cookies, auth.token);
|
|
} catch {
|
|
return fail(400, { error: 'Invalid credentials' });
|
|
}
|
|
return { success: true };
|
|
},
|
|
|
|
logout: async ({ cookies }) => {
|
|
clearPlatformSession(cookies);
|
|
throw redirect(303, '/admin');
|
|
},
|
|
|
|
// Toggles a platform-level feature flag on the singleton platform record.
|
|
togglePlatformFlag: async (event) => {
|
|
requirePlatform(event);
|
|
|
|
const fd = await event.request.formData();
|
|
const flag = fd.get('flag') as string;
|
|
if (!flag) return fail(400, { error: 'Flag required' });
|
|
|
|
try {
|
|
const flags = await getPlatformFlags();
|
|
await setPlatformFlag(flag, !flags[flag]);
|
|
return { success: true };
|
|
} catch (e) {
|
|
return fail(500, { error: e instanceof Error ? e.message : 'Failed to update' });
|
|
}
|
|
},
|
|
|
|
// Issue a new access/trial code. Blank value → auto-generated. trialDays > 0
|
|
// makes it a trial code (maps to Stripe trial_period_days at checkout);
|
|
// otherwise it's a platform-access code (duration months, 0 = continuous).
|
|
createCode: async (event) => {
|
|
requirePlatform(event);
|
|
|
|
const fd = await event.request.formData();
|
|
const name = ((fd.get('name') as string) || '').trim();
|
|
const value = ((fd.get('value') as string) || '').trim().toUpperCase() || genCodeValue();
|
|
const duration = Math.max(0, parseInt(fd.get('duration') as string, 10) || 0);
|
|
const expiry = Math.max(0, parseInt(fd.get('expiry') as string, 10) || 0);
|
|
const trialDays = Math.max(0, parseInt(fd.get('trialDays') as string, 10) || 0);
|
|
|
|
if (!name) return fail(400, { error: 'Name required' });
|
|
|
|
try {
|
|
const existing = await pbAdmin.getList('accesscodes', `value = '${value}'`);
|
|
if (existing.length) return fail(400, { error: `Code "${value}" already exists` });
|
|
await pbAdmin.create('accesscodes', {
|
|
value,
|
|
name,
|
|
duration,
|
|
expiry,
|
|
trialDays,
|
|
active: true,
|
|
createdAt: new Date().toISOString()
|
|
});
|
|
return { success: true, createdValue: value };
|
|
} catch (e) {
|
|
return fail(500, { error: e instanceof Error ? e.message : 'Failed to create code' });
|
|
}
|
|
},
|
|
|
|
toggleCode: async (event) => {
|
|
requirePlatform(event);
|
|
|
|
const fd = await event.request.formData();
|
|
const id = fd.get('id') as string;
|
|
try {
|
|
const rec = (await pbAdmin.getOne('accesscodes', id)) as any;
|
|
await pbAdmin.update('accesscodes', id, { active: rec.active === false });
|
|
return { success: true };
|
|
} catch (e) {
|
|
return fail(500, { error: e instanceof Error ? e.message : 'Failed to update code' });
|
|
}
|
|
},
|
|
|
|
deleteCode: async (event) => {
|
|
requirePlatform(event);
|
|
|
|
const fd = await event.request.formData();
|
|
const id = fd.get('id') as string;
|
|
|
|
// Guard: a code still applied to a fam must be disabled, not deleted.
|
|
const inUse = await pbAdmin.getList('fams', `accessCodeId = '${id}'`);
|
|
if (inUse.length) {
|
|
return fail(400, {
|
|
error: `In use by ${inUse.length} fam${inUse.length > 1 ? 's' : ''} — disable it instead.`
|
|
});
|
|
}
|
|
try {
|
|
await pbAdmin.remove('accesscodes', id);
|
|
return { success: true };
|
|
} catch (e) {
|
|
return fail(500, { error: e instanceof Error ? e.message : 'Failed to delete code' });
|
|
}
|
|
},
|
|
|
|
// Platform-owned chore/reward templates. `type` selects the target
|
|
// collection (chore_templates | bonus_templates); the droplet is always
|
|
// global (visible to every family as an assignable source).
|
|
createTemplate: async (event) => {
|
|
requirePlatform(event);
|
|
const fd = await event.request.formData();
|
|
const type = fd.get('type') as string;
|
|
const name = (fd.get('name') as string || '').trim();
|
|
const description = (fd.get('description') as string) || '';
|
|
const icon = (fd.get('icon') as string) || '';
|
|
const color = (fd.get('color') as string) || '#6366f1';
|
|
if (!name) return fail(400, { error: 'Name required' });
|
|
|
|
try {
|
|
if (type === 'chore') {
|
|
await pbAdmin.create('chore_templates', {
|
|
name,
|
|
description,
|
|
icon,
|
|
color,
|
|
global: true,
|
|
defaultFrequency: (fd.get('defaultFrequency') as string) || 'daily',
|
|
defaultType: (fd.get('defaultType') as string) || 'points',
|
|
defaultValue: Number(fd.get('defaultValue') || 0)
|
|
});
|
|
} else {
|
|
await pbAdmin.create('bonus_templates', {
|
|
name,
|
|
description,
|
|
icon,
|
|
color,
|
|
global: true,
|
|
target: (fd.get('target') as string) || 'individual',
|
|
type: (fd.get('bonusType') as string) || 'threshold',
|
|
thresholdType: (fd.get('thresholdType') as string) || 'points',
|
|
occurrence: (fd.get('occurrence') as string) || 'recurring',
|
|
rewardType: (fd.get('rewardType') as string) || 'points',
|
|
rewardValue: (fd.get('rewardValue') as string) || '',
|
|
criteriaValue: Number(fd.get('criteriaValue') || 0),
|
|
period: (fd.get('period') as string) || 'weekly',
|
|
isPocketMoney: fd.get('isPocketMoney') === 'true'
|
|
});
|
|
}
|
|
return { success: true };
|
|
} catch (e) {
|
|
return fail(500, { error: e instanceof Error ? e.message : 'Failed to create template' });
|
|
}
|
|
},
|
|
|
|
updateTemplate: async (event) => {
|
|
requirePlatform(event);
|
|
const fd = await event.request.formData();
|
|
const kind = fd.get('kind') as string; // chore | reward
|
|
const id = fd.get('id') as string;
|
|
if (!id) return fail(400, { error: 'Missing id' });
|
|
const name = (fd.get('name') as string || '').trim();
|
|
const description = (fd.get('description') as string) || '';
|
|
const icon = (fd.get('icon') as string) || '';
|
|
const color = (fd.get('color') as string) || '#6366f1';
|
|
|
|
try {
|
|
if (kind === 'chore') {
|
|
await pbAdmin.update('chore_templates', id, {
|
|
name,
|
|
description,
|
|
icon,
|
|
color,
|
|
defaultFrequency: (fd.get('defaultFrequency') as string) || 'daily',
|
|
defaultType: (fd.get('defaultType') as string) || 'points',
|
|
defaultValue: Number(fd.get('defaultValue') || 0)
|
|
});
|
|
} else {
|
|
await pbAdmin.update('bonus_templates', id, {
|
|
name,
|
|
description,
|
|
icon,
|
|
color,
|
|
target: (fd.get('target') as string) || 'individual',
|
|
type: (fd.get('bonusType') as string) || 'threshold',
|
|
thresholdType: (fd.get('thresholdType') as string) || 'points',
|
|
occurrence: (fd.get('occurrence') as string) || 'recurring',
|
|
rewardType: (fd.get('rewardType') as string) || 'points',
|
|
rewardValue: (fd.get('rewardValue') as string) || '',
|
|
criteriaValue: Number(fd.get('criteriaValue') || 0),
|
|
period: (fd.get('period') as string) || 'weekly',
|
|
isPocketMoney: fd.get('isPocketMoney') === 'true'
|
|
});
|
|
}
|
|
return { success: true };
|
|
} catch (e) {
|
|
return fail(500, { error: e instanceof Error ? e.message : 'Failed to update template' });
|
|
}
|
|
},
|
|
|
|
deleteTemplate: async (event) => {
|
|
requirePlatform(event);
|
|
const fd = await event.request.formData();
|
|
const kind = fd.get('kind') as string;
|
|
const id = fd.get('id') as string;
|
|
if (!id) return fail(400, { error: 'Missing id' });
|
|
try {
|
|
await pbAdmin.remove(kind === 'chore' ? 'chore_templates' : 'bonus_templates', id);
|
|
return { success: true };
|
|
} catch (e) {
|
|
return fail(500, { error: e instanceof Error ? e.message : 'Failed to delete template' });
|
|
}
|
|
}
|
|
};
|