feat(family): implement parent-to-parent family code sharing (Phase 2)

Add family code system allowing multiple parents to access the same child's
practice data. This is the foundation for the Suzuki Triangle model where
both parents can supervise daily practice.

Key features:
- Family codes (FAM-XXXXXX) per student for sharing access
- FamilyCodeDisplay modal for viewing/copying/regenerating codes
- LinkChildForm modal for linking to existing child via code
- parent_child junction table for many-to-many relationships
- React Query mutations with proper cache invalidation

API routes:
- GET/POST /api/family/children/[playerId]/code - manage family codes
- POST /api/family/link - link to child via family code
- GET /api/family/children - list linked children

Also includes classroom system foundation (Phase 1):
- Classroom, enrollment, and presence schemas
- Teacher classroom management APIs
- Student enrollment request workflow
- Real-time presence tracking infrastructure

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-12-22 12:41:20 -06:00
parent dfc2627ccb
commit 02842270c9
48 changed files with 6167 additions and 45 deletions

View File

@@ -0,0 +1,130 @@
-- Custom SQL migration file, put your code below! --
-- Classroom system: parent-child relationships, classrooms, enrollments, presence
-- ============================================================================
-- 1. Add family_code to players table
-- ============================================================================
ALTER TABLE `players` ADD `family_code` text;
--> statement-breakpoint
CREATE UNIQUE INDEX `players_family_code_unique` ON `players` (`family_code`);
--> statement-breakpoint
-- ============================================================================
-- 2. Add pause fields to session_plans table
-- ============================================================================
ALTER TABLE `session_plans` ADD `paused_at` integer;
--> statement-breakpoint
ALTER TABLE `session_plans` ADD `paused_by` text;
--> statement-breakpoint
ALTER TABLE `session_plans` ADD `paused_reason` text;
--> statement-breakpoint
-- ============================================================================
-- 3. Create parent_child table (many-to-many family relationships)
-- ============================================================================
CREATE TABLE `parent_child` (
`parent_user_id` text NOT NULL REFERENCES `users`(`id`) ON DELETE CASCADE,
`child_player_id` text NOT NULL REFERENCES `players`(`id`) ON DELETE CASCADE,
`linked_at` integer NOT NULL DEFAULT (unixepoch()),
PRIMARY KEY (`parent_user_id`, `child_player_id`)
);
--> statement-breakpoint
-- ============================================================================
-- 4. Create classrooms table (one per teacher)
-- ============================================================================
CREATE TABLE `classrooms` (
`id` text PRIMARY KEY NOT NULL,
`teacher_id` text NOT NULL UNIQUE REFERENCES `users`(`id`) ON DELETE CASCADE,
`name` text NOT NULL,
`code` text NOT NULL UNIQUE,
`created_at` integer NOT NULL DEFAULT (unixepoch())
);
--> statement-breakpoint
CREATE INDEX `classrooms_code_idx` ON `classrooms` (`code`);
--> statement-breakpoint
-- ============================================================================
-- 5. Create classroom_enrollments table (persistent student roster)
-- ============================================================================
CREATE TABLE `classroom_enrollments` (
`id` text PRIMARY KEY NOT NULL,
`classroom_id` text NOT NULL REFERENCES `classrooms`(`id`) ON DELETE CASCADE,
`player_id` text NOT NULL REFERENCES `players`(`id`) ON DELETE CASCADE,
`enrolled_at` integer NOT NULL DEFAULT (unixepoch())
);
--> statement-breakpoint
CREATE UNIQUE INDEX `idx_enrollments_classroom_player` ON `classroom_enrollments` (`classroom_id`, `player_id`);
--> statement-breakpoint
CREATE INDEX `idx_enrollments_classroom` ON `classroom_enrollments` (`classroom_id`);
--> statement-breakpoint
CREATE INDEX `idx_enrollments_player` ON `classroom_enrollments` (`player_id`);
--> statement-breakpoint
-- ============================================================================
-- 6. Create enrollment_requests table (consent workflow)
-- ============================================================================
CREATE TABLE `enrollment_requests` (
`id` text PRIMARY KEY NOT NULL,
`classroom_id` text NOT NULL REFERENCES `classrooms`(`id`) ON DELETE CASCADE,
`player_id` text NOT NULL REFERENCES `players`(`id`) ON DELETE CASCADE,
`requested_by` text NOT NULL REFERENCES `users`(`id`) ON DELETE CASCADE,
`requested_by_role` text NOT NULL,
`requested_at` integer NOT NULL DEFAULT (unixepoch()),
`status` text NOT NULL DEFAULT 'pending',
`teacher_approval` text,
`teacher_approved_at` integer,
`parent_approval` text,
`parent_approved_by` text REFERENCES `users`(`id`),
`parent_approved_at` integer,
`resolved_at` integer
);
--> statement-breakpoint
CREATE UNIQUE INDEX `idx_enrollment_requests_classroom_player` ON `enrollment_requests` (`classroom_id`, `player_id`);
--> statement-breakpoint
CREATE INDEX `idx_enrollment_requests_classroom` ON `enrollment_requests` (`classroom_id`);
--> statement-breakpoint
CREATE INDEX `idx_enrollment_requests_player` ON `enrollment_requests` (`player_id`);
--> statement-breakpoint
CREATE INDEX `idx_enrollment_requests_status` ON `enrollment_requests` (`status`);
--> statement-breakpoint
-- ============================================================================
-- 7. Create classroom_presence table (ephemeral "in classroom" state)
-- ============================================================================
CREATE TABLE `classroom_presence` (
`player_id` text PRIMARY KEY NOT NULL REFERENCES `players`(`id`) ON DELETE CASCADE,
`classroom_id` text NOT NULL REFERENCES `classrooms`(`id`) ON DELETE CASCADE,
`entered_at` integer NOT NULL DEFAULT (unixepoch()),
`entered_by` text NOT NULL REFERENCES `users`(`id`)
);
--> statement-breakpoint
CREATE INDEX `idx_presence_classroom` ON `classroom_presence` (`classroom_id`);
--> statement-breakpoint
-- ============================================================================
-- 8. Data migration: Create parent_child entries from existing players
-- ============================================================================
-- For each existing player, create a parent_child relationship with the creator
INSERT INTO `parent_child` (`parent_user_id`, `child_player_id`, `linked_at`)
SELECT `user_id`, `id`, `created_at` FROM `players`;

View File

@@ -0,0 +1,25 @@
-- Custom SQL migration file, put your code below! --
-- Add missing indexes and generate family codes
-- ============================================================================
-- 1. Add missing indexes to parent_child table
-- ============================================================================
CREATE INDEX `idx_parent_child_parent` ON `parent_child` (`parent_user_id`);
--> statement-breakpoint
CREATE INDEX `idx_parent_child_child` ON `parent_child` (`child_player_id`);
--> statement-breakpoint
-- ============================================================================
-- 2. Generate family codes for existing players
-- ============================================================================
-- SQLite doesn't have built-in random string generation, so we use a combination
-- of hex(randomblob()) to create unique codes, then format them.
-- Format: FAM-XXXXXX where X is alphanumeric
-- The uniqueness constraint on family_code will ensure no collisions.
UPDATE `players`
SET `family_code` = 'FAM-' || UPPER(SUBSTR(HEX(RANDOMBLOB(3)), 1, 6))
WHERE `family_code` IS NULL;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,37 @@
import { type NextRequest, NextResponse } from 'next/server'
import { approveEnrollmentRequest, getTeacherClassroom } from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
interface RouteParams {
params: Promise<{ classroomId: string; requestId: string }>
}
/**
* POST /api/classrooms/[classroomId]/enrollment-requests/[requestId]/approve
* Teacher approves enrollment request
*
* Returns: { request, enrolled: boolean }
*/
export async function POST(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId, requestId } = await params
const viewerId = await getViewerId()
// Verify user is the teacher of this classroom
const classroom = await getTeacherClassroom(viewerId)
if (!classroom || classroom.id !== classroomId) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
const result = await approveEnrollmentRequest(requestId, viewerId, 'teacher')
return NextResponse.json({
request: result.request,
enrolled: result.fullyApproved,
})
} catch (error) {
console.error('Failed to approve enrollment request:', error)
const message = error instanceof Error ? error.message : 'Failed to approve enrollment request'
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@@ -0,0 +1,34 @@
import { type NextRequest, NextResponse } from 'next/server'
import { denyEnrollmentRequest, getTeacherClassroom } from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
interface RouteParams {
params: Promise<{ classroomId: string; requestId: string }>
}
/**
* POST /api/classrooms/[classroomId]/enrollment-requests/[requestId]/deny
* Teacher denies enrollment request
*
* Returns: { request }
*/
export async function POST(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId, requestId } = await params
const viewerId = await getViewerId()
// Verify user is the teacher of this classroom
const classroom = await getTeacherClassroom(viewerId)
if (!classroom || classroom.id !== classroomId) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
const request = await denyEnrollmentRequest(requestId, viewerId, 'teacher')
return NextResponse.json({ request })
} catch (error) {
console.error('Failed to deny enrollment request:', error)
const message = error instanceof Error ? error.message : 'Failed to deny enrollment request'
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@@ -0,0 +1,83 @@
import { type NextRequest, NextResponse } from 'next/server'
import {
createEnrollmentRequest,
getPendingRequestsForClassroom,
getTeacherClassroom,
isParent,
} from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
interface RouteParams {
params: Promise<{ classroomId: string }>
}
/**
* GET /api/classrooms/[classroomId]/enrollment-requests
* Get pending enrollment requests (teacher only)
*
* Returns: { requests: EnrollmentRequest[] }
*/
export async function GET(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId } = await params
const viewerId = await getViewerId()
// Verify user is the teacher of this classroom
const classroom = await getTeacherClassroom(viewerId)
if (!classroom || classroom.id !== classroomId) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
const requests = await getPendingRequestsForClassroom(classroomId)
return NextResponse.json({ requests })
} catch (error) {
console.error('Failed to fetch enrollment requests:', error)
return NextResponse.json({ error: 'Failed to fetch enrollment requests' }, { status: 500 })
}
}
/**
* POST /api/classrooms/[classroomId]/enrollment-requests
* Create enrollment request (parent or teacher)
*
* Body: { playerId: string }
* Returns: { request }
*/
export async function POST(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId } = await params
const viewerId = await getViewerId()
const body = await req.json()
if (!body.playerId) {
return NextResponse.json({ error: 'Missing playerId' }, { status: 400 })
}
// Determine role: is user the teacher or a parent?
const classroom = await getTeacherClassroom(viewerId)
const isTeacher = classroom?.id === classroomId
const parentCheck = await isParent(viewerId, body.playerId)
if (!isTeacher && !parentCheck) {
return NextResponse.json(
{ error: 'Must be the classroom teacher or a parent of the student' },
{ status: 403 }
)
}
const requestedByRole = isTeacher ? 'teacher' : 'parent'
const request = await createEnrollmentRequest({
classroomId,
playerId: body.playerId,
requestedBy: viewerId,
requestedByRole,
})
return NextResponse.json({ request }, { status: 201 })
} catch (error) {
console.error('Failed to create enrollment request:', error)
return NextResponse.json({ error: 'Failed to create enrollment request' }, { status: 500 })
}
}

View File

@@ -0,0 +1,39 @@
import { type NextRequest, NextResponse } from 'next/server'
import { unenrollStudent, getTeacherClassroom, isParent } from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
interface RouteParams {
params: Promise<{ classroomId: string; playerId: string }>
}
/**
* DELETE /api/classrooms/[classroomId]/enrollments/[playerId]
* Unenroll student from classroom (teacher or parent)
*
* Returns: { success: true }
*/
export async function DELETE(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId, playerId } = await params
const viewerId = await getViewerId()
// Check authorization: must be teacher of classroom OR parent of student
const classroom = await getTeacherClassroom(viewerId)
const isTeacher = classroom?.id === classroomId
const parentCheck = await isParent(viewerId, playerId)
if (!isTeacher && !parentCheck) {
return NextResponse.json(
{ error: 'Must be the classroom teacher or a parent of the student' },
{ status: 403 }
)
}
await unenrollStudent(classroomId, playerId)
return NextResponse.json({ success: true })
} catch (error) {
console.error('Failed to unenroll student:', error)
return NextResponse.json({ error: 'Failed to unenroll student' }, { status: 500 })
}
}

View File

@@ -0,0 +1,33 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getEnrolledStudents, getTeacherClassroom } from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
interface RouteParams {
params: Promise<{ classroomId: string }>
}
/**
* GET /api/classrooms/[classroomId]/enrollments
* Get all enrolled students (teacher only)
*
* Returns: { students: Player[] }
*/
export async function GET(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId } = await params
const viewerId = await getViewerId()
// Verify user is the teacher of this classroom
const classroom = await getTeacherClassroom(viewerId)
if (!classroom || classroom.id !== classroomId) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
const students = await getEnrolledStudents(classroomId)
return NextResponse.json({ students })
} catch (error) {
console.error('Failed to fetch enrolled students:', error)
return NextResponse.json({ error: 'Failed to fetch enrolled students' }, { status: 500 })
}
}

View File

@@ -0,0 +1,39 @@
import { type NextRequest, NextResponse } from 'next/server'
import { leaveSpecificClassroom, getTeacherClassroom, isParent } from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
interface RouteParams {
params: Promise<{ classroomId: string; playerId: string }>
}
/**
* DELETE /api/classrooms/[classroomId]/presence/[playerId]
* Remove student from classroom (teacher or parent)
*
* Returns: { success: true }
*/
export async function DELETE(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId, playerId } = await params
const viewerId = await getViewerId()
// Check authorization: must be teacher of classroom OR parent of student
const classroom = await getTeacherClassroom(viewerId)
const isTeacher = classroom?.id === classroomId
const parentCheck = await isParent(viewerId, playerId)
if (!isTeacher && !parentCheck) {
return NextResponse.json(
{ error: 'Must be the classroom teacher or a parent of the student' },
{ status: 403 }
)
}
await leaveSpecificClassroom(playerId, classroomId)
return NextResponse.json({ success: true })
} catch (error) {
console.error('Failed to remove student from classroom:', error)
return NextResponse.json({ error: 'Failed to remove student from classroom' }, { status: 500 })
}
}

View File

@@ -0,0 +1,94 @@
import { type NextRequest, NextResponse } from 'next/server'
import {
enterClassroom,
getClassroomPresence,
getTeacherClassroom,
isParent,
} from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
interface RouteParams {
params: Promise<{ classroomId: string }>
}
/**
* GET /api/classrooms/[classroomId]/presence
* Get all students currently present in classroom (teacher only)
*
* Returns: { students: Player[] }
*/
export async function GET(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId } = await params
const viewerId = await getViewerId()
// Verify user is the teacher of this classroom
const classroom = await getTeacherClassroom(viewerId)
if (!classroom || classroom.id !== classroomId) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
const presences = await getClassroomPresence(classroomId)
// Return players with presence info
return NextResponse.json({
students: presences.map((p) => ({
...p.player,
enteredAt: p.enteredAt,
enteredBy: p.enteredBy,
})),
})
} catch (error) {
console.error('Failed to fetch classroom presence:', error)
return NextResponse.json({ error: 'Failed to fetch classroom presence' }, { status: 500 })
}
}
/**
* POST /api/classrooms/[classroomId]/presence
* Enter student into classroom (teacher or parent)
*
* Body: { playerId: string }
* Returns: { success: true, presence } or { success: false, error }
*/
export async function POST(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId } = await params
const viewerId = await getViewerId()
const body = await req.json()
if (!body.playerId) {
return NextResponse.json({ success: false, error: 'Missing playerId' }, { status: 400 })
}
// Check authorization: must be teacher of classroom OR parent of student
const classroom = await getTeacherClassroom(viewerId)
const isTeacher = classroom?.id === classroomId
const parentCheck = await isParent(viewerId, body.playerId)
if (!isTeacher && !parentCheck) {
return NextResponse.json(
{ success: false, error: 'Must be the classroom teacher or a parent of the student' },
{ status: 403 }
)
}
const result = await enterClassroom({
playerId: body.playerId,
classroomId,
enteredBy: viewerId,
})
if (!result.success) {
return NextResponse.json({ success: false, error: result.error }, { status: 400 })
}
return NextResponse.json({ success: true, presence: result.presence })
} catch (error) {
console.error('Failed to enter classroom:', error)
return NextResponse.json(
{ success: false, error: 'Failed to enter classroom' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,107 @@
import { type NextRequest, NextResponse } from 'next/server'
import {
deleteClassroom,
getClassroom,
updateClassroom,
regenerateClassroomCode,
} from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
interface RouteParams {
params: Promise<{ classroomId: string }>
}
/**
* GET /api/classrooms/[classroomId]
* Get classroom by ID
*
* Returns: { classroom } or 404
*/
export async function GET(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId } = await params
const classroom = await getClassroom(classroomId)
if (!classroom) {
return NextResponse.json({ error: 'Classroom not found' }, { status: 404 })
}
return NextResponse.json({ classroom })
} catch (error) {
console.error('Failed to fetch classroom:', error)
return NextResponse.json({ error: 'Failed to fetch classroom' }, { status: 500 })
}
}
/**
* PATCH /api/classrooms/[classroomId]
* Update classroom settings (teacher only)
*
* Body: { name?: string, regenerateCode?: boolean }
* Returns: { classroom }
*/
export async function PATCH(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId } = await params
const viewerId = await getViewerId()
const body = await req.json()
// Handle code regeneration separately
if (body.regenerateCode) {
const newCode = await regenerateClassroomCode(classroomId, viewerId)
if (!newCode) {
return NextResponse.json(
{ error: 'Not authorized or classroom not found' },
{ status: 403 }
)
}
// Fetch updated classroom
const classroom = await getClassroom(classroomId)
return NextResponse.json({ classroom })
}
// Update other fields
const updates: { name?: string } = {}
if (body.name) updates.name = body.name
if (Object.keys(updates).length === 0) {
return NextResponse.json({ error: 'No valid updates provided' }, { status: 400 })
}
const classroom = await updateClassroom(classroomId, viewerId, updates)
if (!classroom) {
return NextResponse.json({ error: 'Not authorized or classroom not found' }, { status: 403 })
}
return NextResponse.json({ classroom })
} catch (error) {
console.error('Failed to update classroom:', error)
return NextResponse.json({ error: 'Failed to update classroom' }, { status: 500 })
}
}
/**
* DELETE /api/classrooms/[classroomId]
* Delete classroom (teacher only, cascades enrollments)
*
* Returns: { success: true }
*/
export async function DELETE(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId } = await params
const viewerId = await getViewerId()
const success = await deleteClassroom(classroomId, viewerId)
if (!success) {
return NextResponse.json({ error: 'Not authorized or classroom not found' }, { status: 403 })
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('Failed to delete classroom:', error)
return NextResponse.json({ error: 'Failed to delete classroom' }, { status: 500 })
}
}

View File

@@ -0,0 +1,43 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getClassroomByCode } from '@/lib/classroom'
interface RouteParams {
params: Promise<{ code: string }>
}
/**
* GET /api/classrooms/code/[code]
* Look up classroom by join code
*
* Returns: { classroom, teacher } or 404
*/
export async function GET(req: NextRequest, { params }: RouteParams) {
try {
const { code } = await params
const classroom = await getClassroomByCode(code)
if (!classroom) {
return NextResponse.json({ error: 'Classroom not found' }, { status: 404 })
}
return NextResponse.json({
classroom: {
id: classroom.id,
name: classroom.name,
code: classroom.code,
createdAt: classroom.createdAt,
},
teacher: classroom.teacher
? {
id: classroom.teacher.id,
name: classroom.teacher.name,
image: classroom.teacher.image,
}
: null,
})
} catch (error) {
console.error('Failed to lookup classroom:', error)
return NextResponse.json({ error: 'Failed to lookup classroom' }, { status: 500 })
}
}

View File

@@ -0,0 +1,26 @@
import { NextResponse } from 'next/server'
import { getTeacherClassroom } from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
/**
* GET /api/classrooms/mine
* Get current user's classroom (if teacher)
*
* Returns: { classroom } or 404
*/
export async function GET() {
try {
const viewerId = await getViewerId()
const classroom = await getTeacherClassroom(viewerId)
if (!classroom) {
return NextResponse.json({ error: 'No classroom found' }, { status: 404 })
}
return NextResponse.json({ classroom })
} catch (error) {
console.error('Failed to fetch classroom:', error)
return NextResponse.json({ error: 'Failed to fetch classroom' }, { status: 500 })
}
}

View File

@@ -0,0 +1,57 @@
import { type NextRequest, NextResponse } from 'next/server'
import { createClassroom, getTeacherClassroom } from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
/**
* GET /api/classrooms
* Get current user's classroom (alias for /api/classrooms/mine)
*
* Returns: { classroom } or { classroom: null }
*/
export async function GET() {
try {
const viewerId = await getViewerId()
const classroom = await getTeacherClassroom(viewerId)
return NextResponse.json({ classroom })
} catch (error) {
console.error('Failed to fetch classroom:', error)
return NextResponse.json({ error: 'Failed to fetch classroom' }, { status: 500 })
}
}
/**
* POST /api/classrooms
* Create a classroom for current user (becomes teacher)
*
* Body: { name: string }
* Returns: { success: true, classroom } or { success: false, error }
*/
export async function POST(req: NextRequest) {
try {
const viewerId = await getViewerId()
const body = await req.json()
if (!body.name) {
return NextResponse.json({ success: false, error: 'Missing name' }, { status: 400 })
}
const result = await createClassroom({
teacherId: viewerId,
name: body.name,
})
if (!result.success) {
return NextResponse.json({ success: false, error: result.error }, { status: 400 })
}
return NextResponse.json({ success: true, classroom: result.classroom }, { status: 201 })
} catch (error) {
console.error('Failed to create classroom:', error)
return NextResponse.json(
{ success: false, error: 'Failed to create classroom' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,49 @@
import { type NextRequest, NextResponse } from 'next/server'
import { approveEnrollmentRequest, isParent } from '@/lib/classroom'
import { db } from '@/db'
import { enrollmentRequests } from '@/db/schema'
import { eq } from 'drizzle-orm'
import { getViewerId } from '@/lib/viewer'
interface RouteParams {
params: Promise<{ requestId: string }>
}
/**
* POST /api/enrollment-requests/[requestId]/approve
* Parent approves enrollment request
*
* Returns: { request, enrolled: boolean }
*/
export async function POST(req: NextRequest, { params }: RouteParams) {
try {
const { requestId } = await params
const viewerId = await getViewerId()
// Get the request to verify parent owns the child
const request = await db.query.enrollmentRequests.findFirst({
where: eq(enrollmentRequests.id, requestId),
})
if (!request) {
return NextResponse.json({ error: 'Request not found' }, { status: 404 })
}
// Verify user is a parent of the child in the request
const parentCheck = await isParent(viewerId, request.playerId)
if (!parentCheck) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
const result = await approveEnrollmentRequest(requestId, viewerId, 'parent')
return NextResponse.json({
request: result.request,
enrolled: result.fullyApproved,
})
} catch (error) {
console.error('Failed to approve enrollment request:', error)
const message = error instanceof Error ? error.message : 'Failed to approve enrollment request'
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@@ -0,0 +1,46 @@
import { type NextRequest, NextResponse } from 'next/server'
import { denyEnrollmentRequest, isParent } from '@/lib/classroom'
import { db } from '@/db'
import { enrollmentRequests } from '@/db/schema'
import { eq } from 'drizzle-orm'
import { getViewerId } from '@/lib/viewer'
interface RouteParams {
params: Promise<{ requestId: string }>
}
/**
* POST /api/enrollment-requests/[requestId]/deny
* Parent denies enrollment request
*
* Returns: { request }
*/
export async function POST(req: NextRequest, { params }: RouteParams) {
try {
const { requestId } = await params
const viewerId = await getViewerId()
// Get the request to verify parent owns the child
const request = await db.query.enrollmentRequests.findFirst({
where: eq(enrollmentRequests.id, requestId),
})
if (!request) {
return NextResponse.json({ error: 'Request not found' }, { status: 404 })
}
// Verify user is a parent of the child in the request
const parentCheck = await isParent(viewerId, request.playerId)
if (!parentCheck) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
const updatedRequest = await denyEnrollmentRequest(requestId, viewerId, 'parent')
return NextResponse.json({ request: updatedRequest })
} catch (error) {
console.error('Failed to deny enrollment request:', error)
const message = error instanceof Error ? error.message : 'Failed to deny enrollment request'
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server'
import { getPendingRequestsForParent } from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
/**
* GET /api/enrollment-requests/pending
* Get enrollment requests pending current user's approval as parent
*
* Returns: { requests: EnrollmentRequest[] }
*/
export async function GET() {
try {
const viewerId = await getViewerId()
const requests = await getPendingRequestsForParent(viewerId)
return NextResponse.json({ requests })
} catch (error) {
console.error('Failed to fetch pending enrollment requests:', error)
return NextResponse.json(
{ error: 'Failed to fetch pending enrollment requests' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,91 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { getOrCreateFamilyCode, isParent, regenerateFamilyCode } from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
interface RouteParams {
params: Promise<{ playerId: string }>
}
/**
* Resolve viewerId (guestId) to actual user.id
*/
async function getUserId(viewerId: string): Promise<string | null> {
const user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
return user?.id ?? null
}
/**
* GET /api/family/children/[playerId]/code
* Get family code for a child (must be parent)
*
* Returns: { familyCode: string }
*/
export async function GET(req: NextRequest, { params }: RouteParams) {
try {
const { playerId } = await params
const viewerId = await getViewerId()
// Resolve viewerId to actual user.id
const userId = await getUserId(viewerId)
if (!userId) {
return NextResponse.json({ error: 'User not found' }, { status: 401 })
}
// Verify user is a parent of this child
const parentCheck = await isParent(userId, playerId)
if (!parentCheck) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
const familyCode = await getOrCreateFamilyCode(playerId)
if (!familyCode) {
return NextResponse.json({ error: 'Player not found' }, { status: 404 })
}
return NextResponse.json({ familyCode })
} catch (error) {
console.error('Failed to get family code:', error)
return NextResponse.json({ error: 'Failed to get family code' }, { status: 500 })
}
}
/**
* POST /api/family/children/[playerId]/code
* Regenerate family code for a child (invalidates old code)
*
* Returns: { familyCode: string }
*/
export async function POST(req: NextRequest, { params }: RouteParams) {
try {
const { playerId } = await params
const viewerId = await getViewerId()
// Resolve viewerId to actual user.id
const userId = await getUserId(viewerId)
if (!userId) {
return NextResponse.json({ error: 'User not found' }, { status: 401 })
}
// Verify user is a parent of this child
const parentCheck = await isParent(userId, playerId)
if (!parentCheck) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
const familyCode = await regenerateFamilyCode(playerId)
if (!familyCode) {
return NextResponse.json({ error: 'Player not found' }, { status: 404 })
}
return NextResponse.json({ familyCode })
} catch (error) {
console.error('Failed to regenerate family code:', error)
return NextResponse.json({ error: 'Failed to regenerate family code' }, { status: 500 })
}
}

View File

@@ -0,0 +1,33 @@
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { getLinkedChildren } from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
/**
* GET /api/family/children
* Get all children linked to current user
*
* Returns: { children: Player[] }
*/
export async function GET() {
try {
const viewerId = await getViewerId()
// Resolve viewerId to actual user.id
const user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
return NextResponse.json({ children: [] })
}
const children = await getLinkedChildren(user.id)
return NextResponse.json({ children })
} catch (error) {
console.error('Failed to fetch children:', error)
return NextResponse.json({ error: 'Failed to fetch children' }, { status: 500 })
}
}

View File

@@ -0,0 +1,59 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { linkParentToChild } from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
/**
* Get or create user record for a viewerId (guestId)
*/
async function getOrCreateUser(viewerId: string) {
let user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
const [newUser] = await db
.insert(schema.users)
.values({ guestId: viewerId })
.returning()
user = newUser
}
return user
}
/**
* POST /api/family/link
* Link current user as parent to a child via family code
*
* Body: { familyCode: string }
* Returns: { success: true, player } or { success: false, error }
*/
export async function POST(req: NextRequest) {
try {
const viewerId = await getViewerId()
const body = await req.json()
if (!body.familyCode) {
return NextResponse.json({ success: false, error: 'Missing familyCode' }, { status: 400 })
}
// Resolve viewerId to actual user.id (create user if needed)
const user = await getOrCreateUser(viewerId)
const result = await linkParentToChild(user.id, body.familyCode)
if (!result.success) {
return NextResponse.json({ success: false, error: result.error }, { status: 400 })
}
return NextResponse.json({ success: true, player: result.player })
} catch (error) {
console.error('Failed to link parent to child:', error)
return NextResponse.json(
{ success: false, error: 'Failed to link parent to child' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,32 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getPlayerAccess } from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
interface RouteParams {
params: Promise<{ id: string }>
}
/**
* GET /api/players/[id]/access
* Check access level for specific player
*
* Returns: { accessLevel, isParent, isTeacher, isPresent }
*/
export async function GET(req: NextRequest, { params }: RouteParams) {
try {
const { id: playerId } = await params
const viewerId = await getViewerId()
const access = await getPlayerAccess(viewerId, playerId)
return NextResponse.json({
accessLevel: access.accessLevel,
isParent: access.isParent,
isTeacher: access.isTeacher,
isPresent: access.isPresent,
})
} catch (error) {
console.error('Failed to check player access:', error)
return NextResponse.json({ error: 'Failed to check player access' }, { status: 500 })
}
}

View File

@@ -0,0 +1,33 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getStudentPresence, canPerformAction } from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
interface RouteParams {
params: Promise<{ id: string }>
}
/**
* GET /api/players/[id]/presence
* Get student's current classroom presence
*
* Returns: { presence } or { presence: null }
*/
export async function GET(req: NextRequest, { params }: RouteParams) {
try {
const { id: playerId } = await params
const viewerId = await getViewerId()
// Check authorization: must have at least view access
const canView = await canPerformAction(viewerId, playerId, 'view')
if (!canView) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
const presence = await getStudentPresence(playerId)
return NextResponse.json({ presence })
} catch (error) {
console.error('Failed to fetch student presence:', error)
return NextResponse.json({ error: 'Failed to fetch student presence' }, { status: 500 })
}
}

View File

@@ -0,0 +1,26 @@
import { NextResponse } from 'next/server'
import { getAccessiblePlayers } from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
/**
* GET /api/players/accessible
* Get all players current user can access
*
* Returns: {
* ownChildren: Player[],
* enrolledStudents: Player[],
* presentStudents: Player[]
* }
*/
export async function GET() {
try {
const viewerId = await getViewerId()
const accessible = await getAccessiblePlayers(viewerId)
return NextResponse.json(accessible)
} catch (error) {
console.error('Failed to fetch accessible players:', error)
return NextResponse.json({ error: 'Failed to fetch accessible players' }, { status: 500 })
}
}

View File

@@ -1,11 +1,13 @@
import { eq } from 'drizzle-orm'
import { eq, inArray, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { generateFamilyCode, parentChild } from '@/db/schema'
import { getViewerId } from '@/lib/viewer'
/**
* GET /api/players
* List all players for the current viewer (guest or user)
* Includes both created players and linked children via parent_child
*/
export async function GET() {
try {
@@ -14,11 +16,29 @@ export async function GET() {
// Get or create user record
const user = await getOrCreateUser(viewerId)
// Get all players for this user
const players = await db.query.players.findMany({
where: eq(schema.players.userId, user.id),
orderBy: (players, { desc }) => [desc(players.createdAt)],
// Get player IDs linked via parent_child table
const linkedPlayerIds = await db.query.parentChild.findMany({
where: eq(parentChild.parentUserId, user.id),
})
const linkedIds = linkedPlayerIds.map((link) => link.childPlayerId)
// Get all players: created by this user OR linked via parent_child
let players
if (linkedIds.length > 0) {
players = await db.query.players.findMany({
where: or(
eq(schema.players.userId, user.id),
inArray(schema.players.id, linkedIds)
),
orderBy: (players, { desc }) => [desc(players.createdAt)],
})
} else {
// No linked players, just get created players
players = await db.query.players.findMany({
where: eq(schema.players.userId, user.id),
orderBy: (players, { desc }) => [desc(players.createdAt)],
})
}
return NextResponse.json({ players })
} catch (error) {
@@ -47,7 +67,10 @@ export async function POST(req: NextRequest) {
// Get or create user record
const user = await getOrCreateUser(viewerId)
// Create player
// Generate a unique family code for the new player
const familyCode = generateFamilyCode()
// Create player with family code
const [player] = await db
.insert(schema.players)
.values({
@@ -56,9 +79,16 @@ export async function POST(req: NextRequest) {
emoji: body.emoji,
color: body.color,
isActive: body.isActive ?? false,
familyCode,
})
.returning()
// Create parent-child relationship
await db.insert(parentChild).values({
parentUserId: user.id,
childPlayerId: player.id,
})
return NextResponse.json({ player }, { status: 201 })
} catch (error) {
console.error('Failed to create player:', error)

View File

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useState } from 'react'
import { EmojiPicker } from '@/components/EmojiPicker'
import { LinkChildForm } from '@/components/family'
import { PLAYER_EMOJIS } from '@/constants/playerEmojis'
import { useCreatePlayer } from '@/hooks/useUserPlayers'
import { css } from '../../../styled-system/css'
@@ -38,6 +39,7 @@ export function AddStudentModal({ isOpen, onClose, isDark }: AddStudentModalProp
const [formEmoji, setFormEmoji] = useState(PLAYER_EMOJIS[0])
const [formColor, setFormColor] = useState(AVAILABLE_COLORS[0])
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
const [showLinkForm, setShowLinkForm] = useState(false)
// React Query mutation
const createPlayer = useCreatePlayer()
@@ -49,6 +51,7 @@ export function AddStudentModal({ isOpen, onClose, isDark }: AddStudentModalProp
setFormEmoji(PLAYER_EMOJIS[Math.floor(Math.random() * PLAYER_EMOJIS.length)])
setFormColor(AVAILABLE_COLORS[Math.floor(Math.random() * AVAILABLE_COLORS.length)])
setShowEmojiPicker(false)
setShowLinkForm(false)
}
}, [isOpen])
@@ -392,7 +395,57 @@ export function AddStudentModal({ isOpen, onClose, isDark }: AddStudentModalProp
{createPlayer.isPending ? 'Adding...' : 'Add Student'}
</button>
</div>
{/* Link existing child option */}
<div
className={css({
marginTop: '1.5rem',
paddingTop: '1.5rem',
borderTop: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
textAlign: 'center',
})}
>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.500',
marginBottom: '0.5rem',
})}
>
Have a family code from another parent?
</p>
<button
type="button"
onClick={() => setShowLinkForm(true)}
data-action="show-link-form"
className={css({
padding: '8px 16px',
fontSize: '0.875rem',
color: isDark ? 'blue.400' : 'blue.600',
backgroundColor: 'transparent',
border: '1px solid',
borderColor: isDark ? 'blue.700' : 'blue.300',
borderRadius: '8px',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: isDark ? 'blue.900/50' : 'blue.50',
borderColor: isDark ? 'blue.600' : 'blue.400',
},
})}
>
Link Existing Child
</button>
</div>
</div>
{/* Link Child Form Modal */}
<LinkChildForm
isOpen={showLinkForm}
onClose={() => setShowLinkForm(false)}
onSuccess={onClose}
/>
</div>
)
}

View File

@@ -0,0 +1,318 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useTheme } from '@/contexts/ThemeContext'
import { css } from '../../../styled-system/css'
interface FamilyCodeDisplayProps {
playerId: string
playerName: string
isOpen: boolean
onClose: () => void
}
/**
* Modal to display and manage a child's family code
*
* Parents can:
* - View the family code
* - Copy it to clipboard
* - Regenerate it (invalidates old code)
*/
export function FamilyCodeDisplay({
playerId,
playerName,
isOpen,
onClose,
}: FamilyCodeDisplayProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const [familyCode, setFamilyCode] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const [isRegenerating, setIsRegenerating] = useState(false)
// Fetch family code when modal opens
const fetchFamilyCode = useCallback(async () => {
setIsLoading(true)
setError(null)
try {
const response = await fetch(`/api/family/children/${playerId}/code`)
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to fetch family code')
}
setFamilyCode(data.familyCode)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch family code')
} finally {
setIsLoading(false)
}
}, [playerId])
// Reset state when playerId changes (different student)
useEffect(() => {
setFamilyCode(null)
setError(null)
setCopied(false)
}, [playerId])
// Fetch on open
useEffect(() => {
if (isOpen && !familyCode && !isLoading) {
fetchFamilyCode()
}
}, [isOpen, familyCode, isLoading, fetchFamilyCode])
// Copy to clipboard
const handleCopy = useCallback(async () => {
if (!familyCode) return
try {
await navigator.clipboard.writeText(familyCode)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// Fallback for older browsers
const textarea = document.createElement('textarea')
textarea.value = familyCode
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}, [familyCode])
// Regenerate family code
const handleRegenerate = useCallback(async () => {
setIsRegenerating(true)
setError(null)
try {
const response = await fetch(`/api/family/children/${playerId}/code`, {
method: 'POST',
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to regenerate code')
}
setFamilyCode(data.familyCode)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to regenerate code')
} finally {
setIsRegenerating(false)
}
}, [playerId])
if (!isOpen) return null
return (
<div
data-component="family-code-modal"
className={css({
position: 'fixed',
inset: 0,
zIndex: 10000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
})}
onClick={onClose}
>
<div
className={css({
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '16px',
padding: '24px',
maxWidth: '400px',
width: '90%',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.3)',
})}
onClick={(e) => e.stopPropagation()}
>
<h2
className={css({
fontSize: '1.25rem',
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
marginBottom: '8px',
})}
>
Share Access to {playerName}
</h2>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.600',
marginBottom: '20px',
})}
>
Share this code with another parent to give them equal access to {playerName}&apos;s
practice data.
</p>
{isLoading ? (
<div
className={css({
textAlign: 'center',
padding: '20px',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
Loading...
</div>
) : error ? (
<div
className={css({
textAlign: 'center',
padding: '20px',
color: 'red.500',
})}
>
{error}
</div>
) : (
<>
{/* Family Code Display */}
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '12px',
marginBottom: '16px',
})}
>
<div
data-element="family-code"
className={css({
flex: 1,
padding: '16px',
backgroundColor: isDark ? 'gray.700' : 'gray.100',
borderRadius: '8px',
fontFamily: 'monospace',
fontSize: '1.5rem',
fontWeight: 'bold',
textAlign: 'center',
letterSpacing: '0.1em',
color: isDark ? 'green.400' : 'green.600',
})}
>
{familyCode}
</div>
<button
type="button"
onClick={handleCopy}
data-action="copy-family-code"
className={css({
padding: '12px 16px',
backgroundColor: copied
? isDark
? 'green.700'
: 'green.500'
: isDark
? 'blue.700'
: 'blue.500',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: '14px',
fontWeight: 'medium',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: copied
? isDark
? 'green.600'
: 'green.600'
: isDark
? 'blue.600'
: 'blue.600',
},
})}
>
{copied ? '✓ Copied' : 'Copy'}
</button>
</div>
{/* Instructions */}
<p
className={css({
fontSize: '0.8125rem',
color: isDark ? 'gray.500' : 'gray.500',
marginBottom: '20px',
})}
>
The other parent will enter this code on their device to link to {playerName}.
</p>
{/* Regenerate button */}
<button
type="button"
onClick={handleRegenerate}
disabled={isRegenerating}
data-action="regenerate-family-code"
className={css({
width: '100%',
padding: '10px',
backgroundColor: 'transparent',
color: isDark ? 'gray.400' : 'gray.500',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
borderRadius: '8px',
fontSize: '13px',
cursor: isRegenerating ? 'wait' : 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: isDark ? 'gray.700' : 'gray.100',
},
_disabled: {
opacity: 0.5,
cursor: 'not-allowed',
},
})}
>
{isRegenerating ? 'Regenerating...' : 'Generate New Code'}
</button>
<p
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.600' : 'gray.400',
marginTop: '8px',
textAlign: 'center',
})}
>
Generating a new code will invalidate the old one
</p>
</>
)}
{/* Close button */}
<button
type="button"
onClick={onClose}
data-action="close-family-code-modal"
className={css({
position: 'absolute',
top: '12px',
right: '12px',
padding: '8px',
backgroundColor: 'transparent',
border: 'none',
cursor: 'pointer',
color: isDark ? 'gray.500' : 'gray.400',
fontSize: '20px',
lineHeight: 1,
_hover: {
color: isDark ? 'gray.300' : 'gray.600',
},
})}
>
×
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,278 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useTheme } from '@/contexts/ThemeContext'
import { useLinkChild } from '@/hooks/useUserPlayers'
import { css } from '../../../styled-system/css'
interface LinkChildFormProps {
isOpen: boolean
onClose: () => void
onSuccess?: () => void
}
/**
* Modal form to link to an existing child via family code
*
* Used when a second parent wants to add a child that was
* created by another parent.
*/
export function LinkChildForm({ isOpen, onClose, onSuccess }: LinkChildFormProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const [familyCode, setFamilyCode] = useState('')
const [linkedPlayer, setLinkedPlayer] = useState<{ name: string; emoji: string } | null>(null)
// React Query mutation
const linkChild = useLinkChild()
// Reset state when modal opens
useEffect(() => {
if (isOpen) {
setFamilyCode('')
setLinkedPlayer(null)
linkChild.reset()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen])
// Handle form submission
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault()
if (!familyCode.trim()) return
linkChild.mutate(familyCode.trim(), {
onSuccess: (data) => {
if (data.success && data.player) {
setLinkedPlayer({ name: data.player.name, emoji: data.player.emoji })
onSuccess?.()
}
},
})
},
[familyCode, linkChild, onSuccess]
)
// Handle close and reset
const handleClose = useCallback(() => {
setFamilyCode('')
setLinkedPlayer(null)
linkChild.reset()
onClose()
}, [onClose, linkChild])
if (!isOpen) return null
// Get error message from mutation
const error = linkChild.data?.success === false ? linkChild.data.error : null
return (
<div
data-component="link-child-modal"
className={css({
position: 'fixed',
inset: 0,
zIndex: 10000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
})}
onClick={handleClose}
>
<div
className={css({
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '16px',
padding: '24px',
maxWidth: '400px',
width: '90%',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.3)',
position: 'relative',
})}
onClick={(e) => e.stopPropagation()}
>
{linkedPlayer ? (
// Success state
<div
className={css({
textAlign: 'center',
padding: '20px 0',
})}
>
<div
className={css({
fontSize: '4rem',
marginBottom: '16px',
})}
>
{linkedPlayer.emoji}
</div>
<h2
className={css({
fontSize: '1.25rem',
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
marginBottom: '8px',
})}
>
Successfully Linked!
</h2>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.600',
marginBottom: '20px',
})}
>
You now have access to {linkedPlayer.name}&apos;s practice data.
</p>
<button
type="button"
onClick={handleClose}
data-action="close-link-success"
className={css({
padding: '12px 24px',
backgroundColor: isDark ? 'green.700' : 'green.500',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: '14px',
fontWeight: 'medium',
cursor: 'pointer',
_hover: {
backgroundColor: isDark ? 'green.600' : 'green.600',
},
})}
>
Done
</button>
</div>
) : (
// Form state
<>
<h2
className={css({
fontSize: '1.25rem',
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
marginBottom: '8px',
})}
>
Link Existing Child
</h2>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.600',
marginBottom: '20px',
})}
>
Enter the family code shared by another parent to link to their child.
</p>
<form onSubmit={handleSubmit}>
<input
type="text"
value={familyCode}
onChange={(e) => setFamilyCode(e.target.value.toUpperCase())}
placeholder="FAM-XXXXXX"
data-input="family-code"
className={css({
width: '100%',
padding: '14px 16px',
backgroundColor: isDark ? 'gray.700' : 'gray.100',
border: '2px solid',
borderColor: error ? 'red.500' : isDark ? 'gray.600' : 'gray.200',
borderRadius: '8px',
fontSize: '1.125rem',
fontFamily: 'monospace',
letterSpacing: '0.1em',
textAlign: 'center',
color: isDark ? 'white' : 'gray.800',
marginBottom: '8px',
outline: 'none',
transition: 'border-color 0.15s ease',
_focus: {
borderColor: isDark ? 'blue.500' : 'blue.400',
},
_placeholder: {
color: isDark ? 'gray.500' : 'gray.400',
},
})}
/>
{error && (
<p
className={css({
fontSize: '0.8125rem',
color: 'red.500',
marginBottom: '12px',
textAlign: 'center',
})}
>
{error}
</p>
)}
<button
type="submit"
disabled={linkChild.isPending || !familyCode.trim()}
data-action="submit-link-child"
className={css({
width: '100%',
padding: '14px',
backgroundColor: isDark ? 'blue.700' : 'blue.500',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: '16px',
fontWeight: 'medium',
cursor: linkChild.isPending ? 'wait' : 'pointer',
transition: 'all 0.15s ease',
marginTop: '8px',
_hover: {
backgroundColor: isDark ? 'blue.600' : 'blue.600',
},
_disabled: {
opacity: 0.5,
cursor: 'not-allowed',
},
})}
>
{linkChild.isPending ? 'Linking...' : 'Link Child'}
</button>
</form>
</>
)}
{/* Close button */}
<button
type="button"
onClick={handleClose}
data-action="close-link-child-modal"
className={css({
position: 'absolute',
top: '12px',
right: '12px',
padding: '8px',
backgroundColor: 'transparent',
border: 'none',
cursor: 'pointer',
color: isDark ? 'gray.500' : 'gray.400',
fontSize: '20px',
lineHeight: 1,
_hover: {
color: isDark ? 'gray.300' : 'gray.600',
},
})}
>
×
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,2 @@
export { FamilyCodeDisplay } from './FamilyCodeDisplay'
export { LinkChildForm } from './LinkChildForm'

View File

@@ -2,6 +2,7 @@
import { animated, useSpring } from '@react-spring/web'
import { useCallback, useEffect, useRef, useState } from 'react'
import { FamilyCodeDisplay } from '@/components/family'
import { css } from '../../../styled-system/css'
interface NotesModalProps {
@@ -49,6 +50,7 @@ export function NotesModal({
const [isEditing, setIsEditing] = useState(false)
const [editedNotes, setEditedNotes] = useState(student.notes ?? '')
const [isSaving, setIsSaving] = useState(false)
const [showFamilyCode, setShowFamilyCode] = useState(false)
const modalRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
@@ -439,56 +441,83 @@ export function NotesModal({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '0.5rem',
})}
>
{/* Archive button on the left */}
{onToggleArchive && (
{/* Left side buttons */}
<div className={css({ display: 'flex', gap: '0.5rem' })}>
{/* Archive button */}
{onToggleArchive && (
<button
type="button"
data-action="toggle-archive"
onClick={onToggleArchive}
className={css({
padding: '0.625rem 1rem',
borderRadius: '8px',
backgroundColor: student.isArchived
? isDark
? 'green.900'
: 'green.100'
: isDark
? 'gray.700'
: 'gray.100',
color: student.isArchived
? isDark
? 'green.300'
: 'green.700'
: isDark
? 'gray.300'
: 'gray.600',
fontSize: '0.875rem',
fontWeight: 'medium',
border: '1px solid',
borderColor: student.isArchived
? isDark
? 'green.700'
: 'green.300'
: isDark
? 'gray.600'
: 'gray.300',
cursor: 'pointer',
_hover: {
backgroundColor: student.isArchived
? isDark
? 'green.800'
: 'green.200'
: isDark
? 'gray.600'
: 'gray.200',
},
})}
>
{student.isArchived ? '📦 Unarchive' : '📦 Archive'}
</button>
)}
{/* Share Access button */}
<button
type="button"
data-action="toggle-archive"
onClick={onToggleArchive}
data-action="share-access"
onClick={() => setShowFamilyCode(true)}
className={css({
padding: '0.625rem 1rem',
borderRadius: '8px',
backgroundColor: student.isArchived
? isDark
? 'green.900'
: 'green.100'
: isDark
? 'gray.700'
: 'gray.100',
color: student.isArchived
? isDark
? 'green.300'
: 'green.700'
: isDark
? 'gray.300'
: 'gray.600',
backgroundColor: isDark ? 'gray.700' : 'gray.100',
color: isDark ? 'gray.300' : 'gray.600',
fontSize: '0.875rem',
fontWeight: 'medium',
border: '1px solid',
borderColor: student.isArchived
? isDark
? 'green.700'
: 'green.300'
: isDark
? 'gray.600'
: 'gray.300',
borderColor: isDark ? 'gray.600' : 'gray.300',
cursor: 'pointer',
_hover: {
backgroundColor: student.isArchived
? isDark
? 'green.800'
: 'green.200'
: isDark
? 'gray.600'
: 'gray.200',
backgroundColor: isDark ? 'gray.600' : 'gray.200',
},
})}
>
{student.isArchived ? '📦 Unarchive' : '📦 Archive'}
🔗 Share Access
</button>
)}
</div>
{/* Edit notes button on the right */}
<button
@@ -517,6 +546,14 @@ export function NotesModal({
)}
</div>
</animated.div>
{/* Family Code Modal */}
<FamilyCodeDisplay
playerId={student.id}
playerName={student.name}
isOpen={showFamilyCode}
onClose={() => setShowFamilyCode(false)}
/>
</>
)
}

View File

@@ -0,0 +1,53 @@
import { createId } from '@paralleldrive/cuid2'
import { index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'
import { classrooms } from './classrooms'
import { players } from './players'
/**
* Classroom enrollments - persistent student roster
*
* Links students (players) to classrooms. An enrolled student can:
* - Be viewed by the teacher (skills, history, progress)
* - Enter the classroom for live practice sessions
* - Be unenrolled by teacher or parent at any time
*/
export const classroomEnrollments = sqliteTable(
'classroom_enrollments',
{
/** Primary key */
id: text('id')
.primaryKey()
.$defaultFn(() => createId()),
/** Classroom this enrollment is for */
classroomId: text('classroom_id')
.notNull()
.references(() => classrooms.id, { onDelete: 'cascade' }),
/** Student (player) being enrolled */
playerId: text('player_id')
.notNull()
.references(() => players.id, { onDelete: 'cascade' }),
/** When this enrollment was created */
enrolledAt: integer('enrolled_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
},
(table) => ({
/** Each student can only be enrolled once per classroom */
classroomPlayerIdx: uniqueIndex('idx_enrollments_classroom_player').on(
table.classroomId,
table.playerId
),
/** Index for finding all students in a classroom */
classroomIdx: index('idx_enrollments_classroom').on(table.classroomId),
/** Index for finding all classrooms a student is enrolled in */
playerIdx: index('idx_enrollments_player').on(table.playerId),
})
)
export type ClassroomEnrollment = typeof classroomEnrollments.$inferSelect
export type NewClassroomEnrollment = typeof classroomEnrollments.$inferInsert

View File

@@ -0,0 +1,51 @@
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { classrooms } from './classrooms'
import { players } from './players'
import { users } from './users'
/**
* Classroom presence - ephemeral "in classroom" state
*
* Tracks which students are currently "in" a classroom for live sessions.
* A student can only be present in one classroom at a time (enforced by primary key on playerId).
*
* Presence is different from enrollment:
* - Enrollment: persistent registration in a classroom
* - Presence: currently active in the classroom for a live session
*
* When present, the teacher can:
* - Start practice sessions for the student
* - Observe the student's practice in real-time
* - Control the student's tutorial/abacus
*/
export const classroomPresence = sqliteTable(
'classroom_presence',
{
/** Player ID - also the primary key (one classroom at a time) */
playerId: text('player_id')
.primaryKey()
.references(() => players.id, { onDelete: 'cascade' }),
/** Classroom the student is currently in */
classroomId: text('classroom_id')
.notNull()
.references(() => classrooms.id, { onDelete: 'cascade' }),
/** When the student entered the classroom */
enteredAt: integer('entered_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
/** Who put the student in the classroom (parent, teacher, or self) */
enteredBy: text('entered_by')
.notNull()
.references(() => users.id),
},
(table) => ({
/** Index for finding all students present in a classroom */
classroomIdx: index('idx_presence_classroom').on(table.classroomId),
})
)
export type ClassroomPresence = typeof classroomPresence.$inferSelect
export type NewClassroomPresence = typeof classroomPresence.$inferInsert

View File

@@ -0,0 +1,56 @@
import { createId } from '@paralleldrive/cuid2'
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { users } from './users'
/**
* Classrooms table - teacher's persistent space for students
*
* Each teacher has exactly one classroom (enforced by unique constraint on teacherId).
* Students enroll in classrooms and can be "present" for live sessions.
*/
export const classrooms = sqliteTable(
'classrooms',
{
/** Primary key */
id: text('id')
.primaryKey()
.$defaultFn(() => createId()),
/** Teacher who owns this classroom (one classroom per teacher) */
teacherId: text('teacher_id')
.notNull()
.unique()
.references(() => users.id, { onDelete: 'cascade' }),
/** Classroom display name */
name: text('name').notNull(),
/** Join code for enrollment (e.g., "MATH-4B") */
code: text('code').notNull().unique(),
/** When this classroom was created */
createdAt: integer('created_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
},
(table) => ({
/** Index for looking up classroom by code */
codeIdx: index('classrooms_code_idx').on(table.code),
})
)
export type Classroom = typeof classrooms.$inferSelect
export type NewClassroom = typeof classrooms.$inferInsert
/**
* Generate a unique classroom code
* Format: 4-6 uppercase alphanumeric characters (no confusing chars like 0/O, 1/I)
*/
export function generateClassroomCode(): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
let code = ''
for (let i = 0; i < 6; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length))
}
return code
}

View File

@@ -0,0 +1,156 @@
import { createId } from '@paralleldrive/cuid2'
import { index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'
import { classrooms } from './classrooms'
import { players } from './players'
import { users } from './users'
/**
* Enrollment request status
*/
export type EnrollmentRequestStatus = 'pending' | 'approved' | 'denied' | 'cancelled'
/**
* Who initiated the enrollment request
*/
export type EnrollmentRequestRole = 'parent' | 'teacher'
/**
* Approval status for a single party
*/
export type ApprovalStatus = 'approved' | 'denied'
/**
* Enrollment requests - consent workflow for classroom enrollment
*
* Enrollment requires mutual consent:
* - If parent initiates: teacher must approve
* - If teacher initiates: any linked parent must approve
*
* Once all required approvals are in, the actual enrollment is created
* and the request status is set to 'approved'.
*/
export const enrollmentRequests = sqliteTable(
'enrollment_requests',
{
/** Primary key */
id: text('id')
.primaryKey()
.$defaultFn(() => createId()),
/** Classroom this request is for */
classroomId: text('classroom_id')
.notNull()
.references(() => classrooms.id, { onDelete: 'cascade' }),
/** Student (player) to be enrolled */
playerId: text('player_id')
.notNull()
.references(() => players.id, { onDelete: 'cascade' }),
// ---- Who initiated ----
/** User who created this request */
requestedBy: text('requested_by')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** Role of the requester */
requestedByRole: text('requested_by_role').notNull().$type<EnrollmentRequestRole>(),
/** When the request was created */
requestedAt: integer('requested_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
// ---- Overall status ----
/** Current status of the request */
status: text('status').notNull().default('pending').$type<EnrollmentRequestStatus>(),
// ---- Teacher approval ----
/** Teacher's approval decision (null if not yet acted or not required) */
teacherApproval: text('teacher_approval').$type<ApprovalStatus>(),
/** When teacher approved/denied */
teacherApprovedAt: integer('teacher_approved_at', { mode: 'timestamp' }),
// ---- Parent approval ----
/** Parent's approval decision (null if not yet acted or not required) */
parentApproval: text('parent_approval').$type<ApprovalStatus>(),
/** Which parent approved (since multiple parents may exist) */
parentApprovedBy: text('parent_approved_by').references(() => users.id),
/** When parent approved/denied */
parentApprovedAt: integer('parent_approved_at', { mode: 'timestamp' }),
// ---- Resolution ----
/** When the request was resolved (approved, denied, or cancelled) */
resolvedAt: integer('resolved_at', { mode: 'timestamp' }),
},
(table) => ({
/** One active request per player per classroom */
classroomPlayerIdx: uniqueIndex('idx_enrollment_requests_classroom_player').on(
table.classroomId,
table.playerId
),
/** Index for finding all requests for a classroom */
classroomIdx: index('idx_enrollment_requests_classroom').on(table.classroomId),
/** Index for finding all requests for a player */
playerIdx: index('idx_enrollment_requests_player').on(table.playerId),
/** Index for filtering by status */
statusIdx: index('idx_enrollment_requests_status').on(table.status),
})
)
export type EnrollmentRequest = typeof enrollmentRequests.$inferSelect
export type NewEnrollmentRequest = typeof enrollmentRequests.$inferInsert
/**
* Determine what approvals are required based on who initiated
*/
export function getRequiredApprovals(
requestedByRole: EnrollmentRequestRole
): ('teacher' | 'parent')[] {
switch (requestedByRole) {
case 'parent':
// Parent initiated → need teacher approval
return ['teacher']
case 'teacher':
// Teacher initiated → need parent approval
return ['parent']
default:
return []
}
}
/**
* Check if a request has all required approvals
*/
export function isFullyApproved(request: EnrollmentRequest): boolean {
const required = getRequiredApprovals(request.requestedByRole as EnrollmentRequestRole)
for (const role of required) {
if (role === 'teacher' && request.teacherApproval !== 'approved') {
return false
}
if (role === 'parent' && request.parentApproval !== 'approved') {
return false
}
}
return true
}
/**
* Check if a request has been denied by anyone
*/
export function isDenied(request: EnrollmentRequest): boolean {
return request.teacherApproval === 'denied' || request.parentApproval === 'denied'
}

View File

@@ -9,7 +9,12 @@ export * from './abacus-settings'
export * from './app-settings'
export * from './arcade-rooms'
export * from './arcade-sessions'
export * from './classroom-enrollments'
export * from './classroom-presence'
export * from './classrooms'
export * from './custom-skills'
export * from './enrollment-requests'
export * from './parent-child'
export * from './player-curriculum'
export * from './player-skill-mastery'
export * from './player-stats'

View File

@@ -0,0 +1,36 @@
import { integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { players } from './players'
import { users } from './users'
/**
* Parent-child relationships
*
* Many-to-many relationship between parents (users) and children (players).
* A child can have multiple parents, and a parent can have multiple children.
* All linked parents have equal access to the child.
*/
export const parentChild = sqliteTable(
'parent_child',
{
/** Parent's user ID */
parentUserId: text('parent_user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** Child's player ID */
childPlayerId: text('child_player_id')
.notNull()
.references(() => players.id, { onDelete: 'cascade' }),
/** When this relationship was created */
linkedAt: integer('linked_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
},
(table) => ({
pk: primaryKey({ columns: [table.parentUserId, table.childPlayerId] }),
})
)
export type ParentChild = typeof parentChild.$inferSelect
export type NewParentChild = typeof parentChild.$inferInsert

View File

@@ -90,6 +90,12 @@ export const players = sqliteTable(
* Archived students are not deleted but don't appear in normal lists
*/
isArchived: integer('is_archived', { mode: 'boolean' }).notNull().default(false),
/**
* Family code for sharing access to this player with other parents
* Format: FAM-XXXXXX (6 alphanumeric chars)
*/
familyCode: text('family_code').unique(),
},
(table) => ({
/** Index for fast lookups by userId */
@@ -99,3 +105,16 @@ export const players = sqliteTable(
export type Player = typeof players.$inferSelect
export type NewPlayer = typeof players.$inferInsert
/**
* Generate a unique family code for sharing player access with other parents
* Format: FAM-XXXXXX (6 alphanumeric characters, no confusing chars like 0/O, 1/I)
*/
export function generateFamilyCode(): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
let code = 'FAM-'
for (let i = 0; i < 6; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length))
}
return code
}

View File

@@ -0,0 +1,111 @@
'use client'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { Classroom } from '@/db/schema/classrooms'
import { api } from '@/lib/queryClient'
// Query keys for classroom data
export const classroomKeys = {
all: ['classroom'] as const,
mine: () => [...classroomKeys.all, 'mine'] as const,
detail: (id: string) => [...classroomKeys.all, 'detail', id] as const,
byCode: (code: string) => [...classroomKeys.all, 'code', code] as const,
}
/**
* Fetch current user's classroom
*/
async function fetchMyClassroom(): Promise<Classroom | null> {
const res = await api('classrooms')
if (!res.ok) {
// 404 means no classroom
if (res.status === 404) return null
throw new Error('Failed to fetch classroom')
}
const data = await res.json()
return data.classroom ?? null
}
/**
* Create a classroom
*/
async function createClassroom(name: string): Promise<Classroom> {
const res = await api('classrooms', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
const data = await res.json()
if (!res.ok || !data.success) {
throw new Error(data.error || 'Failed to create classroom')
}
return data.classroom
}
/**
* Look up classroom by code
*/
async function fetchClassroomByCode(
code: string
): Promise<{ classroom: Classroom; teacherName: string } | null> {
const res = await api(`classrooms/code/${code.toUpperCase()}`)
if (!res.ok) {
if (res.status === 404) return null
throw new Error('Failed to look up classroom')
}
const data = await res.json()
return data
}
/**
* Hook: Get current user's classroom (if they're a teacher)
*/
export function useMyClassroom() {
return useQuery({
queryKey: classroomKeys.mine(),
queryFn: fetchMyClassroom,
// Don't refetch too aggressively - classrooms rarely change
staleTime: 60_000, // 1 minute
})
}
/**
* Hook: Create a classroom
*/
export function useCreateClassroom() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createClassroom,
onSuccess: (classroom) => {
// Update the cache with the new classroom
queryClient.setQueryData(classroomKeys.mine(), classroom)
// Invalidate to ensure consistency
queryClient.invalidateQueries({ queryKey: classroomKeys.all })
},
})
}
/**
* Hook: Look up classroom by join code
*/
export function useClassroomByCode(code: string | null) {
return useQuery({
queryKey: classroomKeys.byCode(code ?? ''),
queryFn: () => (code ? fetchClassroomByCode(code) : null),
enabled: !!code && code.length >= 4, // Only query if code is entered
staleTime: 30_000, // 30 seconds
})
}
/**
* Check if current user is a teacher (has a classroom)
*/
export function useIsTeacher() {
const { data: classroom, isLoading } = useMyClassroom()
return {
isTeacher: classroom !== null && classroom !== undefined,
isLoading,
classroom,
}
}

View File

@@ -158,6 +158,7 @@ export function useCreatePlayer() {
userId: 'temp-user', // Temporary userId, will be replaced by server response
helpSettings: null, // Will be set by server with default values
notes: null,
familyCode: null, // Will be generated by server
}
queryClient.setQueryData<Player[]>(playerKeys.list(), [
...previousPlayers,
@@ -292,3 +293,43 @@ export function useSetPlayerActive() {
},
}
}
/**
* Link to an existing child via family code
*/
interface LinkChildResult {
success: boolean
player?: Player
error?: string
}
async function linkChild(familyCode: string): Promise<LinkChildResult> {
const res = await api('family/link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ familyCode }),
})
const data = await res.json()
if (!res.ok || !data.success) {
return { success: false, error: data.error || 'Failed to link child' }
}
return { success: true, player: data.player }
}
/**
* Hook: Link to an existing child via family code
*/
export function useLinkChild() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: linkChild,
onSuccess: (data) => {
if (data.success) {
// Invalidate ALL player queries to show the newly linked child
// This includes both playerKeys.list() and playerKeys.listWithSkillData()
queryClient.invalidateQueries({ queryKey: playerKeys.all })
}
},
})
}

View File

@@ -0,0 +1,229 @@
/**
* Access Control Module
*
* Determines what access a user has to a player based on:
* - Parent-child relationship (always full access)
* - Teacher-student relationship (enrolled students)
* - Presence (student currently in teacher's classroom)
*/
import { and, eq } from 'drizzle-orm'
import { db } from '@/db'
import {
classroomEnrollments,
classroomPresence,
classrooms,
parentChild,
type Player,
players,
} from '@/db/schema'
/**
* Access levels in order of increasing permissions:
* - 'none': No access to this player
* - 'teacher-enrolled': Can view history/skills (student is enrolled)
* - 'teacher-present': Can run sessions, observe, control (student is present)
* - 'parent': Full access always (parent-child relationship)
*/
export type AccessLevel = 'none' | 'teacher-enrolled' | 'teacher-present' | 'parent'
/**
* Result of checking a user's access to a player
*/
export interface PlayerAccess {
playerId: string
accessLevel: AccessLevel
isParent: boolean
isTeacher: boolean
isPresent: boolean
}
/**
* Determine what access a viewer has to a player
*/
export async function getPlayerAccess(viewerId: string, playerId: string): Promise<PlayerAccess> {
// Check parent relationship
const parentLink = await db.query.parentChild.findFirst({
where: and(eq(parentChild.parentUserId, viewerId), eq(parentChild.childPlayerId, playerId)),
})
const isParent = !!parentLink
// Check teacher relationship (enrolled in their classroom)
const classroom = await db.query.classrooms.findFirst({
where: eq(classrooms.teacherId, viewerId),
})
let isTeacher = false
let isPresent = false
if (classroom) {
const enrollment = await db.query.classroomEnrollments.findFirst({
where: and(
eq(classroomEnrollments.classroomId, classroom.id),
eq(classroomEnrollments.playerId, playerId)
),
})
isTeacher = !!enrollment
if (isTeacher) {
const presence = await db.query.classroomPresence.findFirst({
where: and(
eq(classroomPresence.classroomId, classroom.id),
eq(classroomPresence.playerId, playerId)
),
})
isPresent = !!presence
}
}
// Determine access level (parent takes precedence)
let accessLevel: AccessLevel = 'none'
if (isParent) {
accessLevel = 'parent'
} else if (isPresent) {
accessLevel = 'teacher-present'
} else if (isTeacher) {
accessLevel = 'teacher-enrolled'
}
return { playerId, accessLevel, isParent, isTeacher, isPresent }
}
/**
* Actions that can be performed on a player
*/
export type PlayerAction =
| 'view' // View skills, history, progress
| 'start-session' // Start a practice session
| 'observe' // Watch an active session
| 'control-tutorial' // Control tutorial navigation
| 'control-abacus' // Control the abacus display
/**
* Check if viewer can perform action on player
*/
export async function canPerformAction(
viewerId: string,
playerId: string,
action: PlayerAction
): Promise<boolean> {
const access = await getPlayerAccess(viewerId, playerId)
switch (action) {
case 'view':
// Parent or any teacher relationship (enrolled or present)
return access.accessLevel !== 'none'
case 'start-session':
case 'observe':
case 'control-tutorial':
case 'control-abacus':
// Parent always, or teacher with presence
return access.isParent || access.isPresent
default:
return false
}
}
/**
* Result of getting all accessible players for a viewer
*/
export interface AccessiblePlayers {
/** Children where viewer is a parent (full access) */
ownChildren: Player[]
/** Students enrolled in viewer's classroom (view only unless present) */
enrolledStudents: Player[]
/** Students currently present in viewer's classroom (full access) */
presentStudents: Player[]
}
/**
* Get all players accessible to a viewer
*
* Returns three categories:
* - ownChildren: Viewer is a parent (always full access)
* - enrolledStudents: Enrolled in viewer's classroom (can be view-only or full)
* - presentStudents: Currently present in viewer's classroom (full access)
*
* Note: Own children who are also enrolled appear ONLY in ownChildren,
* not duplicated in enrolledStudents.
*/
export async function getAccessiblePlayers(viewerId: string): Promise<AccessiblePlayers> {
// Own children (via parent_child)
const parentLinks = await db.query.parentChild.findMany({
where: eq(parentChild.parentUserId, viewerId),
})
const childIds = parentLinks.map((l) => l.childPlayerId)
let ownChildren: Player[] = []
if (childIds.length > 0) {
ownChildren = await db.query.players.findMany({
where: (players, { inArray }) => inArray(players.id, childIds),
})
}
const ownChildIds = new Set(ownChildren.map((c) => c.id))
// Check if viewer is a teacher
const classroom = await db.query.classrooms.findFirst({
where: eq(classrooms.teacherId, viewerId),
})
let enrolledStudents: Player[] = []
let presentStudents: Player[] = []
if (classroom) {
// Enrolled students (exclude own children to avoid duplication)
const enrollments = await db.query.classroomEnrollments.findMany({
where: eq(classroomEnrollments.classroomId, classroom.id),
})
const enrolledIds = enrollments.map((e) => e.playerId).filter((id) => !ownChildIds.has(id))
if (enrolledIds.length > 0) {
enrolledStudents = await db.query.players.findMany({
where: (players, { inArray }) => inArray(players.id, enrolledIds),
})
}
// Present students (subset of enrolled, for quick lookup)
const presences = await db.query.classroomPresence.findMany({
where: eq(classroomPresence.classroomId, classroom.id),
})
const presentIds = new Set(presences.map((p) => p.playerId))
// Present students includes both own children and enrolled students
presentStudents = [...ownChildren, ...enrolledStudents].filter((s) => presentIds.has(s.id))
}
return { ownChildren, enrolledStudents, presentStudents }
}
/**
* Check if a user is a parent of a player
*/
export async function isParentOf(userId: string, playerId: string): Promise<boolean> {
const link = await db.query.parentChild.findFirst({
where: and(eq(parentChild.parentUserId, userId), eq(parentChild.childPlayerId, playerId)),
})
return !!link
}
/**
* Check if a user is the teacher of a classroom where the player is enrolled
*/
export async function isTeacherOf(userId: string, playerId: string): Promise<boolean> {
const classroom = await db.query.classrooms.findFirst({
where: eq(classrooms.teacherId, userId),
})
if (!classroom) return false
const enrollment = await db.query.classroomEnrollments.findFirst({
where: and(
eq(classroomEnrollments.classroomId, classroom.id),
eq(classroomEnrollments.playerId, playerId)
),
})
return !!enrollment
}

View File

@@ -0,0 +1,257 @@
/**
* Classroom Manager Module
*
* CRUD operations for classrooms:
* - Create classroom (one per teacher)
* - Get classroom by teacher
* - Get classroom by code
* - Update classroom settings
* - Delete classroom
*/
import { createId } from '@paralleldrive/cuid2'
import { eq } from 'drizzle-orm'
import { db } from '@/db'
import {
classrooms,
generateClassroomCode,
type Classroom,
type NewClassroom,
type User,
users,
} from '@/db/schema'
// ============================================================================
// Create Classroom
// ============================================================================
export interface CreateClassroomParams {
teacherId: string
name: string
}
export interface CreateClassroomResult {
success: boolean
classroom?: Classroom
error?: string
}
/**
* Create a classroom for a teacher
*
* Each teacher can have exactly one classroom (enforced by unique constraint).
* Returns error if teacher already has a classroom.
*/
export async function createClassroom(
params: CreateClassroomParams
): Promise<CreateClassroomResult> {
const { teacherId, name } = params
// Check if teacher already has a classroom
const existing = await db.query.classrooms.findFirst({
where: eq(classrooms.teacherId, teacherId),
})
if (existing) {
return { success: false, error: 'Teacher already has a classroom' }
}
// Generate unique code with collision handling
let code = generateClassroomCode()
let attempts = 0
const maxAttempts = 10
while (attempts < maxAttempts) {
const codeExists = await db.query.classrooms.findFirst({
where: eq(classrooms.code, code),
})
if (!codeExists) break
code = generateClassroomCode()
attempts++
}
if (attempts >= maxAttempts) {
return { success: false, error: 'Failed to generate unique classroom code' }
}
// Create classroom
const [classroom] = await db
.insert(classrooms)
.values({
id: createId(),
teacherId,
name,
code,
})
.returning()
return { success: true, classroom }
}
// ============================================================================
// Read Classroom
// ============================================================================
/**
* Get a classroom by ID
*/
export async function getClassroom(classroomId: string): Promise<Classroom | null> {
const classroom = await db.query.classrooms.findFirst({
where: eq(classrooms.id, classroomId),
})
return classroom ?? null
}
/**
* Get a teacher's classroom
*
* Returns null if the user doesn't have a classroom (not a teacher).
*/
export async function getTeacherClassroom(teacherId: string): Promise<Classroom | null> {
const classroom = await db.query.classrooms.findFirst({
where: eq(classrooms.teacherId, teacherId),
})
return classroom ?? null
}
/**
* Check if a user is a teacher (has a classroom)
*/
export async function isTeacher(userId: string): Promise<boolean> {
const classroom = await getTeacherClassroom(userId)
return classroom !== null
}
export interface ClassroomWithTeacher extends Classroom {
teacher?: User
}
/**
* Get a classroom by join code
*
* Used when a parent wants to enroll their child using the code.
*/
export async function getClassroomByCode(code: string): Promise<ClassroomWithTeacher | null> {
const normalizedCode = code.toUpperCase().trim()
const classroom = await db.query.classrooms.findFirst({
where: eq(classrooms.code, normalizedCode),
})
if (!classroom) return null
const teacher = await db.query.users.findFirst({
where: eq(users.id, classroom.teacherId),
})
return { ...classroom, teacher }
}
// ============================================================================
// Update Classroom
// ============================================================================
export interface UpdateClassroomParams {
name?: string
}
/**
* Update classroom settings
*
* Only the teacher can update their classroom.
*/
export async function updateClassroom(
classroomId: string,
teacherId: string,
updates: UpdateClassroomParams
): Promise<Classroom | null> {
// Verify ownership
const classroom = await db.query.classrooms.findFirst({
where: eq(classrooms.id, classroomId),
})
if (!classroom || classroom.teacherId !== teacherId) {
return null
}
const [updated] = await db
.update(classrooms)
.set(updates)
.where(eq(classrooms.id, classroomId))
.returning()
return updated
}
/**
* Regenerate classroom join code
*
* Use this if a teacher wants to invalidate the old code.
*/
export async function regenerateClassroomCode(
classroomId: string,
teacherId: string
): Promise<string | null> {
// Verify ownership
const classroom = await db.query.classrooms.findFirst({
where: eq(classrooms.id, classroomId),
})
if (!classroom || classroom.teacherId !== teacherId) {
return null
}
// Generate unique code
let code = generateClassroomCode()
let attempts = 0
const maxAttempts = 10
while (attempts < maxAttempts) {
const codeExists = await db.query.classrooms.findFirst({
where: eq(classrooms.code, code),
})
if (!codeExists) break
code = generateClassroomCode()
attempts++
}
if (attempts >= maxAttempts) {
return null
}
await db.update(classrooms).set({ code }).where(eq(classrooms.id, classroomId))
return code
}
// ============================================================================
// Delete Classroom
// ============================================================================
/**
* Delete a classroom
*
* Only the teacher can delete their classroom.
* All enrollments, requests, and presence records will be cascade deleted.
*/
export async function deleteClassroom(classroomId: string, teacherId: string): Promise<boolean> {
// Verify ownership
const classroom = await db.query.classrooms.findFirst({
where: eq(classrooms.id, classroomId),
})
if (!classroom || classroom.teacherId !== teacherId) {
return false
}
await db.delete(classrooms).where(eq(classrooms.id, classroomId))
return true
}
// Re-export code generation function
export { generateClassroomCode } from '@/db/schema'

View File

@@ -0,0 +1,394 @@
/**
* Enrollment Manager Module
*
* Manages classroom enrollment with consent workflow:
* - Create enrollment requests (by parent or teacher)
* - Approve/deny requests
* - Automatic enrollment on full approval
* - Unenroll students
*/
import { createId } from '@paralleldrive/cuid2'
import { and, desc, eq, inArray, isNull } from 'drizzle-orm'
import { db } from '@/db'
import {
classroomEnrollments,
classroomPresence,
classrooms,
enrollmentRequests,
isFullyApproved,
isDenied,
parentChild,
type EnrollmentRequest,
type EnrollmentRequestRole,
type Classroom,
type Player,
} from '@/db/schema'
// ============================================================================
// Create Enrollment Request
// ============================================================================
export interface CreateEnrollmentRequestParams {
classroomId: string
playerId: string
requestedBy: string
requestedByRole: EnrollmentRequestRole
}
/**
* Create an enrollment request
*
* If a request already exists for this classroom/player pair, it will be
* reset to pending status with new requester info.
*/
export async function createEnrollmentRequest(
params: CreateEnrollmentRequestParams
): Promise<EnrollmentRequest> {
const { classroomId, playerId, requestedBy, requestedByRole } = params
// Check for existing request
const existing = await db.query.enrollmentRequests.findFirst({
where: and(
eq(enrollmentRequests.classroomId, classroomId),
eq(enrollmentRequests.playerId, playerId)
),
})
if (existing) {
// Upsert: reset to pending
const [updated] = await db
.update(enrollmentRequests)
.set({
status: 'pending',
requestedBy,
requestedByRole,
requestedAt: new Date(),
teacherApproval: null,
teacherApprovedAt: null,
parentApproval: null,
parentApprovedBy: null,
parentApprovedAt: null,
resolvedAt: null,
})
.where(eq(enrollmentRequests.id, existing.id))
.returning()
return updated
}
// Create new request
const [request] = await db
.insert(enrollmentRequests)
.values({
id: createId(),
classroomId,
playerId,
requestedBy,
requestedByRole,
})
.returning()
return request
}
// ============================================================================
// Approve/Deny Requests
// ============================================================================
export interface ApprovalResult {
request: EnrollmentRequest
fullyApproved: boolean
}
/**
* Approve an enrollment request (by teacher or parent)
*
* If this approval completes the consent workflow, the actual
* enrollment is automatically created.
*/
export async function approveEnrollmentRequest(
requestId: string,
approverId: string,
approverRole: 'teacher' | 'parent'
): Promise<ApprovalResult> {
const request = await db.query.enrollmentRequests.findFirst({
where: eq(enrollmentRequests.id, requestId),
})
if (!request || request.status !== 'pending') {
throw new Error('Request not found or not pending')
}
// Update appropriate approval field
const updates: Partial<EnrollmentRequest> = {}
if (approverRole === 'teacher') {
updates.teacherApproval = 'approved'
updates.teacherApprovedAt = new Date()
} else {
updates.parentApproval = 'approved'
updates.parentApprovedBy = approverId
updates.parentApprovedAt = new Date()
}
const [updated] = await db
.update(enrollmentRequests)
.set(updates)
.where(eq(enrollmentRequests.id, requestId))
.returning()
// Check if fully approved
const fullyApproved = isFullyApproved(updated)
if (fullyApproved) {
// Create actual enrollment
await db.insert(classroomEnrollments).values({
id: createId(),
classroomId: request.classroomId,
playerId: request.playerId,
})
// Update request status
await db
.update(enrollmentRequests)
.set({ status: 'approved', resolvedAt: new Date() })
.where(eq(enrollmentRequests.id, requestId))
}
return { request: updated, fullyApproved }
}
/**
* Deny an enrollment request
*/
export async function denyEnrollmentRequest(
requestId: string,
denierId: string,
denierRole: 'teacher' | 'parent'
): Promise<EnrollmentRequest> {
const request = await db.query.enrollmentRequests.findFirst({
where: eq(enrollmentRequests.id, requestId),
})
if (!request || request.status !== 'pending') {
throw new Error('Request not found or not pending')
}
const updates: Partial<EnrollmentRequest> = {
status: 'denied',
resolvedAt: new Date(),
}
if (denierRole === 'teacher') {
updates.teacherApproval = 'denied'
updates.teacherApprovedAt = new Date()
} else {
updates.parentApproval = 'denied'
updates.parentApprovedBy = denierId
updates.parentApprovedAt = new Date()
}
const [updated] = await db
.update(enrollmentRequests)
.set(updates)
.where(eq(enrollmentRequests.id, requestId))
.returning()
return updated
}
/**
* Cancel an enrollment request
*/
export async function cancelEnrollmentRequest(requestId: string): Promise<EnrollmentRequest> {
const [updated] = await db
.update(enrollmentRequests)
.set({ status: 'cancelled', resolvedAt: new Date() })
.where(eq(enrollmentRequests.id, requestId))
.returning()
return updated
}
// ============================================================================
// Query Pending Requests
// ============================================================================
export interface EnrollmentRequestWithRelations extends EnrollmentRequest {
player?: Player
classroom?: Classroom
}
/**
* Get pending requests for a teacher's classroom
*/
export async function getPendingRequestsForClassroom(
classroomId: string
): Promise<EnrollmentRequestWithRelations[]> {
const requests = await db.query.enrollmentRequests.findMany({
where: and(
eq(enrollmentRequests.classroomId, classroomId),
eq(enrollmentRequests.status, 'pending')
),
orderBy: [desc(enrollmentRequests.requestedAt)],
})
// Fetch related players
if (requests.length === 0) return []
const playerIds = [...new Set(requests.map((r) => r.playerId))]
const players = await db.query.players.findMany({
where: (players, { inArray }) => inArray(players.id, playerIds),
})
const playerMap = new Map(players.map((p) => [p.id, p]))
return requests.map((r) => ({
...r,
player: playerMap.get(r.playerId),
}))
}
/**
* Get pending requests where user needs to approve as parent
*
* These are requests initiated by a teacher for one of the user's children,
* where parent approval hasn't been given yet.
*/
export async function getPendingRequestsForParent(
parentUserId: string
): Promise<EnrollmentRequestWithRelations[]> {
// Get all children of this parent
const children = await db.query.parentChild.findMany({
where: eq(parentChild.parentUserId, parentUserId),
})
const childIds = children.map((c) => c.childPlayerId)
if (childIds.length === 0) return []
// Find pending requests for these children that need parent approval
const requests = await db.query.enrollmentRequests.findMany({
where: and(
inArray(enrollmentRequests.playerId, childIds),
eq(enrollmentRequests.status, 'pending'),
eq(enrollmentRequests.requestedByRole, 'teacher'), // Teacher initiated, needs parent
isNull(enrollmentRequests.parentApproval)
),
orderBy: [desc(enrollmentRequests.requestedAt)],
})
if (requests.length === 0) return []
// Fetch related players and classrooms
const playerIds = [...new Set(requests.map((r) => r.playerId))]
const classroomIds = [...new Set(requests.map((r) => r.classroomId))]
const [players, classroomList] = await Promise.all([
db.query.players.findMany({
where: (players, { inArray }) => inArray(players.id, playerIds),
}),
db.query.classrooms.findMany({
where: (classrooms, { inArray }) => inArray(classrooms.id, classroomIds),
}),
])
const playerMap = new Map(players.map((p) => [p.id, p]))
const classroomMap = new Map(classroomList.map((c) => [c.id, c]))
return requests.map((r) => ({
...r,
player: playerMap.get(r.playerId),
classroom: classroomMap.get(r.classroomId),
}))
}
// ============================================================================
// Enrollment Management
// ============================================================================
/**
* Check if a player is enrolled in a classroom
*/
export async function isEnrolled(classroomId: string, playerId: string): Promise<boolean> {
const enrollment = await db.query.classroomEnrollments.findFirst({
where: and(
eq(classroomEnrollments.classroomId, classroomId),
eq(classroomEnrollments.playerId, playerId)
),
})
return !!enrollment
}
/**
* Get all enrolled students in a classroom
*/
export async function getEnrolledStudents(classroomId: string): Promise<Player[]> {
const enrollments = await db.query.classroomEnrollments.findMany({
where: eq(classroomEnrollments.classroomId, classroomId),
})
if (enrollments.length === 0) return []
const playerIds = enrollments.map((e) => e.playerId)
const students = await db.query.players.findMany({
where: (players, { inArray }) => inArray(players.id, playerIds),
})
return students
}
/**
* Unenroll a student from a classroom
*
* Also removes presence and cancels pending requests.
*/
export async function unenrollStudent(classroomId: string, playerId: string): Promise<void> {
// Remove enrollment
await db
.delete(classroomEnrollments)
.where(
and(
eq(classroomEnrollments.classroomId, classroomId),
eq(classroomEnrollments.playerId, playerId)
)
)
// Also remove presence if present
await db
.delete(classroomPresence)
.where(
and(eq(classroomPresence.classroomId, classroomId), eq(classroomPresence.playerId, playerId))
)
// Cancel any pending requests
await db
.update(enrollmentRequests)
.set({ status: 'cancelled', resolvedAt: new Date() })
.where(
and(
eq(enrollmentRequests.classroomId, classroomId),
eq(enrollmentRequests.playerId, playerId),
eq(enrollmentRequests.status, 'pending')
)
)
}
/**
* Get all classrooms a player is enrolled in
*/
export async function getEnrolledClassrooms(playerId: string): Promise<Classroom[]> {
const enrollments = await db.query.classroomEnrollments.findMany({
where: eq(classroomEnrollments.playerId, playerId),
})
if (enrollments.length === 0) return []
const classroomIds = enrollments.map((e) => e.classroomId)
const classroomList = await db.query.classrooms.findMany({
where: (classrooms, { inArray }) => inArray(classrooms.id, classroomIds),
})
return classroomList
}
// Re-export helper functions from schema
export { getRequiredApprovals, isFullyApproved, isDenied } from '@/db/schema'

View File

@@ -0,0 +1,208 @@
/**
* Family Manager Module
*
* Manages parent-child relationships:
* - Link parent to child via family code
* - Get linked parents for a child
* - Unlink parent from child
* - Generate family codes
*/
import { and, eq } from 'drizzle-orm'
import { db } from '@/db'
import { generateFamilyCode, parentChild, type Player, players, type User } from '@/db/schema'
/**
* Result of linking a parent to a child
*/
export interface LinkResult {
success: boolean
player?: Player
error?: string
}
/**
* Link a parent to a child via family code
*
* Parents share family codes to link another parent to their child.
* This creates a many-to-many relationship where children can have
* multiple parents with equal access.
*/
export async function linkParentToChild(
parentUserId: string,
familyCode: string
): Promise<LinkResult> {
// Normalize code
const normalizedCode = familyCode.toUpperCase().trim()
// Find player by family code
const player = await db.query.players.findFirst({
where: eq(players.familyCode, normalizedCode),
})
if (!player) {
return { success: false, error: 'Invalid family code' }
}
// Check if already linked
const existing = await db.query.parentChild.findFirst({
where: and(
eq(parentChild.parentUserId, parentUserId),
eq(parentChild.childPlayerId, player.id)
),
})
if (existing) {
return { success: false, error: 'Already linked to this child' }
}
// Create link
await db.insert(parentChild).values({
parentUserId,
childPlayerId: player.id,
})
return { success: true, player }
}
/**
* Get all parents linked to a child
*/
export async function getLinkedParents(playerId: string): Promise<User[]> {
const links = await db.query.parentChild.findMany({
where: eq(parentChild.childPlayerId, playerId),
})
if (links.length === 0) return []
const parentIds = links.map((l) => l.parentUserId)
const linkedParents = await db.query.users.findMany({
where: (users, { inArray }) => inArray(users.id, parentIds),
})
return linkedParents
}
/**
* Get all parent user IDs for a child (simpler version)
*/
export async function getLinkedParentIds(playerId: string): Promise<string[]> {
const links = await db.query.parentChild.findMany({
where: eq(parentChild.childPlayerId, playerId),
})
return links.map((l) => l.parentUserId)
}
/**
* Get all children linked to a parent
*/
export async function getLinkedChildren(parentUserId: string): Promise<Player[]> {
const links = await db.query.parentChild.findMany({
where: eq(parentChild.parentUserId, parentUserId),
})
if (links.length === 0) return []
const childIds = links.map((l) => l.childPlayerId)
const linkedChildren = await db.query.players.findMany({
where: (players, { inArray }) => inArray(players.id, childIds),
})
return linkedChildren
}
/**
* Unlink a parent from a child
*
* Note: The last parent cannot be unlinked (every child must have at least one parent).
* Returns error if trying to unlink the only parent.
*/
export async function unlinkParentFromChild(
parentUserId: string,
playerId: string
): Promise<{ success: boolean; error?: string }> {
// Check how many parents this child has
const parentCount = await db.query.parentChild.findMany({
where: eq(parentChild.childPlayerId, playerId),
})
if (parentCount.length <= 1) {
return { success: false, error: 'Cannot unlink the only parent' }
}
// Remove the link
await db
.delete(parentChild)
.where(and(eq(parentChild.parentUserId, parentUserId), eq(parentChild.childPlayerId, playerId)))
return { success: true }
}
/**
* Get the family code for a player, generating one if needed
*/
export async function getOrCreateFamilyCode(playerId: string): Promise<string | null> {
const player = await db.query.players.findFirst({
where: eq(players.id, playerId),
})
if (!player) return null
if (player.familyCode) {
return player.familyCode
}
// Generate and save new family code
const newCode = generateFamilyCode()
await db.update(players).set({ familyCode: newCode }).where(eq(players.id, playerId))
return newCode
}
/**
* Regenerate family code for a player
*
* Use this if a parent wants to invalidate an old code that was shared.
* Note: This won't affect existing parent-child links.
*/
export async function regenerateFamilyCode(playerId: string): Promise<string | null> {
const player = await db.query.players.findFirst({
where: eq(players.id, playerId),
})
if (!player) return null
const newCode = generateFamilyCode()
await db.update(players).set({ familyCode: newCode }).where(eq(players.id, playerId))
return newCode
}
/**
* Check if a user is a parent of a player
*
* Checks both:
* 1. The parent_child many-to-many table (new relationship)
* 2. The players.userId field (legacy - original creator)
*/
export async function isParent(userId: string, playerId: string): Promise<boolean> {
// Check the parent_child table first (many-to-many relationship)
const link = await db.query.parentChild.findFirst({
where: and(eq(parentChild.parentUserId, userId), eq(parentChild.childPlayerId, playerId)),
})
if (link) return true
// Fallback: Check if user is the original creator (legacy players)
// This handles players created before the parent_child system was added
const player = await db.query.players.findFirst({
where: eq(players.id, playerId),
})
if (player && player.userId === userId) return true
return false
}
// Re-export the generateFamilyCode function from schema for convenience
export { generateFamilyCode } from '@/db/schema'

View File

@@ -0,0 +1,94 @@
/**
* Classroom Module
*
* Central module for the classroom/teacher/parent system.
*
* This module provides:
* - Access control (who can see/control what)
* - Family management (parent-child relationships)
* - Enrollment management (consent workflow)
* - Presence management (live classroom state)
* - Classroom CRUD operations
*/
// Access Control
export {
type AccessLevel,
type PlayerAccess,
type PlayerAction,
type AccessiblePlayers,
getPlayerAccess,
canPerformAction,
getAccessiblePlayers,
isParentOf,
isTeacherOf,
} from './access-control'
// Family Management
export {
type LinkResult,
linkParentToChild,
getLinkedParents,
getLinkedParentIds,
getLinkedChildren,
unlinkParentFromChild,
getOrCreateFamilyCode,
regenerateFamilyCode,
isParent,
generateFamilyCode,
} from './family-manager'
// Enrollment Management
export {
type CreateEnrollmentRequestParams,
type ApprovalResult,
type EnrollmentRequestWithRelations,
createEnrollmentRequest,
approveEnrollmentRequest,
denyEnrollmentRequest,
cancelEnrollmentRequest,
getPendingRequestsForClassroom,
getPendingRequestsForParent,
isEnrolled,
getEnrolledStudents,
unenrollStudent,
getEnrolledClassrooms,
getRequiredApprovals,
isFullyApproved,
isDenied,
} from './enrollment-manager'
// Presence Management
export {
type EnterClassroomParams,
type EnterClassroomResult,
type PresenceWithClassroom,
type PresenceWithPlayer,
enterClassroom,
leaveClassroom,
leaveSpecificClassroom,
clearClassroomPresence,
getStudentPresence,
isStudentPresent,
isStudentPresentIn,
getClassroomPresence,
getPresenceCount,
getPresentPlayerIds,
} from './presence-manager'
// Classroom Management
export {
type CreateClassroomParams,
type CreateClassroomResult,
type ClassroomWithTeacher,
type UpdateClassroomParams,
createClassroom,
getClassroom,
getTeacherClassroom,
isTeacher,
getClassroomByCode,
updateClassroom,
regenerateClassroomCode,
deleteClassroom,
generateClassroomCode,
} from './classroom-manager'

View File

@@ -0,0 +1,227 @@
/**
* Presence Manager Module
*
* Manages ephemeral "in classroom" state:
* - Enter student into classroom
* - Leave classroom
* - Query current presence
*
* Presence is different from enrollment:
* - Enrollment: persistent registration in a classroom
* - Presence: currently active in the classroom for a live session
*
* A student can only be present in one classroom at a time.
*/
import { and, eq } from 'drizzle-orm'
import { db } from '@/db'
import {
classroomEnrollments,
classroomPresence,
classrooms,
type ClassroomPresence,
type Classroom,
type Player,
} from '@/db/schema'
// ============================================================================
// Enter/Leave Classroom
// ============================================================================
export interface EnterClassroomParams {
playerId: string
classroomId: string
enteredBy: string
}
export interface EnterClassroomResult {
success: boolean
presence?: ClassroomPresence
error?: string
}
/**
* Enter a student into a classroom
*
* Requirements:
* - Student must be enrolled in the classroom
* - Student cannot be in another classroom (must leave first)
*
* If student is already in this classroom, the timestamp is updated.
*/
export async function enterClassroom(params: EnterClassroomParams): Promise<EnterClassroomResult> {
const { playerId, classroomId, enteredBy } = params
// Check if student is enrolled
const enrollment = await db.query.classroomEnrollments.findFirst({
where: and(
eq(classroomEnrollments.classroomId, classroomId),
eq(classroomEnrollments.playerId, playerId)
),
})
if (!enrollment) {
return { success: false, error: 'Student not enrolled in this classroom' }
}
// Check if already in another classroom
const currentPresence = await db.query.classroomPresence.findFirst({
where: eq(classroomPresence.playerId, playerId),
})
if (currentPresence && currentPresence.classroomId !== classroomId) {
return {
success: false,
error: 'Student is in another classroom. Must leave first.',
}
}
// Upsert presence
if (currentPresence) {
// Already in this classroom, update timestamp
const [updated] = await db
.update(classroomPresence)
.set({ enteredAt: new Date(), enteredBy })
.where(eq(classroomPresence.playerId, playerId))
.returning()
return { success: true, presence: updated }
}
// Insert new presence
const [inserted] = await db
.insert(classroomPresence)
.values({
playerId,
classroomId,
enteredBy,
})
.returning()
return { success: true, presence: inserted }
}
/**
* Remove a student from their current classroom
*/
export async function leaveClassroom(playerId: string): Promise<void> {
await db.delete(classroomPresence).where(eq(classroomPresence.playerId, playerId))
}
/**
* Remove a student from a specific classroom (if they're in it)
*/
export async function leaveSpecificClassroom(playerId: string, classroomId: string): Promise<void> {
await db
.delete(classroomPresence)
.where(
and(eq(classroomPresence.playerId, playerId), eq(classroomPresence.classroomId, classroomId))
)
}
/**
* Remove all students from a classroom
*
* Useful for "end class" functionality.
*/
export async function clearClassroomPresence(classroomId: string): Promise<number> {
const result = await db
.delete(classroomPresence)
.where(eq(classroomPresence.classroomId, classroomId))
.returning()
return result.length
}
// ============================================================================
// Query Presence
// ============================================================================
export interface PresenceWithClassroom extends ClassroomPresence {
classroom?: Classroom
}
export interface PresenceWithPlayer extends ClassroomPresence {
player?: Player
}
/**
* Get a student's current presence (which classroom they're in)
*/
export async function getStudentPresence(playerId: string): Promise<PresenceWithClassroom | null> {
const presence = await db.query.classroomPresence.findFirst({
where: eq(classroomPresence.playerId, playerId),
})
if (!presence) return null
const classroom = await db.query.classrooms.findFirst({
where: eq(classrooms.id, presence.classroomId),
})
return { ...presence, classroom }
}
/**
* Check if a student is present in any classroom
*/
export async function isStudentPresent(playerId: string): Promise<boolean> {
const presence = await db.query.classroomPresence.findFirst({
where: eq(classroomPresence.playerId, playerId),
})
return !!presence
}
/**
* Check if a student is present in a specific classroom
*/
export async function isStudentPresentIn(playerId: string, classroomId: string): Promise<boolean> {
const presence = await db.query.classroomPresence.findFirst({
where: and(
eq(classroomPresence.playerId, playerId),
eq(classroomPresence.classroomId, classroomId)
),
})
return !!presence
}
/**
* Get all students currently present in a classroom
*/
export async function getClassroomPresence(classroomId: string): Promise<PresenceWithPlayer[]> {
const presences = await db.query.classroomPresence.findMany({
where: eq(classroomPresence.classroomId, classroomId),
})
if (presences.length === 0) return []
const playerIds = presences.map((p) => p.playerId)
const players = await db.query.players.findMany({
where: (players, { inArray }) => inArray(players.id, playerIds),
})
const playerMap = new Map(players.map((p) => [p.id, p]))
return presences.map((p) => ({
...p,
player: playerMap.get(p.playerId),
}))
}
/**
* Get count of students present in a classroom
*/
export async function getPresenceCount(classroomId: string): Promise<number> {
const presences = await db.query.classroomPresence.findMany({
where: eq(classroomPresence.classroomId, classroomId),
})
return presences.length
}
/**
* Get all player IDs present in a classroom
*/
export async function getPresentPlayerIds(classroomId: string): Promise<string[]> {
const presences = await db.query.classroomPresence.findMany({
where: eq(classroomPresence.classroomId, classroomId),
})
return presences.map((p) => p.playerId)
}

View File

@@ -0,0 +1,141 @@
/**
* Classroom Socket Event Types
*
* These types define the Socket.IO events used for real-time classroom communication.
*
* Channel Patterns:
* - user:${userId} - User-specific notifications (enrollment requests)
* - classroom:${classroomId} - Classroom presence events (student entered/left)
* - session:${sessionId} - Practice session observation
*/
// ============================================================================
// Enrollment Events (sent to user:${userId} channel)
// ============================================================================
export interface EnrollmentRequestCreatedEvent {
request: {
id: string
classroomId: string
classroomName: string
playerId: string
playerName: string
requestedByRole: 'parent' | 'teacher'
}
}
export interface EnrollmentApprovedEvent {
classroomId: string
playerId: string
playerName: string
}
export interface EnrollmentDeniedEvent {
classroomId: string
playerId: string
deniedBy: 'teacher' | 'parent'
}
// ============================================================================
// Presence Events (sent to classroom:${classroomId} channel)
// ============================================================================
export interface StudentEnteredEvent {
playerId: string
playerName: string
enteredBy: string
}
export interface StudentLeftEvent {
playerId: string
playerName: string
}
// ============================================================================
// Session Observation Events (sent to session:${sessionId} channel)
// ============================================================================
export interface PracticeStateEvent {
sessionId: string
currentProblem: unknown // GeneratedProblem type from curriculum
phase: 'problem' | 'feedback' | 'tutorial'
studentAnswer: number | null
isCorrect: boolean | null
timing: {
startedAt: number
elapsed: number
}
}
export interface TutorialStateEvent {
sessionId: string
currentStep: number
totalSteps: number
content: unknown // TutorialStep type
}
export interface TutorialControlEvent {
sessionId: string
action: 'skip' | 'next' | 'previous'
}
export interface AbacusControlEvent {
sessionId: string
target: 'help' | 'hero'
action: 'show' | 'hide' | 'set-value'
value?: number
}
export interface ObserverJoinedEvent {
observerId: string
}
export interface SessionPausedEvent {
sessionId: string
reason: string
}
// ============================================================================
// Client-Side Event Map (for typed socket.io client)
// ============================================================================
/**
* Events the client can listen to
*/
export interface ClassroomServerToClientEvents {
// Enrollment events
'enrollment-request-created': (data: EnrollmentRequestCreatedEvent) => void
'enrollment-approved': (data: EnrollmentApprovedEvent) => void
'enrollment-denied': (data: EnrollmentDeniedEvent) => void
// Presence events
'student-entered': (data: StudentEnteredEvent) => void
'student-left': (data: StudentLeftEvent) => void
// Session observation events
'practice-state': (data: PracticeStateEvent) => void
'tutorial-state': (data: TutorialStateEvent) => void
'tutorial-control': (data: TutorialControlEvent) => void
'abacus-control': (data: AbacusControlEvent) => void
'observer-joined': (data: ObserverJoinedEvent) => void
'session-paused': (data: SessionPausedEvent) => void
}
/**
* Events the client can emit
*/
export interface ClassroomClientToServerEvents {
// Channel subscriptions
'join-classroom': (data: { classroomId: string }) => void
'leave-classroom': (data: { classroomId: string }) => void
'observe-session': (data: { sessionId: string; observerId: string }) => void
'stop-observing': (data: { sessionId: string }) => void
// Session state broadcasts (from student client)
'practice-state': (data: PracticeStateEvent) => void
'tutorial-state': (data: TutorialStateEvent) => void
// Observer controls
'tutorial-control': (data: TutorialControlEvent) => void
'abacus-control': (data: AbacusControlEvent) => void
}

View File

@@ -9,8 +9,9 @@
import 'server-only'
import { eq } from 'drizzle-orm'
import { eq, inArray, or } from 'drizzle-orm'
import { db, schema } from '@/db'
import { parentChild } from '@/db/schema'
import type { Player } from '@/db/schema/players'
import { getPlayer } from '@/lib/arcade/player-manager'
import { getViewerId } from '@/lib/viewer'
@@ -179,11 +180,25 @@ export async function getPlayersWithSkillData(): Promise<StudentWithSkillData[]>
user = newUser
}
// Get all players for this user
const players = await db.query.players.findMany({
where: eq(schema.players.userId, user.id),
orderBy: (players, { desc }) => [desc(players.createdAt)],
// Get player IDs linked via parent_child table
const linkedPlayerIds = await db.query.parentChild.findMany({
where: eq(parentChild.parentUserId, user.id),
})
const linkedIds = linkedPlayerIds.map((link) => link.childPlayerId)
// Get all players: created by this user OR linked via parent_child
let players: Player[]
if (linkedIds.length > 0) {
players = await db.query.players.findMany({
where: or(eq(schema.players.userId, user.id), inArray(schema.players.id, linkedIds)),
orderBy: (players, { desc }) => [desc(players.createdAt)],
})
} else {
players = await db.query.players.findMany({
where: eq(schema.players.userId, user.id),
orderBy: (players, { desc }) => [desc(players.createdAt)],
})
}
// Fetch skill mastery for all players in parallel
const playersWithSkills = await Promise.all(

View File

@@ -720,6 +720,100 @@ export function initializeSocketServer(httpServer: HTTPServer) {
}
)
// Classroom: Join classroom channel (for teachers to receive presence updates)
socket.on('join-classroom', async ({ classroomId }: { classroomId: string }) => {
try {
await socket.join(`classroom:${classroomId}`)
console.log(`🏫 User joined classroom channel: ${classroomId}`)
} catch (error) {
console.error('Error joining classroom channel:', error)
}
})
// Classroom: Leave classroom channel
socket.on('leave-classroom', async ({ classroomId }: { classroomId: string }) => {
try {
await socket.leave(`classroom:${classroomId}`)
console.log(`🏫 User left classroom channel: ${classroomId}`)
} catch (error) {
console.error('Error leaving classroom channel:', error)
}
})
// Session Observation: Start observing a practice session
socket.on(
'observe-session',
async ({ sessionId, observerId }: { sessionId: string; observerId: string }) => {
try {
await socket.join(`session:${sessionId}`)
console.log(`👁️ Observer ${observerId} started watching session: ${sessionId}`)
// Notify session that an observer joined
socket.to(`session:${sessionId}`).emit('observer-joined', { observerId })
} catch (error) {
console.error('Error starting session observation:', error)
}
}
)
// Session Observation: Stop observing a practice session
socket.on('stop-observing', async ({ sessionId }: { sessionId: string }) => {
try {
await socket.leave(`session:${sessionId}`)
console.log(`👁️ Observer stopped watching session: ${sessionId}`)
} catch (error) {
console.error('Error stopping session observation:', error)
}
})
// Session Observation: Broadcast practice state (from student's client)
socket.on(
'practice-state',
(data: {
sessionId: string
currentProblem: unknown
phase: 'problem' | 'feedback' | 'tutorial'
studentAnswer: number | null
isCorrect: boolean | null
timing: { startedAt: number; elapsed: number }
}) => {
// Broadcast to all observers in the session channel
socket.to(`session:${data.sessionId}`).emit('practice-state', data)
}
)
// Session Observation: Broadcast tutorial state (from student's client)
socket.on(
'tutorial-state',
(data: { sessionId: string; currentStep: number; totalSteps: number; content: unknown }) => {
// Broadcast to all observers in the session channel
socket.to(`session:${data.sessionId}`).emit('tutorial-state', data)
}
)
// Session Observation: Tutorial control from observer
socket.on(
'tutorial-control',
(data: { sessionId: string; action: 'skip' | 'next' | 'previous' }) => {
// Send control command to student's client
io!.to(`session:${data.sessionId}`).emit('tutorial-control', data)
}
)
// Session Observation: Abacus control from observer
socket.on(
'abacus-control',
(data: {
sessionId: string
target: 'help' | 'hero'
action: 'show' | 'hide' | 'set-value'
value?: number
}) => {
// Send control command to student's client
io!.to(`session:${data.sessionId}`).emit('abacus-control', data)
}
)
socket.on('disconnect', () => {
// Don't delete session on disconnect - it persists across devices
})