Run the interactive script:
pnpm tsx scripts/create-global-sponsor.tsFollow the prompts to create your account with Global Sponsor privileges.
pnpm devNavigate to http://localhost:5173/login and log in with your Global Sponsor credentials.
Create a simple API route or use the browser console on an authenticated page:
// In browser console on authenticated page
await fetch("/api/invite-sponsor", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: "teacher@school.edu",
roleType: "SCHOOL_SPONSOR",
schoolName: "Point Loma High School",
districtName: "Point Loma Unified School District",
domains: ["psdr3.org", "student.psdr3.org"],
}),
});Or create this temporary API route at src/routes/api/invite-sponsor/+server.ts:
import { json } from "@sveltejs/kit";
import { createSponsorInvitation } from "$lib/sponsors";
import { RoleType } from "@prisma/client";
import type { RequestHandler } from "./$types";
import { canInviteSponsors } from "$lib/permissions";
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) {
return json({ error: "Unauthorized" }, { status: 401 });
}
const { email, roleType, schoolName, districtName, domains } =
await request.json();
// Check permissions
const level =
roleType === "GLOBAL_SPONSOR"
? "global"
: roleType === "DISTRICT_SPONSOR"
? "district"
: "school";
if (!canInviteSponsors(locals.user, level)) {
return json({ error: "Insufficient permissions" }, { status: 403 });
}
try {
const invitationId = await createSponsorInvitation({
email,
roleType: roleType as RoleType,
schoolName,
districtName,
domains: domains
? domains.split(",").map((d: string) => d.trim())
: undefined,
inviterId: locals.user.id,
});
return json({ success: true, invitationId });
} catch (error) {
console.error("Invitation error:", error);
return json({ error: "Failed to send invitation" }, { status: 500 });
}
};The sponsor receives an email with a link like:
http://localhost:5173/setup?token=abc123...
They click it, create an account (or log in), and complete the setup.
// In any server-side code
import { loadUserWithRoles } from "$lib/permissions";
const user = await loadUserWithRoles(userId);
console.log(user.roles);
// [
// { type: 'GLOBAL_SPONSOR', ... },
// { type: 'SCHOOL_SPONSOR', schoolId: '...' }
// ]import { grantRole } from "$lib/permissions";
import { RoleType } from "@prisma/client";
// Make someone a district sponsor
await grantRole(userId, RoleType.DISTRICT_SPONSOR, {
districtId: "district-uuid",
grantedBy: currentUser.id,
});
// Make someone a student in a cohort
await grantRole(userId, RoleType.STUDENT, {
schoolId: "school-uuid",
cohortId: "cohort-uuid",
grantedBy: currentUser.id,
});import { prisma } from "$lib/db";
// Create district
const district = await prisma.district.create({
data: {
name: "Point Loma Unified School District",
slug: "psdr3",
description: "San Diego area school district",
},
});
// Create school
const school = await prisma.school.create({
data: {
name: "Point Loma High School",
slug: "plhs",
districtId: district.id,
autoApproveStudents: false,
},
});
// Register domains
await prisma.domain.createMany({
data: [
{
domain: "psdr3.org",
districtId: district.id,
verified: true,
},
{
domain: "student.psdr3.org",
districtId: district.id,
schoolId: school.id,
verified: true,
},
],
});
// Create a cohort (graduation year)
await prisma.cohort.create({
data: {
schoolId: school.id,
year: 2025,
name: "Class of 2025",
visible: false,
lettersEnabled: false,
},
});import { findSchoolsByDomain } from "$lib/domains";
const schools = await findSchoolsByDomain("student@psdr3.org");
console.log(schools);
// [{ id: '...', name: 'Point Loma High School', ... }]// +page.server.ts
import { error } from "@sveltejs/kit";
import { canApproveUsers } from "$lib/permissions";
export const load = async ({ locals, params }) => {
if (!locals.user) {
throw error(401, "Not logged in");
}
const schoolId = params.schoolId;
if (!canApproveUsers(locals.user, schoolId)) {
throw error(403, "You cannot manage users at this school");
}
// ... rest of your code
};import { getUserSchools } from "$lib/permissions";
const schools = await getUserSchools(locals.user.id);
// Returns all schools the user has access to based on their roles// Create a user who is both a sponsor and a student
const user = await prisma.user.create({
data: {
name: "Parker Hasenkamp",
email: "parker@psdr3.org",
schoolEmail: "parker@psdr3.org",
passwordHash: await hashPassword("password"),
status: "ACTIVE",
schoolVerifiedAt: new Date(),
},
});
// Grant multiple roles
await grantRole(user.id, RoleType.GLOBAL_SPONSOR);
await grantRole(user.id, RoleType.SCHOOL_SPONSOR, { schoolId });
await grantRole(user.id, RoleType.STUDENT, { schoolId, cohortId });
// This user can:
// - Manage anything (global sponsor)
// - Participate as a student
// - See both admin controls AND student features// User is school sponsor
const user = await loadUserWithRoles(userId);
// Can they create a cohort at this school?
if (canManageCohort(user, schoolId)) {
await prisma.cohort.create({
data: {
schoolId,
year: 2026,
name: "Class of 2026",
visible: false,
},
});
}import { registerUser } from "$lib/auth";
// Student registers with school and graduation year
const result = await registerUser({
name: "John Doe",
schoolEmail: "john@student.psdr3.org",
personalEmail: "john@gmail.com",
password: "secure-password",
schoolId: "school-uuid",
cohortYear: 2026,
});
// User is created with STUDENT role automatically assigned to cohort-- In SQLite
SELECT
u.name,
u.email,
r.type as role,
d.name as district,
s.name as school,
c.year as cohort_year
FROM User u
LEFT JOIN UserRole r ON u.id = r.userId
LEFT JOIN District d ON r.districtId = d.id
LEFT JOIN School s ON r.schoolId = s.id
LEFT JOIN Cohort c ON r.cohortId = c.id
WHERE u.email = 'parker@psdr3.org';// In any component or route
import { isSchoolSponsor, canApproveUsers } from "$lib/permissions";
console.log("Is school sponsor?", isSchoolSponsor(user, schoolId));
console.log("Can approve users?", canApproveUsers(user, schoolId));
console.log("User roles:", user.roles);SELECT
email,
roleType,
accepted,
expiresAt,
createdAt
FROM SponsorInvitation
ORDER BY createdAt DESC;If you have existing users, you'll need to migrate them. Here's a basic script:
// scripts/migrate-users.ts
import { prisma } from "../src/lib/db";
import { RoleType } from "@prisma/client";
async function migrate() {
const oldUsers = await prisma.$queryRaw`
SELECT * FROM User WHERE role = 'ADMIN'
`;
for (const oldUser of oldUsers) {
// Create student role with cohort
// (Assuming old schema had graduationYear)
const cohort = await prisma.cohort.findFirst({
where: {
year: oldUser.graduationYear,
// Find school by domain somehow
},
});
if (cohort) {
await prisma.userRole.create({
data: {
userId: oldUser.id,
type: RoleType.STUDENT,
schoolId: cohort.schoolId,
cohortId: cohort.id,
},
});
}
}
}- Build Admin UI - Create the sponsor dashboard
- Update Registration - Integrate domain detection
- Cohort Management - UI for creating and managing cohorts
- Student Profiles - Link to cohorts and visibility
- Letters System - Integrate with cohorts and permissions
You now have a fully functional multi-role sponsor system! The foundation is solid and ready for UI development.