parent: - Add parent-student-attendance-detail component auth: - Add actions, data-access, schema, services, types onboarding: - Add parent-children-form and hooks directory files: - Add actions, schema, hooks directory notifications: - Add schema and schema test adaptive-practice: - Add answer-input, answer-result, practice-result-view, practice-starter-with-nav - Add question-card, question-content, lib and services directories ai: - Add context/create-ai-client-service, hooks/use-drag-position, hooks/use-position-persistence
305 lines
8.9 KiB
TypeScript
305 lines
8.9 KiB
TypeScript
import "server-only"
|
||
|
||
import { cache } from "react"
|
||
import { and, asc, eq, inArray } from "drizzle-orm"
|
||
|
||
import { db } from "@/shared/db"
|
||
import { parentStudentRelations } from "@/shared/db/schema"
|
||
import {
|
||
getStudentActiveClass,
|
||
getStudentClasses,
|
||
getStudentSchedule,
|
||
} from "@/modules/classes/data-access"
|
||
import {
|
||
getStudentHomeworkAssignments,
|
||
getStudentExamResults,
|
||
} from "@/modules/homework/data-access-student"
|
||
import { getStudentDashboardGrades } from "@/modules/homework/stats-service"
|
||
import { getStudentGradeSummary } from "@/modules/grades/data-access"
|
||
import { getGradeNameById } from "@/modules/school/data-access"
|
||
import { getUserBasicInfo, getUserNamesByIds } from "@/modules/users/data-access"
|
||
import type {
|
||
ChildBasicInfo,
|
||
ChildDashboardData,
|
||
ChildHomeworkSummaryData,
|
||
ChildScheduleItem,
|
||
ChildWeeklyScheduleItem,
|
||
ParentChildRelation,
|
||
ParentDashboardData,
|
||
} from "./types"
|
||
|
||
type Weekday = 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||
|
||
const isWeekday = (n: number): n is Weekday => n >= 1 && n <= 7
|
||
|
||
const toWeekday = (d: Date): Weekday => {
|
||
const day = d.getDay()
|
||
// getDay() returns 0 (Sun) - 6 (Sat); normalize Sunday (0) to 7
|
||
const normalized = day === 0 ? 7 : day
|
||
return isWeekday(normalized) ? normalized : 1
|
||
}
|
||
|
||
export const getChildren = cache(async (parentId: string): Promise<ParentChildRelation[]> => {
|
||
const id = parentId.trim()
|
||
if (!id) return []
|
||
|
||
const rows = await db
|
||
.select({
|
||
id: parentStudentRelations.id,
|
||
parentId: parentStudentRelations.parentId,
|
||
studentId: parentStudentRelations.studentId,
|
||
relation: parentStudentRelations.relation,
|
||
createdAt: parentStudentRelations.createdAt,
|
||
})
|
||
.from(parentStudentRelations)
|
||
.where(eq(parentStudentRelations.parentId, id))
|
||
.orderBy(asc(parentStudentRelations.createdAt))
|
||
|
||
return rows.map((r) => ({
|
||
id: r.id,
|
||
parentId: r.parentId,
|
||
studentId: r.studentId,
|
||
relation: r.relation,
|
||
createdAt: r.createdAt.toISOString(),
|
||
}))
|
||
})
|
||
|
||
/**
|
||
* 校验家长与子女的关系是否存在,并返回关系类型。
|
||
* 同时按 parentId 与 studentId 过滤,防止跨家庭信息泄露。
|
||
*/
|
||
export const verifyParentChildRelation = cache(
|
||
async (studentId: string, parentId: string): Promise<string | null> => {
|
||
const [row] = await db
|
||
.select({ relation: parentStudentRelations.relation })
|
||
.from(parentStudentRelations)
|
||
.where(
|
||
and(
|
||
eq(parentStudentRelations.studentId, studentId),
|
||
eq(parentStudentRelations.parentId, parentId),
|
||
),
|
||
)
|
||
.limit(1)
|
||
return row?.relation ?? null
|
||
},
|
||
)
|
||
|
||
export const getChildBasicInfo = cache(
|
||
async (
|
||
studentId: string,
|
||
relation: string | null = null,
|
||
): Promise<ChildBasicInfo | null> => {
|
||
const student = await getUserBasicInfo(studentId)
|
||
|
||
if (!student) return null
|
||
|
||
// gradeName 与 activeClass 相互独立,并行拉取
|
||
const [gradeName, activeClass] = await Promise.all([
|
||
student.gradeId ? getGradeNameById(student.gradeId) : Promise.resolve(null),
|
||
getStudentActiveClass(studentId),
|
||
])
|
||
|
||
return {
|
||
id: student.id,
|
||
name: student.name,
|
||
email: student.email,
|
||
image: student.image,
|
||
gradeName,
|
||
className: activeClass?.className ?? null,
|
||
classId: activeClass?.classId ?? null,
|
||
relation,
|
||
}
|
||
},
|
||
)
|
||
|
||
const buildHomeworkSummary = (
|
||
assignments: Awaited<ReturnType<typeof getStudentHomeworkAssignments>>,
|
||
): ChildHomeworkSummaryData => {
|
||
const now = new Date()
|
||
|
||
let pendingCount = 0
|
||
let submittedCount = 0
|
||
let gradedCount = 0
|
||
let overdueCount = 0
|
||
|
||
for (const a of assignments) {
|
||
if (a.progressStatus === "graded") {
|
||
gradedCount += 1
|
||
continue
|
||
}
|
||
if (a.progressStatus === "submitted") {
|
||
submittedCount += 1
|
||
}
|
||
if (a.progressStatus === "not_started" || a.progressStatus === "in_progress") {
|
||
pendingCount += 1
|
||
}
|
||
if (a.dueAt) {
|
||
const due = new Date(a.dueAt)
|
||
if (due < now && a.progressStatus !== "submitted") {
|
||
overdueCount += 1
|
||
}
|
||
}
|
||
}
|
||
|
||
const recentAssignments = assignments
|
||
.toSorted((a, b) => {
|
||
const aDue = a.dueAt ? new Date(a.dueAt).getTime() : Number.POSITIVE_INFINITY
|
||
const bDue = b.dueAt ? new Date(b.dueAt).getTime() : Number.POSITIVE_INFINITY
|
||
return aDue - bDue
|
||
})
|
||
.slice(0, 5)
|
||
|
||
return {
|
||
pendingCount,
|
||
submittedCount,
|
||
gradedCount,
|
||
overdueCount,
|
||
recentAssignments,
|
||
}
|
||
}
|
||
|
||
const buildTodaySchedule = (
|
||
schedule: Awaited<ReturnType<typeof getStudentSchedule>>,
|
||
): ChildScheduleItem[] => {
|
||
const todayWeekday = toWeekday(new Date())
|
||
return schedule
|
||
.filter((s) => s.weekday === todayWeekday)
|
||
.map((s) => ({
|
||
id: s.id,
|
||
classId: s.classId,
|
||
className: s.className,
|
||
course: s.course,
|
||
startTime: s.startTime,
|
||
endTime: s.endTime,
|
||
location: s.location ?? null,
|
||
}))
|
||
.sort((a, b) => a.startTime.localeCompare(b.startTime))
|
||
}
|
||
|
||
const buildWeeklySchedule = (
|
||
schedule: Awaited<ReturnType<typeof getStudentSchedule>>,
|
||
): ChildWeeklyScheduleItem[] => {
|
||
return schedule
|
||
.map((s) => ({
|
||
id: s.id,
|
||
classId: s.classId,
|
||
className: s.className,
|
||
course: s.course,
|
||
startTime: s.startTime,
|
||
endTime: s.endTime,
|
||
location: s.location ?? null,
|
||
weekday: s.weekday,
|
||
}))
|
||
.sort((a, b) =>
|
||
a.weekday === b.weekday
|
||
? a.startTime.localeCompare(b.startTime)
|
||
: a.weekday - b.weekday,
|
||
)
|
||
}
|
||
|
||
export const getChildDashboardData = cache(
|
||
async (studentId: string, relation: string | null = null): Promise<ChildDashboardData | null> => {
|
||
const basicInfo = await getChildBasicInfo(studentId, relation)
|
||
if (!basicInfo) return null
|
||
|
||
const [enrolledClasses, schedule, assignments, gradeTrend, gradeSummary, examResults] = await Promise.all([
|
||
getStudentClasses(studentId),
|
||
getStudentSchedule(studentId),
|
||
getStudentHomeworkAssignments(studentId),
|
||
getStudentDashboardGrades(studentId),
|
||
getStudentGradeSummary(studentId),
|
||
getStudentExamResults(studentId),
|
||
])
|
||
|
||
return {
|
||
basicInfo,
|
||
enrolledClasses,
|
||
todaySchedule: buildTodaySchedule(schedule),
|
||
weeklySchedule: buildWeeklySchedule(schedule),
|
||
homeworkSummary: buildHomeworkSummary(assignments),
|
||
gradeTrend,
|
||
gradeSummary,
|
||
examResults,
|
||
}
|
||
},
|
||
)
|
||
|
||
export const getParentDashboardData = cache(
|
||
async (parentId: string): Promise<ParentDashboardData> => {
|
||
const id = parentId.trim()
|
||
if (!id) return { parentName: null, children: [] }
|
||
|
||
const [parentInfo, relations] = await Promise.all([
|
||
getUserNamesByIds([id]),
|
||
getChildren(id),
|
||
])
|
||
|
||
const parentName = parentInfo.get(id)?.name ?? null
|
||
|
||
if (relations.length === 0) {
|
||
return { parentName, children: [] }
|
||
}
|
||
|
||
const children = await Promise.all(
|
||
relations.map((r) => getChildDashboardData(r.studentId, r.relation)),
|
||
)
|
||
|
||
return {
|
||
parentName,
|
||
children: children.filter((c): c is ChildDashboardData => c !== null),
|
||
}
|
||
},
|
||
)
|
||
|
||
/**
|
||
* 获取家长所有子女的轻量列表(id + name),用于详情页头部多子女切换器。
|
||
* 一次批量查询,避免 N+1。
|
||
*/
|
||
export const getChildNameList = cache(
|
||
async (parentId: string): Promise<Array<{ id: string; name: string | null }>> => {
|
||
const relations = await getChildren(parentId)
|
||
if (relations.length === 0) return []
|
||
|
||
const nameMap = await getUserNamesByIds(relations.map((r) => r.studentId))
|
||
return relations.map((r) => ({
|
||
id: r.studentId,
|
||
name: nameMap.get(r.studentId)?.name ?? null,
|
||
}))
|
||
},
|
||
)
|
||
|
||
/**
|
||
* v4-P1-5: 批量查询多个学生的家长 userId 列表。
|
||
* 用于通知场景:报告/成绩发布时同步通知所有相关家长。
|
||
* 返回去重后的 parentId 数组。
|
||
*/
|
||
export const getParentIdsByStudentIds = cache(
|
||
async (studentIds: string[]): Promise<string[]> => {
|
||
if (studentIds.length === 0) return []
|
||
const rows = await db
|
||
.select({ parentId: parentStudentRelations.parentId })
|
||
.from(parentStudentRelations)
|
||
.where(inArray(parentStudentRelations.studentId, studentIds))
|
||
return Array.from(new Set(rows.map((r) => r.parentId)))
|
||
},
|
||
)
|
||
|
||
/**
|
||
* L-2: 批量查询学生→家长映射(保留关联关系)。
|
||
* 用于考勤通知等需要按家长-学生分组发送的场景,避免 N+1 查询。
|
||
* 返回数组形如 [{ parentId, studentId }],同一家长多个学生会出现多条。
|
||
*/
|
||
export const getParentStudentMapByStudentIds = cache(
|
||
async (studentIds: string[]): Promise<Array<{ parentId: string; studentId: string }>> => {
|
||
if (studentIds.length === 0) return []
|
||
const rows = await db
|
||
.select({
|
||
parentId: parentStudentRelations.parentId,
|
||
studentId: parentStudentRelations.studentId,
|
||
})
|
||
.from(parentStudentRelations)
|
||
.where(inArray(parentStudentRelations.studentId, studentIds))
|
||
return rows.map((r) => ({ parentId: r.parentId, studentId: r.studentId }))
|
||
},
|
||
)
|