sync-docs-and-fixes

This commit is contained in:
SpecialX
2026-03-03 17:32:26 +08:00
parent 538805bad0
commit eb08c0ab68
73 changed files with 2218 additions and 422 deletions

View File

@@ -1,7 +1,7 @@
"use server";
import { revalidatePath } from "next/cache"
import { and, eq, sql, or, inArray } from "drizzle-orm"
import { and, eq, sql, or } from "drizzle-orm"
import { auth } from "@/auth"
import { db } from "@/shared/db"
@@ -16,6 +16,7 @@ import {
deleteTeacherClass,
enrollStudentByEmail,
enrollStudentByInvitationCode,
enrollTeacherByInvitationCode,
ensureClassInvitationCode,
regenerateClassInvitationCode,
setClassSubjectTeachers,
@@ -371,8 +372,18 @@ export async function joinClassByInvitationCodeAction(
return { success: false, message: "Unauthorized" }
}
const subjectValue = formData.get("subject")
const subject = role === "teacher" && typeof subjectValue === "string" ? subjectValue.trim() : null
if (role === "teacher" && (!subject || subject.length === 0)) {
return { success: false, message: "Subject is required" }
}
try {
const classId = await enrollStudentByInvitationCode(session.user.id, code)
const classId =
role === "teacher"
? await enrollTeacherByInvitationCode(session.user.id, code, subject)
: await enrollStudentByInvitationCode(session.user.id, code)
if (role === "student") {
revalidatePath("/student/learning/courses")
revalidatePath("/student/schedule")

View File

@@ -1,6 +1,6 @@
import Link from "next/link"
import { Calendar, FilePlus, Mail, MessageSquare, Settings } from "lucide-react"
import { Calendar, FilePlus, MessageSquare, Settings } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"

View File

@@ -42,7 +42,7 @@ export function ClassScheduleGrid({ schedule, compact = false }: { schedule: Cla
return (
<div className="grid grid-cols-5 gap-1 text-center h-full grid-rows-[auto_1fr]">
{WEEKDAYS.slice(0, 5).map((day, i) => (
{WEEKDAYS.slice(0, 5).map((day) => (
<div key={day} className="text-[10px] font-medium text-muted-foreground uppercase py-0.5 border-b bg-muted/20 h-fit">
{day}
</div>

View File

@@ -6,7 +6,6 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avat
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { formatDate } from "@/shared/lib/utils"
interface StudentSummary {
id: string

View File

@@ -1,13 +1,12 @@
"use client"
import { useState, useMemo } from "react"
import { useState } from "react"
import { Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
import { ChevronDown } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/shared/components/ui/chart"
import { Tabs, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
import { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
@@ -31,7 +30,6 @@ interface AssignmentSummary {
}
interface ClassTrendsWidgetProps {
classId: string
assignments: AssignmentSummary[]
compact?: boolean
className?: string
@@ -121,7 +119,7 @@ export function ClassSubmissionTrendChart({
)
}
export function ClassTrendsWidget({ classId, assignments, compact, className }: ClassTrendsWidgetProps) {
export function ClassTrendsWidget({ assignments, compact, className }: ClassTrendsWidgetProps) {
const [chartTab, setChartTab] = useState<"submission" | "score">("submission")
const [selectedSubject, setSelectedSubject] = useState<string>("all")

View File

@@ -1,7 +1,7 @@
"use client"
import Link from "next/link"
import { useMemo, useState } from "react"
import { useState } from "react"
import { useRouter } from "next/navigation"
import {
Plus,
@@ -10,11 +10,9 @@ import {
Users,
MapPin,
GraduationCap,
Search,
} from "lucide-react"
import { toast } from "sonner"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
import { Badge } from "@/shared/components/ui/badge"
import { EmptyState } from "@/shared/components/ui/empty-state"
@@ -30,30 +28,35 @@ import {
} from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/components/ui/tooltip"
import type { TeacherClass, ClassScheduleItem } from "../types"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import type { TeacherClass } from "../types"
import {
ensureClassInvitationCodeAction,
regenerateClassInvitationCodeAction,
joinClassByInvitationCodeAction,
} from "../actions"
const GRADIENTS = [
"bg-card border-border",
"bg-card border-border",
"bg-card border-border",
"bg-card border-border",
"bg-card border-border",
]
function getClassGradient(id: string) {
return "bg-card border-border shadow-sm hover:shadow-md"
const getSeededValue = (seed: string, index: number) => {
let h = 2166136261
const str = `${seed}:${index}`
for (let i = 0; i < str.length; i += 1) {
h ^= str.charCodeAt(i)
h = Math.imul(h, 16777619)
}
return (h >>> 0) / 4294967296
}
export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherClass[]; canCreateClass: boolean }) {
export function MyClassesGrid({
classes,
subjectOptions,
}: {
classes: TeacherClass[]
subjectOptions: string[]
}) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [joinOpen, setJoinOpen] = useState(false)
const [joinSubject, setJoinSubject] = useState("")
const handleJoin = async (formData: FormData) => {
setIsWorking(true)
@@ -62,6 +65,7 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
if (res.success) {
toast.success(res.message || "Joined class successfully")
setJoinOpen(false)
setJoinSubject("")
router.refresh()
} else {
toast.error(res.message || "Failed to join class")
@@ -83,6 +87,7 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
onOpenChange={(open) => {
if (isWorking) return
setJoinOpen(open)
if (!open) setJoinSubject("")
}}
>
<DialogTrigger asChild>
@@ -140,12 +145,30 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
Ask your administrator for the code if you don&apos;t have one.
</p>
</div>
<div className="space-y-3">
<Label htmlFor="join-subject" className="text-sm font-medium">
</Label>
<Select value={joinSubject} onValueChange={(v) => setJoinSubject(v)}>
<SelectTrigger id="join-subject" className="h-12">
<SelectValue placeholder={subjectOptions.length === 0 ? "暂无可选科目" : "选择教学科目"} />
</SelectTrigger>
<SelectContent>
{subjectOptions.map((subject) => (
<SelectItem key={subject} value={subject}>
{subject}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="subject" value={joinSubject} />
</div>
</div>
<DialogFooter className="p-6 pt-2 bg-muted/5 border-t border-border/50">
<Button type="button" variant="ghost" onClick={() => setJoinOpen(false)} disabled={isWorking}>
Cancel
</Button>
<Button type="submit" disabled={isWorking} className="min-w-[100px]">
<Button type="submit" disabled={isWorking || !joinSubject || subjectOptions.length === 0} className="min-w-[100px]">
{isWorking ? "Joining..." : "Join Class"}
</Button>
</DialogFooter>
@@ -167,7 +190,7 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
/>
) : (
classes.map((c) => (
<ClassTicket key={c.id} c={c} onWorkingChange={setIsWorking} isWorking={isWorking} />
<ClassTicket key={c.id} c={c} onWorkingChange={setIsWorking} />
))
)}
</div>
@@ -182,11 +205,9 @@ import { ClassTrendsWidget } from "./class-detail/class-trends-widget"
function ClassTicket({
c,
isWorking,
onWorkingChange,
}: {
c: TeacherClass
isWorking: boolean
onWorkingChange: (v: boolean) => void
}) {
const router = useRouter()
@@ -256,7 +277,11 @@ function ClassTicket({
{/* Decorative Barcode Strip */}
<div className="absolute left-0 top-0 bottom-0 w-1.5 bg-primary/10 flex flex-col justify-between py-2 pointer-events-none">
{Array.from({ length: 20 }).map((_, i) => (
<div key={i} className="w-full h-px bg-primary/20" style={{ marginBottom: Math.random() * 8 + 2 + 'px' }}></div>
<div
key={i}
className="w-full h-px bg-primary/20"
style={{ marginBottom: `${2 + getSeededValue(c.id, i) * 8}px` }}
></div>
))}
</div>
@@ -320,7 +345,7 @@ function ClassTicket({
<div className="absolute right-10 top-1/2 -translate-y-1/2 opacity-[0.03]">
<div className="w-8 h-8 bg-current grid grid-cols-4 grid-rows-4 gap-px">
{Array.from({ length: 16 }).map((_, i) => (
<div key={i} className={cn("bg-transparent", Math.random() > 0.5 && "bg-black")}></div>
<div key={i} className={cn("bg-transparent", getSeededValue(`${c.id}-qr`, i) > 0.5 && "bg-black")}></div>
))}
</div>
</div>
@@ -373,12 +398,7 @@ function ClassTicket({
{/* Real Chart */}
<div className="h-[140px] w-full">
<ClassTrendsWidget
classId={c.id}
assignments={recentAssignments}
compact
className="h-full w-full"
/>
<ClassTrendsWidget assignments={recentAssignments} compact className="h-full w-full" />
</div>
</div>

View File

@@ -2,11 +2,9 @@
import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { Clock, MapPin, MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button"
import {
@@ -518,4 +516,4 @@ export function ScheduleView({
</AlertDialog>
</div>
)
}
}

View File

@@ -1,9 +1,9 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { useQueryState, parseAsString } from "nuqs"
import { Search, UserPlus, X, ChevronDown, Check } from "lucide-react"
import { Search, UserPlus, ChevronDown, Check } from "lucide-react"
import { toast } from "sonner"
import { Input } from "@/shared/components/ui/input"
@@ -33,7 +33,6 @@ import {
SelectValue,
} from "@/shared/components/ui/select"
import { Label } from "@/shared/components/ui/label"
import { cn } from "@/shared/lib/utils"
import type { TeacherClass } from "../types"
import { enrollStudentByEmailAction } from "../actions"
@@ -78,8 +77,6 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
const statusLabel = status === "all" ? "All Status" : (status === "active" ? "Active" : "Inactive")
const hasFilters = search || classId !== "all" || status !== "all"
return (
<div className="flex items-center justify-between py-2">
<div className="flex items-center gap-2">

View File

@@ -5,11 +5,10 @@ import { useRouter } from "next/navigation"
import { MoreHorizontal, UserCheck, UserX } from "lucide-react"
import { toast } from "sonner"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
import { Card, CardContent, CardFooter, CardHeader } from "@/shared/components/ui/card"
import { cn, formatDate } from "@/shared/lib/utils"
import { cn } from "@/shared/lib/utils"
import {
DropdownMenu,
DropdownMenuContent,

View File

@@ -2,9 +2,10 @@ import "server-only";
import { randomInt } from "node:crypto"
import { cache } from "react"
import { and, asc, desc, eq, inArray, or, sql, type SQL } from "drizzle-orm"
import { and, asc, desc, eq, inArray, isNull, or, sql, type SQL } from "drizzle-orm"
import { createId } from "@paralleldrive/cuid2"
import { auth } from "@/auth"
import { db } from "@/shared/db"
import {
classes,
@@ -19,7 +20,9 @@ import {
schools,
subjects,
exams,
roles,
users,
usersToRoles,
} from "@/shared/db/schema"
import { DEFAULT_CLASS_SUBJECTS } from "./types"
import type {
@@ -43,16 +46,22 @@ import type {
UpdateTeacherClassInput,
} from "./types"
const getDefaultTeacherId = cache(async () => {
const [row] = await db
const getSessionTeacherId = async (): Promise<string | null> => {
const session = await auth()
const userId = String(session?.user?.id ?? "").trim()
if (!userId) return null
const [teacher] = await db
.select({ id: users.id })
.from(users)
.where(eq(users.role, "teacher"))
.orderBy(asc(users.createdAt))
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(and(eq(users.id, userId), eq(roles.name, "teacher")))
.limit(1)
return teacher?.id ?? null
}
return row?.id
})
// Strict subjectId-based mapping: no aliasing
const isDuplicateInvitationCodeError = (err: unknown) => {
if (!err) return false
@@ -80,11 +89,20 @@ const generateUniqueInvitationCode = async (): Promise<string> => {
}
export const getTeacherIdForMutations = async (): Promise<string> => {
const teacherId = await getDefaultTeacherId()
if (!teacherId) throw new Error("No teacher available")
const teacherId = await getSessionTeacherId()
if (!teacherId) throw new Error("Teacher not found")
return teacherId
}
export const getClassSubjects = async (): Promise<string[]> => {
const rows = await db.query.subjects.findMany({
orderBy: (subjects, { asc }) => [asc(subjects.order), asc(subjects.name)],
})
const names = rows.map((r) => r.name.trim()).filter((n) => n.length > 0)
return Array.from(new Set(names))
}
const normalizeSortText = (v: string | null | undefined) => (typeof v === "string" ? v.trim().toLowerCase() : "")
const parseFirstInt = (v: string) => {
@@ -118,23 +136,30 @@ const compareClassLike = (
return normalizeSortText(a.room).localeCompare(normalizeSortText(b.room))
}
const getAccessibleClassIdsForTeacher = async (teacherId: string): Promise<string[]> => {
const ownedIds = await db.select({ id: classes.id }).from(classes).where(eq(classes.teacherId, teacherId))
const assignedIds = await db
.select({ id: classSubjectTeachers.classId })
.from(classSubjectTeachers)
.where(eq(classSubjectTeachers.teacherId, teacherId))
return Array.from(new Set([...ownedIds.map((x) => x.id), ...assignedIds.map((x) => x.id)]))
}
const getTeacherSubjectIdsForClass = async (teacherId: string, classId: string): Promise<string[]> => {
const rows = await db
.select({ subjectId: classSubjectTeachers.subjectId })
.from(classSubjectTeachers)
.where(and(eq(classSubjectTeachers.teacherId, teacherId), eq(classSubjectTeachers.classId, classId)))
return Array.from(new Set(rows.map((r) => String(r.subjectId))))
}
export const getTeacherClasses = cache(async (params?: { teacherId?: string }): Promise<TeacherClass[]> => {
const teacherId = params?.teacherId ?? (await getDefaultTeacherId())
const teacherId = params?.teacherId ?? (await getSessionTeacherId())
if (!teacherId) return []
const rows = await (async () => {
try {
const ownedIds = await db
.select({ id: classes.id })
.from(classes)
.where(eq(classes.teacherId, teacherId))
const enrolledIds = await db
.select({ id: classEnrollments.classId })
.from(classEnrollments)
.where(and(eq(classEnrollments.studentId, teacherId), eq(classEnrollments.status, "active")))
const allIds = Array.from(new Set([...ownedIds.map((x) => x.id), ...enrolledIds.map((x) => x.id)]))
const allIds = await getAccessibleClassIdsForTeacher(teacherId)
if (allIds.length === 0) return []
@@ -206,7 +231,9 @@ export const getTeacherOptions = cache(async (): Promise<TeacherOption[]> => {
const rows = await db
.select({ id: users.id, name: users.name, email: users.email })
.from(users)
.where(eq(users.role, "teacher"))
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(eq(roles.name, "teacher"))
.orderBy(asc(users.createdAt))
return rows.map((r) => ({
@@ -216,6 +243,23 @@ export const getTeacherOptions = cache(async (): Promise<TeacherOption[]> => {
}))
})
export const getTeacherTeachingSubjects = cache(async (): Promise<ClassSubject[]> => {
const teacherId = await getSessionTeacherId()
if (!teacherId) return []
const rows = await db
.select({ subject: subjects.name })
.from(classSubjectTeachers)
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
.where(eq(classSubjectTeachers.teacherId, teacherId))
.groupBy(subjects.name)
.orderBy(asc(subjects.name))
return rows
.map((r) => r.subject as ClassSubject)
.filter((s) => DEFAULT_CLASS_SUBJECTS.includes(s))
})
export const getAdminClasses = cache(async (): Promise<AdminClassListItem[]> => {
const [rows, subjectRows] = await Promise.all([
(async () => {
@@ -304,14 +348,15 @@ export const getAdminClasses = cache(async (): Promise<AdminClassListItem[]> =>
db
.select({
classId: classSubjectTeachers.classId,
subject: classSubjectTeachers.subject,
subject: subjects.name,
teacherId: users.id,
teacherName: users.name,
teacherEmail: users.email,
})
.from(classSubjectTeachers)
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
.leftJoin(users, eq(users.id, classSubjectTeachers.teacherId))
.orderBy(asc(classSubjectTeachers.classId), asc(classSubjectTeachers.subject)),
.orderBy(asc(classSubjectTeachers.classId), asc(subjects.name)),
])
const subjectsByClassId = new Map<string, Map<ClassSubject, TeacherOption | null>>()
@@ -425,16 +470,17 @@ export const getGradeManagedClasses = cache(async (userId: string): Promise<Admi
db
.select({
classId: classSubjectTeachers.classId,
subject: classSubjectTeachers.subject,
subject: subjects.name,
teacherId: users.id,
teacherName: users.name,
teacherEmail: users.email,
})
.from(classSubjectTeachers)
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
.innerJoin(classes, eq(classes.id, classSubjectTeachers.classId))
.leftJoin(users, eq(users.id, classSubjectTeachers.teacherId))
.where(inArray(classes.gradeId, gradeIds))
.orderBy(asc(classSubjectTeachers.classId), asc(classSubjectTeachers.subject)),
.orderBy(asc(classSubjectTeachers.classId), asc(subjects.name)),
])
const subjectsByClassId = new Map<string, Map<ClassSubject, TeacherOption | null>>()
@@ -589,14 +635,17 @@ export const getStudentSchedule = cache(async (studentId: string): Promise<Stude
export const getClassStudents = cache(
async (params?: { classId?: string; q?: string; status?: string; teacherId?: string }): Promise<ClassStudent[]> => {
const teacherId = params?.teacherId ?? (await getDefaultTeacherId())
const teacherId = params?.teacherId ?? (await getSessionTeacherId())
if (!teacherId) return []
const classId = params?.classId?.trim()
const q = params?.q?.trim().toLowerCase()
const status = params?.status?.trim().toLowerCase()
const conditions: SQL[] = [eq(classes.teacherId, teacherId)]
const accessibleIds = await getAccessibleClassIdsForTeacher(teacherId)
if (accessibleIds.length === 0) return []
const conditions: SQL[] = [inArray(classes.id, accessibleIds)]
if (classId) {
conditions.push(eq(classes.id, classId))
@@ -647,12 +696,15 @@ export const getClassStudents = cache(
export const getClassSchedule = cache(
async (params?: { classId?: string; teacherId?: string }): Promise<ClassScheduleItem[]> => {
const teacherId = params?.teacherId ?? (await getDefaultTeacherId())
const teacherId = params?.teacherId ?? (await getSessionTeacherId())
if (!teacherId) return []
const classId = params?.classId?.trim()
const conditions: SQL[] = [eq(classes.teacherId, teacherId)]
const accessibleIds = await getAccessibleClassIdsForTeacher(teacherId)
if (accessibleIds.length === 0) return []
const conditions: SQL[] = [inArray(classes.id, accessibleIds)]
if (classId) conditions.push(eq(classSchedule.classId, classId))
const rows = await db
@@ -707,11 +759,13 @@ const toScoreStats = (scores: number[]): ScoreStats => {
export const getClassHomeworkInsights = cache(
async (params: { classId: string; teacherId?: string; limit?: number }): Promise<ClassHomeworkInsights | null> => {
const teacherId = params.teacherId ?? (await getDefaultTeacherId())
const teacherId = params.teacherId ?? (await getSessionTeacherId())
if (!teacherId) return null
const classId = params.classId.trim()
if (!classId) return null
const accessibleIds = await getAccessibleClassIdsForTeacher(teacherId)
if (accessibleIds.length === 0 || !accessibleIds.includes(classId)) return null
const [classRow] = await db
.select({
@@ -721,12 +775,15 @@ export const getClassHomeworkInsights = cache(
homeroom: classes.homeroom,
room: classes.room,
invitationCode: classes.invitationCode,
teacherId: classes.teacherId,
})
.from(classes)
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
.where(and(eq(classes.id, classId), inArray(classes.id, accessibleIds)))
.limit(1)
if (!classRow) return null
const isHomeroomTeacher = classRow.teacherId === teacherId
const subjectIdFilter = isHomeroomTeacher ? [] : await getTeacherSubjectIdsForClass(teacherId, classId)
const enrollments = await db
.select({
@@ -735,12 +792,29 @@ export const getClassHomeworkInsights = cache(
})
.from(classEnrollments)
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
.where(and(eq(classes.teacherId, teacherId), eq(classEnrollments.classId, classId)))
.where(and(inArray(classes.id, accessibleIds), eq(classEnrollments.classId, classId)))
const activeStudentIds = enrollments.filter((e) => e.status === "active").map((e) => e.studentId)
const inactiveStudentIds = enrollments.filter((e) => e.status !== "active").map((e) => e.studentId)
const studentIds = enrollments.map((e) => e.studentId)
if (!isHomeroomTeacher && subjectIdFilter.length === 0) {
return {
class: {
id: classRow.id,
name: classRow.name,
grade: classRow.grade,
homeroom: classRow.homeroom,
room: classRow.room,
invitationCode: classRow.invitationCode ?? null,
},
studentCounts: { total: studentIds.length, active: activeStudentIds.length, inactive: inactiveStudentIds.length },
assignments: [],
latest: null,
overallScores: { count: 0, avg: null, median: null, min: null, max: null },
}
}
if (studentIds.length === 0) {
return {
class: {
@@ -782,6 +856,10 @@ export const getClassHomeworkInsights = cache(
}
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50
const assignmentConditions: SQL[] = [inArray(homeworkAssignments.id, assignmentIds)]
if (subjectIdFilter.length > 0) {
assignmentConditions.push(inArray(exams.subjectId, subjectIdFilter))
}
const assignments = await db
.select({
id: homeworkAssignments.id,
@@ -795,7 +873,7 @@ export const getClassHomeworkInsights = cache(
.from(homeworkAssignments)
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
.where(and(inArray(homeworkAssignments.id, assignmentIds), eq(homeworkAssignments.creatorId, teacherId)))
.where(and(...assignmentConditions))
.orderBy(desc(homeworkAssignments.createdAt))
.limit(limit)
@@ -1239,6 +1317,12 @@ export async function createTeacherClass(data: CreateTeacherClassInput): Promise
for (let attempt = 0; attempt < 20; attempt += 1) {
const invitationCode = await generateUniqueInvitationCode()
try {
const subjectRows = await db
.select({ id: subjects.id, name: subjects.name })
.from(subjects)
.where(inArray(subjects.name, DEFAULT_CLASS_SUBJECTS))
const idByName = new Map(subjectRows.map((r) => [r.name as ClassSubject, r.id]))
await db.transaction(async (tx) => {
await tx.insert(classes).values({
id,
@@ -1253,13 +1337,14 @@ export async function createTeacherClass(data: CreateTeacherClassInput): Promise
teacherId,
})
await tx.insert(classSubjectTeachers).values(
DEFAULT_CLASS_SUBJECTS.map((subject) => ({
const values = DEFAULT_CLASS_SUBJECTS
.filter((name) => idByName.has(name))
.map((name) => ({
classId: id,
subject,
subjectId: idByName.get(name)!,
teacherId: null,
}))
)
await tx.insert(classSubjectTeachers).values(values)
})
return id
} catch (err) {
@@ -1291,13 +1376,21 @@ export async function createAdminClass(data: CreateTeacherClassInput & { teacher
const [teacher] = await db
.select({ id: users.id })
.from(users)
.where(and(eq(users.id, teacherId), eq(users.role, "teacher")))
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(and(eq(users.id, teacherId), eq(roles.name, "teacher")))
.limit(1)
if (!teacher) throw new Error("Teacher not found")
for (let attempt = 0; attempt < 20; attempt += 1) {
const invitationCode = await generateUniqueInvitationCode()
try {
const subjectRows = await db
.select({ id: subjects.id, name: subjects.name })
.from(subjects)
.where(inArray(subjects.name, DEFAULT_CLASS_SUBJECTS))
const idByName = new Map(subjectRows.map((r) => [r.name as ClassSubject, r.id]))
await db.transaction(async (tx) => {
await tx.insert(classes).values({
id,
@@ -1312,13 +1405,14 @@ export async function createAdminClass(data: CreateTeacherClassInput & { teacher
teacherId,
})
await tx.insert(classSubjectTeachers).values(
DEFAULT_CLASS_SUBJECTS.map((subject) => ({
const values = DEFAULT_CLASS_SUBJECTS
.filter((name) => idByName.has(name))
.map((name) => ({
classId: id,
subject,
subjectId: idByName.get(name)!,
teacherId: null,
}))
)
await tx.insert(classSubjectTeachers).values(values)
})
return id
} catch (err) {
@@ -1410,6 +1504,123 @@ export async function enrollStudentByInvitationCode(studentId: string, invitatio
return cls.id
}
export async function enrollTeacherByInvitationCode(
teacherId: string,
invitationCode: string,
subject: string | null
): Promise<string> {
const tid = teacherId.trim()
const code = invitationCode.trim()
if (!tid) throw new Error("Missing teacher id")
if (!/^\d{6}$/.test(code)) throw new Error("Invalid invitation code")
const [teacher] = await db
.select({ id: users.id })
.from(users)
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(and(eq(users.id, tid), eq(roles.name, "teacher")))
.limit(1)
if (!teacher) throw new Error("Teacher not found")
const [cls] = await db
.select({ id: classes.id, teacherId: classes.teacherId })
.from(classes)
.where(eq(classes.invitationCode, code))
.limit(1)
if (!cls) throw new Error("Invalid invitation code")
if (cls.teacherId === tid) return cls.id
const subjectValue = typeof subject === "string" ? subject.trim() : ""
const [existing] = await db
.select({ id: classSubjectTeachers.classId })
.from(classSubjectTeachers)
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.teacherId, tid)))
.limit(1)
if (existing && !subjectValue) return cls.id
if (subjectValue) {
const [subRow] = await db.select({ id: subjects.id }).from(subjects).where(eq(subjects.name, subjectValue)).limit(1)
if (!subRow) throw new Error("Subject not found")
const sid = subRow.id
const [mapping] = await db
.select({ teacherId: classSubjectTeachers.teacherId })
.from(classSubjectTeachers)
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid)))
.limit(1)
if (mapping?.teacherId && mapping.teacherId !== tid) throw new Error("Subject already assigned")
if (mapping?.teacherId === tid) return cls.id
if (!mapping) {
await db
.insert(classSubjectTeachers)
.values({ classId: cls.id, subjectId: sid, teacherId: null })
.onDuplicateKeyUpdate({ set: { teacherId: sql`${classSubjectTeachers.teacherId}` } })
}
const [existingSubject] = await db
.select({ id: classSubjectTeachers.classId })
.from(classSubjectTeachers)
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), eq(classSubjectTeachers.teacherId, tid)))
.limit(1)
if (existingSubject) return cls.id
await db
.update(classSubjectTeachers)
.set({ teacherId: tid })
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), isNull(classSubjectTeachers.teacherId)))
const [assigned] = await db
.select({ id: classSubjectTeachers.classId })
.from(classSubjectTeachers)
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), eq(classSubjectTeachers.teacherId, tid)))
.limit(1)
if (!assigned) throw new Error("Subject already assigned")
} else {
const subjectRows = await db
.select({ id: classSubjectTeachers.subjectId, name: subjects.name })
.from(classSubjectTeachers)
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
.where(and(eq(classSubjectTeachers.classId, cls.id), isNull(classSubjectTeachers.teacherId)))
const preferred = DEFAULT_CLASS_SUBJECTS.find((s) => subjectRows.some((r) => r.name === s))
if (!preferred) throw new Error("Class already has assigned teachers")
const sid = subjectRows.find((r) => r.name === preferred)!.id
await db
.update(classSubjectTeachers)
.set({ teacherId: tid })
.where(
and(
eq(classSubjectTeachers.classId, cls.id),
eq(classSubjectTeachers.subjectId, sid),
isNull(classSubjectTeachers.teacherId)
)
)
const [assigned] = await db
.select({ id: classSubjectTeachers.classId })
.from(classSubjectTeachers)
.where(
and(
eq(classSubjectTeachers.classId, cls.id),
eq(classSubjectTeachers.subjectId, sid),
eq(classSubjectTeachers.teacherId, tid)
)
)
.limit(1)
if (!assigned) throw new Error("Class already has assigned teachers")
}
return cls.id
}
export async function updateTeacherClass(classId: string, data: UpdateTeacherClassInput): Promise<void> {
const teacherId = await getTeacherIdForMutations()
@@ -1468,7 +1679,9 @@ export async function updateAdminClass(
const [teacher] = await db
.select({ id: users.id })
.from(users)
.where(and(eq(users.id, nextTeacherId), eq(users.role, "teacher")))
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(and(eq(users.id, nextTeacherId), eq(roles.name, "teacher")))
.limit(1)
if (!teacher) throw new Error("Teacher not found")
@@ -1498,7 +1711,9 @@ export async function setClassSubjectTeachers(params: {
const rows = await db
.select({ id: users.id })
.from(users)
.where(and(eq(users.role, "teacher"), inArray(users.id, teacherIds)))
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(and(eq(roles.name, "teacher"), inArray(users.id, teacherIds)))
if (rows.length !== new Set(teacherIds).size) throw new Error("Teacher not found")
}
@@ -1508,15 +1723,24 @@ export async function setClassSubjectTeachers(params: {
teacherBySubject.set(a.subject, typeof a.teacherId === "string" && a.teacherId.trim().length > 0 ? a.teacherId.trim() : null)
}
// Map subject names to ids
const subjectRows = await db
.select({ id: subjects.id, name: subjects.name })
.from(subjects)
.where(inArray(subjects.name, DEFAULT_CLASS_SUBJECTS))
const idByName = new Map(subjectRows.map((r) => [r.name as ClassSubject, r.id]))
const values = DEFAULT_CLASS_SUBJECTS
.filter((name) => idByName.has(name))
.map((name) => ({
classId,
subjectId: idByName.get(name)!,
teacherId: teacherBySubject.get(name) ?? null,
}))
await db
.insert(classSubjectTeachers)
.values(
DEFAULT_CLASS_SUBJECTS.map((subject) => ({
classId,
subject,
teacherId: teacherBySubject.get(subject) ?? null,
}))
)
.values(values)
.onDuplicateKeyUpdate({ set: { teacherId: sql`VALUES(${classSubjectTeachers.teacherId})` } })
}
@@ -1564,13 +1788,19 @@ export async function enrollStudentByEmail(classId: string, email: string): Prom
if (!owned) throw new Error("Class not found")
const [student] = await db
.select({ id: users.id, role: users.role })
.select({ id: users.id })
.from(users)
.where(eq(users.email, normalized))
.limit(1)
if (!student) throw new Error("Student not found")
if (student.role !== "student") throw new Error("User is not a student")
const [studentRole] = await db
.select({ id: usersToRoles.userId })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(and(eq(usersToRoles.userId, student.id), eq(roles.name, "student")))
.limit(1)
if (!studentRole) throw new Error("User is not a student")
await db
.insert(classEnrollments)
@@ -1823,8 +2053,26 @@ export const getStudentsSubjectScores = cache(
)
export const getClassStudentSubjectScoresV2 = cache(
async (classId: string): Promise<Map<string, Record<string, number | null>>> => {
// 1. Get student IDs in the class
async (params: { classId: string; teacherId?: string }): Promise<Map<string, Record<string, number | null>>> => {
const teacherId = params.teacherId ?? (await getSessionTeacherId())
if (!teacherId) return new Map()
const classId = params.classId.trim()
if (!classId) return new Map()
const accessibleIds = await getAccessibleClassIdsForTeacher(teacherId)
if (accessibleIds.length === 0 || !accessibleIds.includes(classId)) return new Map()
const [classRow] = await db
.select({ id: classes.id, teacherId: classes.teacherId })
.from(classes)
.where(eq(classes.id, classId))
.limit(1)
if (!classRow) return new Map()
const isHomeroomTeacher = classRow.teacherId === teacherId
const subjectIds = isHomeroomTeacher ? [] : await getTeacherSubjectIdsForClass(teacherId, classId)
if (!isHomeroomTeacher && subjectIds.length === 0) return new Map()
const enrollments = await db
.select({ studentId: classEnrollments.studentId })
.from(classEnrollments)
@@ -1833,7 +2081,24 @@ export const getClassStudentSubjectScoresV2 = cache(
eq(classEnrollments.status, "active")
))
const studentIds = enrollments.map(e => e.studentId)
return getStudentsSubjectScores(studentIds)
const studentIds = enrollments.map((e) => e.studentId)
const studentScores = await getStudentsSubjectScores(studentIds)
if (subjectIds.length === 0) return studentScores
// Map subjectIds to names for filtering
const subjectRows = await db
.select({ id: subjects.id, name: subjects.name })
.from(subjects)
.where(inArray(subjects.id, subjectIds))
const allowed = new Set(subjectRows.map((s) => s.name))
const filtered = new Map<string, Record<string, number | null>>()
for (const [studentId, scores] of studentScores.entries()) {
const nextScores: Record<string, number | null> = {}
for (const [subject, score] of Object.entries(scores)) {
if (allowed.has(subject)) nextScores[subject] = score
}
filtered.set(studentId, nextScores)
}
return filtered
}
)