178 lines
6.1 KiB
TypeScript
178 lines
6.1 KiB
TypeScript
import { fail, redirect } from '@sveltejs/kit';
|
|
import type { RequestEvent } from '@sveltejs/kit';
|
|
import { pbAdmin } from '$lib/server/pocketbase';
|
|
import { createPbClient } from '$lib/server/pocketbase';
|
|
import { setSessionCookie } from '$lib/server/session';
|
|
import { issueAccess } from '$lib/server/member-otp';
|
|
import { slugify, handle, famUsername, handleOf } from '@shared/slugify';
|
|
import { applyAccessCode } from '$lib/server/access';
|
|
import { sendWelcomeEmail, sendAccessUnlockedEmail } from '$lib/server/email';
|
|
import { createEmbeddedCheckoutSession, PLAN_IDS } from '$lib/server/stripe';
|
|
import type { PlanId } from '$lib/server/stripe';
|
|
|
|
class SignupError extends Error {}
|
|
|
|
export const actions = {
|
|
// Step 1 — create the family + parent (admin) user, mint their session.
|
|
// The parent's human-entered name is kept as the display `name`; their PB
|
|
// `username` is `{famSlug}:{handle}` (globally unique, handle = no whitespace).
|
|
signup: async (event) => {
|
|
const fd = await event.request.formData();
|
|
const famName = fd.get('familyName') as string;
|
|
const yourName = (fd.get('yourName') as string) || '';
|
|
const email = fd.get('email') as string;
|
|
const password = fd.get('password') as string;
|
|
|
|
if (!famName || !yourName || !email || !password) {
|
|
return fail(400, { message: 'All fields required', famName, email });
|
|
}
|
|
if (password.length < 8) {
|
|
return fail(400, { message: 'Password must be at least 8 characters', famName, email });
|
|
}
|
|
|
|
const parentName = yourName.trim();
|
|
const slug = slugify(famName);
|
|
const handleName = handle(parentName) || 'admin';
|
|
const username = famUsername(slug, handleName);
|
|
|
|
try {
|
|
const famData: Record<string, unknown> = {
|
|
name: famName,
|
|
slug,
|
|
timezone: 'auto',
|
|
paymentMode: 'none',
|
|
active: false
|
|
};
|
|
const fam = await pbAdmin.create('fams', famData);
|
|
const user = await pbAdmin.create('users', {
|
|
username,
|
|
name: parentName,
|
|
email,
|
|
password,
|
|
passwordConfirm: password,
|
|
emailVisibility: false,
|
|
famId: fam.id,
|
|
role: 'parent'
|
|
});
|
|
await pbAdmin.create('settings', { famId: fam.id });
|
|
// Pocket money is a platform-owned global template (seeded by migrate)
|
|
// and per-child instances are created when each child joins — no
|
|
// family-level template needed here.
|
|
} catch (e) {
|
|
throw new SignupError(
|
|
`Could not create account — ${e instanceof Error ? e.message : 'please try again'}`
|
|
);
|
|
}
|
|
|
|
// Auth as the new parent to mint their JWT, then move to the child step.
|
|
const authResult = await createPbClient()
|
|
.collection('users')
|
|
.authWithPassword(email, password)
|
|
.catch(() => null);
|
|
if (authResult?.token) setSessionCookie(event.cookies, authResult.token);
|
|
|
|
// Welcome email — a best-effort send. Failures must not block signup.
|
|
const dashboardUrl = `${event.url.origin}/${slug}`;
|
|
sendWelcomeEmail({ to: email, famName, dashboardUrl }).catch(() => {});
|
|
|
|
return { success: true, famSlug: slug, username: handleName };
|
|
},
|
|
|
|
// Step 2 — optionally add a child now; issues their OTP join code.
|
|
child: async (event: RequestEvent) => {
|
|
const user = requireUser(event);
|
|
const fd = await event.request.formData();
|
|
const name = ((fd.get('member') as string) || '').trim();
|
|
|
|
// No [fam] URL param here (signup isn't fam-scoped) — resolve the slug
|
|
// from DB. Everywhere else, the slug comes from event.params.fam /
|
|
// page.data.famSlug ([fam] layout load) — never copy it into state.
|
|
const fam = await pbAdmin.getOne('fams', user.famId);
|
|
const famSlug = fam?.slug || user.famId;
|
|
|
|
if (!name) {
|
|
return { success: true, famSlug, username: handleOf(user.username || '') };
|
|
}
|
|
|
|
const { otp, joinUrl } = await issueAccess({
|
|
famId: user.famId,
|
|
famSlug,
|
|
name
|
|
});
|
|
|
|
return { success: true, code: otp, joinUrl, famSlug, username: handleOf(user.username || '') };
|
|
},
|
|
|
|
// Step 3 — apply an access code (or skip via client-side navigation).
|
|
access: async (event: RequestEvent) => {
|
|
const user = requireUser(event);
|
|
const fd = await event.request.formData();
|
|
const code = ((fd.get('code') as string) || '').trim();
|
|
|
|
if (!code) {
|
|
return { ok: true, skipped: true };
|
|
}
|
|
|
|
const result = await applyAccessCode(user.famId, code);
|
|
if (result.error) return fail(400, { error: result.error });
|
|
|
|
// Access code applied successfully — confirm by email (best-effort).
|
|
// Failure must never block the user continuing to the plan step.
|
|
if (result.ok) {
|
|
const fam = await pbAdmin
|
|
.getOne('fams', user.famId)
|
|
.catch(() => ({ name: '', slug: user.famId }));
|
|
const parents = await pbAdmin
|
|
.getList('users', `famId = '${user.famId}' && role = 'parent'`)
|
|
.catch(() => []);
|
|
const parentEmail = (parents[0] as { email?: string } | undefined | null)?.email;
|
|
if (parentEmail) {
|
|
sendAccessUnlockedEmail({
|
|
to: parentEmail,
|
|
famName: fam.name || '',
|
|
codeName: result.code?.name || code,
|
|
codeValue: code,
|
|
dashboardUrl: `${event.url.origin}/${fam.slug || user.famId}`
|
|
}).catch(() => {});
|
|
}
|
|
}
|
|
|
|
return { ok: true, ...result };
|
|
},
|
|
|
|
// Step 4 — choose a plan, start embedded checkout.
|
|
choose: async (event: RequestEvent) => {
|
|
const user = requireUser(event);
|
|
const fd = await event.request.formData();
|
|
const plan = fd.get('plan') as PlanId;
|
|
|
|
if (!['monthly', 'yearly'].includes(plan)) {
|
|
return fail(400, { error: 'Unknown plan' });
|
|
}
|
|
|
|
const fam = await pbAdmin.getOne('fams', user.famId);
|
|
const parents = await pbAdmin.getList('users', `famId = '${user.famId}' && role = 'parent'`);
|
|
const email = (parents[0] as { email?: string } | undefined)?.email || null;
|
|
|
|
try {
|
|
const { clientSecret, sessionId } = await createEmbeddedCheckoutSession({
|
|
plan,
|
|
famId: user.famId,
|
|
famSlug: fam.slug,
|
|
email,
|
|
customerId: fam.stripeCustomerId || null,
|
|
origin: event.url.origin
|
|
});
|
|
return { success: true, clientSecret, sessionId, plan };
|
|
} catch (e) {
|
|
if (e instanceof redirect) throw e;
|
|
return fail(500, { error: e instanceof Error ? e.message : 'Failed to start checkout' });
|
|
}
|
|
}
|
|
};
|
|
|
|
function requireUser(event: RequestEvent) {
|
|
if (!event.locals.user) throw redirect(303, '/signup');
|
|
return event.locals.user;
|
|
}
|