diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 70ca977..edf7f0d 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -439,6 +439,44 @@ export type Database = { }, ] } + intake_tokens: { + Row: { + created_at: string + created_by: string | null + expires_at: string + id: string + student_id: string + token: string + used_at: string | null + } + Insert: { + created_at?: string + created_by?: string | null + expires_at: string + id?: string + student_id: string + token: string + used_at?: string | null + } + Update: { + created_at?: string + created_by?: string | null + expires_at?: string + id?: string + student_id?: string + token?: string + used_at?: string | null + } + Relationships: [ + { + foreignKeyName: "intake_tokens_student_id_fkey" + columns: ["student_id"] + isOneToOne: false + referencedRelation: "students" + referencedColumns: ["id"] + }, + ] + } ledger_entries: { Row: { amount_cents: number diff --git a/src/lib/intake.functions.ts b/src/lib/intake.functions.ts new file mode 100644 index 0000000..450c237 --- /dev/null +++ b/src/lib/intake.functions.ts @@ -0,0 +1,97 @@ +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 }; + }); diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index f245f78..708f69e 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as AuthRouteImport } from './routes/auth' import { Route as AuthenticatedRouteRouteImport } from './routes/_authenticated/route' import { Route as IndexRouteImport } from './routes/index' +import { Route as IntakeTokenRouteImport } from './routes/intake.$token' import { Route as AuthenticatedStudentsRouteImport } from './routes/_authenticated/students' import { Route as AuthenticatedMessagesRouteImport } from './routes/_authenticated/messages' import { Route as AuthenticatedLedgerRouteImport } from './routes/_authenticated/ledger' @@ -41,6 +42,11 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const IntakeTokenRoute = IntakeTokenRouteImport.update({ + id: '/intake/$token', + path: '/intake/$token', + getParentRoute: () => rootRouteImport, +} as any) const AuthenticatedStudentsRoute = AuthenticatedStudentsRouteImport.update({ id: '/students', path: '/students', @@ -127,6 +133,7 @@ export interface FileRoutesByFullPath { '/ledger': typeof AuthenticatedLedgerRoute '/messages': typeof AuthenticatedMessagesRoute '/students': typeof AuthenticatedStudentsRouteWithChildren + '/intake/$token': typeof IntakeTokenRoute '/classes/$id': typeof AuthenticatedClassesIdRoute '/students/$id': typeof AuthenticatedStudentsIdRoute '/students/new': typeof AuthenticatedStudentsNewRoute @@ -143,6 +150,7 @@ export interface FileRoutesByTo { '/forms': typeof AuthenticatedFormsRoute '/ledger': typeof AuthenticatedLedgerRoute '/messages': typeof AuthenticatedMessagesRoute + '/intake/$token': typeof IntakeTokenRoute '/classes/$id': typeof AuthenticatedClassesIdRoute '/students/$id': typeof AuthenticatedStudentsIdRoute '/students/new': typeof AuthenticatedStudentsNewRoute @@ -163,6 +171,7 @@ export interface FileRoutesById { '/_authenticated/ledger': typeof AuthenticatedLedgerRoute '/_authenticated/messages': typeof AuthenticatedMessagesRoute '/_authenticated/students': typeof AuthenticatedStudentsRouteWithChildren + '/intake/$token': typeof IntakeTokenRoute '/_authenticated/classes/$id': typeof AuthenticatedClassesIdRoute '/_authenticated/students/$id': typeof AuthenticatedStudentsIdRoute '/_authenticated/students/new': typeof AuthenticatedStudentsNewRoute @@ -183,6 +192,7 @@ export interface FileRouteTypes { | '/ledger' | '/messages' | '/students' + | '/intake/$token' | '/classes/$id' | '/students/$id' | '/students/new' @@ -199,6 +209,7 @@ export interface FileRouteTypes { | '/forms' | '/ledger' | '/messages' + | '/intake/$token' | '/classes/$id' | '/students/$id' | '/students/new' @@ -218,6 +229,7 @@ export interface FileRouteTypes { | '/_authenticated/ledger' | '/_authenticated/messages' | '/_authenticated/students' + | '/intake/$token' | '/_authenticated/classes/$id' | '/_authenticated/students/$id' | '/_authenticated/students/new' @@ -229,6 +241,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute AuthenticatedRouteRoute: typeof AuthenticatedRouteRouteWithChildren AuthRoute: typeof AuthRoute + IntakeTokenRoute: typeof IntakeTokenRoute } declare module '@tanstack/react-router' { @@ -254,6 +267,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/intake/$token': { + id: '/intake/$token' + path: '/intake/$token' + fullPath: '/intake/$token' + preLoaderRoute: typeof IntakeTokenRouteImport + parentRoute: typeof rootRouteImport + } '/_authenticated/students': { id: '/_authenticated/students' path: '/students' @@ -416,6 +436,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AuthenticatedRouteRoute: AuthenticatedRouteRouteWithChildren, AuthRoute: AuthRoute, + IntakeTokenRoute: IntakeTokenRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/_authenticated/students.$id.tsx b/src/routes/_authenticated/students.$id.tsx index f9c1be6..b60edda 100644 --- a/src/routes/_authenticated/students.$id.tsx +++ b/src/routes/_authenticated/students.$id.tsx @@ -10,10 +10,11 @@ import { Switch } from "@/components/ui/switch"; import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; -import { ArrowLeft, Trash2, Plus, FileText, Loader2, ImageUp, User, Pencil, Check, UserPlus, Copy } from "lucide-react"; +import { ArrowLeft, Trash2, Plus, FileText, Loader2, ImageUp, User, Pencil, Check, UserPlus, Copy, Link2, Mail } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; import { createUserFn } from "@/lib/user-admin.functions"; +import { createIntakeToken } from "@/lib/intake.functions"; import { genTempPassword } from "@/lib/temp-password"; import { StudentGradeReport } from "@/components/gradebook"; @@ -323,6 +324,43 @@ function FamilyTab({ studentId, canEdit, isAdmin }: { studentId: string; canEdit {isAdmin && } + {isAdmin && } + + ); +} + +function IntakeLinkSection({ studentId }: { studentId: string }) { + const { data: student } = useQuery({ + queryKey: ["student-name", studentId], + queryFn: async () => (await supabase.from("students").select("first_name, last_name").eq("id", studentId).maybeSingle()).data, + }); + const name = student ? `${student.first_name} ${student.last_name}` : "this student"; + const [link, setLink] = useState(null); + const gen = useMutation({ + mutationFn: async () => { + const { token } = await createIntakeToken({ data: { studentId } }); + return `${window.location.origin}/intake/${token}`; + }, + onSuccess: (url) => { setLink(url); toast.success("Intake link created"); }, + onError: (e: Error) => toast.error(e.message), + }); + const mailto = link + ? `mailto:?subject=${encodeURIComponent(`Student intake for ${name} — Bayside Academy`)}&body=${encodeURIComponent(`Please complete the student intake form for ${name} using this one-time link (valid 14 days):\n\n${link}\n\nThank you,\nBayside Academy`)}` + : "#"; + return ( +
+

Intake form link

Generate a one-time link a parent can use to fill out this student's intake — no login required, valid 14 days.

+ + {link && ( +
+
{link}
+
+ + +
+

Note: this link lets whoever opens it fill out {name}'s intake once. Share it only with the parent.

+
+ )}
); } diff --git a/src/routes/intake.$token.tsx b/src/routes/intake.$token.tsx new file mode 100644 index 0000000..10681dd --- /dev/null +++ b/src/routes/intake.$token.tsx @@ -0,0 +1,184 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { GraduationCap, Loader2, CheckCircle2, Plus } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { getIntakeToken, submitIntake, type IntakePayload } from "@/lib/intake.functions"; + +export const Route = createFileRoute("/intake/$token")({ + head: () => ({ meta: [{ title: "Student intake — Bayside Academy" }] }), + component: IntakePage, +}); + +const emptyGuardian = { is_primary: false, full_name: "", relationship: "", phone: "", address: "", city: "", state: "", zip: "", email: "", employer: "" }; +const emptyLogin = { website: "", student_login: "", student_password: "", parent_account: "", parent_password: "" }; +const DISMISSAL = [ + { value: "not_allowed", label: "NOT ALLOWED" }, { value: "bicycle", label: "Bicycle" }, { value: "scooter", label: "Scooter" }, + { value: "walking", label: "Walking" }, { value: "rideshare", label: "Rideshare (Uber/Lyft)" }, { value: "other", label: "Other" }, +]; + +function Field({ label, children, className = "" }: { label: string; children: React.ReactNode; className?: string }) { + return
{children}
; +} +function Section({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) { + return

{title}

{description &&

{description}

}
{children}
; +} + +// Top-level so the inputs don't remount / lose focus on each keystroke. +function GuardianFields({ g, setG }: { g: typeof emptyGuardian; setG: (v: typeof emptyGuardian) => void }) { + const f = (k: keyof typeof emptyGuardian) => (e: React.ChangeEvent) => setG({ ...g, [k]: e.target.value }); + return ( +
+ + + + + + + + + +
+ ); +} + +function IntakePage() { + const { token } = Route.useParams(); + const [done, setDone] = useState(false); + + const { data: info, isLoading } = useQuery({ + queryKey: ["intake-token", token], + queryFn: async () => getIntakeToken({ data: { token } }), + retry: false, + }); + + const [s, setS] = useState({ + dob: "", gender: "", grade_level: "", preferred_start_date: "", allergies: "", chronic_conditions: "", primary_physician: "", physician_phone: "", + home_ed_eval_due: "", previous_schools: "", special_needs: "", portfolio_keeper: "", disciplinary_history: "", custody_agreement: "", + dismissal_other: "", backup_transport_plan: "", interests: "", other_info: "", agreement_signed_by: "", agreement_signed_date: "", + }); + const set = (k: keyof typeof s) => (e: React.ChangeEvent) => setS((f) => ({ ...f, [k]: e.target.value })); + const [primary, setPrimary] = useState({ ...emptyGuardian, is_primary: true }); + const [secondary, setSecondary] = useState({ ...emptyGuardian }); + const [contacts, setContacts] = useState([{ name: "", relationship: "", phone: "", alt_phone: "" }, { name: "", relationship: "", phone: "", alt_phone: "" }]); + const [pickups, setPickups] = useState([{ name: "", relationship: "", phone: "", notes: "" }, { name: "", relationship: "", phone: "", notes: "" }, { name: "", relationship: "", phone: "", notes: "" }]); + const [logins, setLogins] = useState([{ ...emptyLogin }]); + const [dismissal, setDismissal] = useState([]); + const [agree, setAgree] = useState(false); + + const submit = useMutation({ + mutationFn: async () => { + const payload: IntakePayload = { + student: { ...s, dismissal_methods: dismissal }, + guardians: [primary, secondary], + contacts, pickups, logins, + }; + return submitIntake({ data: { token, payload } }); + }, + onSuccess: () => setDone(true), + onError: (e: Error) => toast.error(e.message), + }); + + if (isLoading) return ; + if (!info || info.status !== "ok") { + const msg = info?.status === "used" ? "This intake link has already been used." : info?.status === "expired" ? "This intake link has expired. Please ask the school for a new one." : "This intake link is not valid."; + return

Link unavailable

{msg}

; + } + if (done) return

Thank you!

{info.firstName}'s information has been submitted to Bayside Academy.

; + + return ( +
+
+

Student intake

+

For {info.firstName} {info.lastName} — Bayside Academy. Please complete and submit.

+ +
{ e.preventDefault(); submit.mutate(); }} className="space-y-5"> +
+
+ + + + +
+
+
+
+
+