- intake_tokens table (server-only via service role) - Server functions: createIntakeToken (admin), getIntakeToken (public validate), submitIntake (public write + mark used, 14-day one-time tokens) - Public /intake/$token full intake form (no login) with valid/used/expired states - Student profile (admin): generate link, copy, and email-to-parent (mailto) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
98 lines
7.3 KiB
TypeScript
98 lines
7.3 KiB
TypeScript
import { createServerFn } from "@tanstack/react-start";
|
|
import { requireSupabaseAuth } from "@/integrations/supabase/auth-middleware";
|
|
|
|
export type IntakePayload = {
|
|
student: {
|
|
dob: string; gender: string; grade_level: string; preferred_start_date: string;
|
|
allergies: string; chronic_conditions: string; primary_physician: string; physician_phone: string;
|
|
home_ed_eval_due: string; previous_schools: string; special_needs: string; portfolio_keeper: string;
|
|
disciplinary_history: string; custody_agreement: string;
|
|
dismissal_methods: string[]; dismissal_other: string; backup_transport_plan: string;
|
|
interests: string; other_info: string; agreement_signed_by: string; agreement_signed_date: string;
|
|
};
|
|
guardians: { is_primary: boolean; full_name: string; relationship: string; phone: string; address: string; city: string; state: string; zip: string; email: string; employer: string }[];
|
|
contacts: { name: string; relationship: string; phone: string; alt_phone: string }[];
|
|
pickups: { name: string; relationship: string; phone: string; notes: string }[];
|
|
logins: { website: string; student_login: string; student_password: string; parent_account: string; parent_password: string }[];
|
|
};
|
|
|
|
const orNull = (v: string) => (v && v.trim() ? v.trim() : null);
|
|
|
|
// ── Admin: create a one-time intake link for a student ────────────────────────
|
|
export const createIntakeToken = createServerFn({ method: "POST" })
|
|
.middleware([requireSupabaseAuth])
|
|
.validator((d: { studentId: string }) => d)
|
|
.handler(async ({ data, context }) => {
|
|
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
|
|
const callerId = (context as { userId: string }).userId;
|
|
const { data: adminRow } = await supabaseAdmin.from("user_roles").select("role").eq("user_id", callerId).eq("role", "admin").maybeSingle();
|
|
if (!adminRow) throw new Error("Only admins can create intake links.");
|
|
|
|
const token = crypto.randomUUID().replace(/-/g, "") + crypto.randomUUID().replace(/-/g, "");
|
|
const expires_at = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString();
|
|
const { error } = await supabaseAdmin.from("intake_tokens").insert({ token, student_id: data.studentId, created_by: callerId, expires_at });
|
|
if (error) throw new Error(error.message);
|
|
return { token };
|
|
});
|
|
|
|
// ── Public: validate a token, return the student's name for the form ──────────
|
|
export const getIntakeToken = createServerFn({ method: "POST" })
|
|
.validator((d: { token: string }) => d)
|
|
.handler(async ({ data }) => {
|
|
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
|
|
const { data: row } = await supabaseAdmin.from("intake_tokens").select("student_id, expires_at, used_at").eq("token", data.token).maybeSingle();
|
|
if (!row) return { status: "invalid" as const };
|
|
if (row.used_at) return { status: "used" as const };
|
|
if (new Date(row.expires_at) < new Date()) return { status: "expired" as const };
|
|
const { data: student } = await supabaseAdmin.from("students").select("first_name, last_name").eq("id", row.student_id).maybeSingle();
|
|
return { status: "ok" as const, firstName: student?.first_name ?? "", lastName: student?.last_name ?? "" };
|
|
});
|
|
|
|
// ── Public: submit the intake, write to the student's record, mark used ───────
|
|
export const submitIntake = createServerFn({ method: "POST" })
|
|
.validator((d: { token: string; payload: IntakePayload }) => d)
|
|
.handler(async ({ data }) => {
|
|
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
|
|
const { data: row } = await supabaseAdmin.from("intake_tokens").select("id, student_id, expires_at, used_at").eq("token", data.token).maybeSingle();
|
|
if (!row) throw new Error("This link is not valid.");
|
|
if (row.used_at) throw new Error("This link has already been used.");
|
|
if (new Date(row.expires_at) < new Date()) throw new Error("This link has expired.");
|
|
|
|
const sid = row.student_id;
|
|
const s = data.payload.student;
|
|
const { error: e1 } = await supabaseAdmin.from("students").update({
|
|
dob: orNull(s.dob), gender: orNull(s.gender), grade_level: orNull(s.grade_level), preferred_start_date: orNull(s.preferred_start_date),
|
|
allergies: orNull(s.allergies), chronic_conditions: orNull(s.chronic_conditions), primary_physician: orNull(s.primary_physician), physician_phone: orNull(s.physician_phone),
|
|
home_ed_eval_due: orNull(s.home_ed_eval_due), previous_schools: orNull(s.previous_schools), special_needs: orNull(s.special_needs), portfolio_keeper: orNull(s.portfolio_keeper),
|
|
disciplinary_history: orNull(s.disciplinary_history), custody_agreement: orNull(s.custody_agreement),
|
|
dismissal_methods: s.dismissal_methods ?? [], dismissal_other: orNull(s.dismissal_other), backup_transport_plan: orNull(s.backup_transport_plan),
|
|
interests: orNull(s.interests), other_info: orNull(s.other_info), agreement_signed_by: orNull(s.agreement_signed_by), agreement_signed_date: orNull(s.agreement_signed_date),
|
|
}).eq("id", sid);
|
|
if (e1) throw new Error(e1.message);
|
|
|
|
// Replace collections with what was submitted (initial intake is the source of truth here).
|
|
await supabaseAdmin.from("student_guardians").delete().eq("student_id", sid);
|
|
await supabaseAdmin.from("authorized_pickups").delete().eq("student_id", sid);
|
|
await supabaseAdmin.from("student_curriculum_logins").delete().eq("student_id", sid);
|
|
|
|
const guardianRows = data.payload.guardians.filter((g) => g.full_name.trim()).map((g) => ({
|
|
student_id: sid, is_primary: g.is_primary, full_name: g.full_name.trim(), relationship: orNull(g.relationship), phone: orNull(g.phone),
|
|
address: orNull(g.address), city: orNull(g.city), state: orNull(g.state), zip: orNull(g.zip), email: orNull(g.email), employer: orNull(g.employer),
|
|
}));
|
|
if (guardianRows.length) { const { error } = await supabaseAdmin.from("student_guardians").insert(guardianRows); if (error) throw new Error(error.message); }
|
|
|
|
const pickupRows = [
|
|
...data.payload.pickups.filter((p) => p.name.trim()).map((p, i) => ({ student_id: sid, kind: "pickup", sort_order: i, name: p.name.trim(), relationship: orNull(p.relationship), phone: orNull(p.phone), alt_phone: null as string | null, notes: orNull(p.notes) })),
|
|
...data.payload.contacts.filter((c) => c.name.trim()).map((c, i) => ({ student_id: sid, kind: "emergency", sort_order: i, name: c.name.trim(), relationship: orNull(c.relationship), phone: orNull(c.phone), alt_phone: orNull(c.alt_phone), notes: null as string | null })),
|
|
];
|
|
if (pickupRows.length) { const { error } = await supabaseAdmin.from("authorized_pickups").insert(pickupRows); if (error) throw new Error(error.message); }
|
|
|
|
const loginRows = data.payload.logins.filter((l) => Object.values(l).some((v) => v.trim())).map((l) => ({
|
|
student_id: sid, website: orNull(l.website), student_login: orNull(l.student_login), student_password: orNull(l.student_password), parent_account: orNull(l.parent_account), parent_password: orNull(l.parent_password),
|
|
}));
|
|
if (loginRows.length) { const { error } = await supabaseAdmin.from("student_curriculum_logins").insert(loginRows); if (error) throw new Error(error.message); }
|
|
|
|
await supabaseAdmin.from("intake_tokens").update({ used_at: new Date().toISOString() }).eq("id", row.id);
|
|
return { ok: true };
|
|
});
|