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:
130
apps/web/drizzle/0041_classroom-system.sql
Normal file
130
apps/web/drizzle/0041_classroom-system.sql
Normal 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`;
|
||||
25
apps/web/drizzle/0042_classroom-system-indexes.sql
Normal file
25
apps/web/drizzle/0042_classroom-system-indexes.sql
Normal 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;
|
||||
1038
apps/web/drizzle/meta/0041_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0041_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1038
apps/web/drizzle/meta/0042_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0042_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
107
apps/web/src/app/api/classrooms/[classroomId]/route.ts
Normal file
107
apps/web/src/app/api/classrooms/[classroomId]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
43
apps/web/src/app/api/classrooms/code/[code]/route.ts
Normal file
43
apps/web/src/app/api/classrooms/code/[code]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
26
apps/web/src/app/api/classrooms/mine/route.ts
Normal file
26
apps/web/src/app/api/classrooms/mine/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
57
apps/web/src/app/api/classrooms/route.ts
Normal file
57
apps/web/src/app/api/classrooms/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
25
apps/web/src/app/api/enrollment-requests/pending/route.ts
Normal file
25
apps/web/src/app/api/enrollment-requests/pending/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
33
apps/web/src/app/api/family/children/route.ts
Normal file
33
apps/web/src/app/api/family/children/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
59
apps/web/src/app/api/family/link/route.ts
Normal file
59
apps/web/src/app/api/family/link/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
32
apps/web/src/app/api/players/[id]/access/route.ts
Normal file
32
apps/web/src/app/api/players/[id]/access/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
33
apps/web/src/app/api/players/[id]/presence/route.ts
Normal file
33
apps/web/src/app/api/players/[id]/presence/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
26
apps/web/src/app/api/players/accessible/route.ts
Normal file
26
apps/web/src/app/api/players/accessible/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
318
apps/web/src/components/family/FamilyCodeDisplay.tsx
Normal file
318
apps/web/src/components/family/FamilyCodeDisplay.tsx
Normal 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}'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>
|
||||
)
|
||||
}
|
||||
278
apps/web/src/components/family/LinkChildForm.tsx
Normal file
278
apps/web/src/components/family/LinkChildForm.tsx
Normal 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}'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>
|
||||
)
|
||||
}
|
||||
2
apps/web/src/components/family/index.ts
Normal file
2
apps/web/src/components/family/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { FamilyCodeDisplay } from './FamilyCodeDisplay'
|
||||
export { LinkChildForm } from './LinkChildForm'
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
53
apps/web/src/db/schema/classroom-enrollments.ts
Normal file
53
apps/web/src/db/schema/classroom-enrollments.ts
Normal 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
|
||||
51
apps/web/src/db/schema/classroom-presence.ts
Normal file
51
apps/web/src/db/schema/classroom-presence.ts
Normal 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
|
||||
56
apps/web/src/db/schema/classrooms.ts
Normal file
56
apps/web/src/db/schema/classrooms.ts
Normal 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
|
||||
}
|
||||
156
apps/web/src/db/schema/enrollment-requests.ts
Normal file
156
apps/web/src/db/schema/enrollment-requests.ts
Normal 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'
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
36
apps/web/src/db/schema/parent-child.ts
Normal file
36
apps/web/src/db/schema/parent-child.ts
Normal 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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
111
apps/web/src/hooks/useClassroom.ts
Normal file
111
apps/web/src/hooks/useClassroom.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
229
apps/web/src/lib/classroom/access-control.ts
Normal file
229
apps/web/src/lib/classroom/access-control.ts
Normal 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
|
||||
}
|
||||
257
apps/web/src/lib/classroom/classroom-manager.ts
Normal file
257
apps/web/src/lib/classroom/classroom-manager.ts
Normal 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'
|
||||
394
apps/web/src/lib/classroom/enrollment-manager.ts
Normal file
394
apps/web/src/lib/classroom/enrollment-manager.ts
Normal 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'
|
||||
208
apps/web/src/lib/classroom/family-manager.ts
Normal file
208
apps/web/src/lib/classroom/family-manager.ts
Normal 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'
|
||||
94
apps/web/src/lib/classroom/index.ts
Normal file
94
apps/web/src/lib/classroom/index.ts
Normal 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'
|
||||
227
apps/web/src/lib/classroom/presence-manager.ts
Normal file
227
apps/web/src/lib/classroom/presence-manager.ts
Normal 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)
|
||||
}
|
||||
141
apps/web/src/lib/classroom/socket-events.ts
Normal file
141
apps/web/src/lib/classroom/socket-events.ts
Normal 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
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user