import "server-only" import { cache } from "react" import { and, asc, desc, eq, inArray, or, sql } from "drizzle-orm" import { db } from "@/shared/db" import { academicYears, departments, grades, roles, schools, subjects, users, usersToRoles } from "@/shared/db/schema" import type { AcademicYearInsertData, AcademicYearListItem, AcademicYearUpdateData, DepartmentInsertData, DepartmentListItem, DepartmentUpdateData, GradeInsertData, GradeListItem, GradeUpdateData, OrgTreeNode, SchoolInsertData, SchoolListItem, SchoolUpdateData, StaffOption, } from "./types" const toIso = (d: Date): string => d.toISOString() export const getDepartments = cache(async (): Promise => { try { const rows = await db.select().from(departments).orderBy(asc(departments.name)) return rows.map((r) => ({ id: r.id, name: r.name, description: r.description ?? null, createdAt: toIso(r.createdAt), updatedAt: toIso(r.updatedAt), })) } catch (error) { console.error("getDepartments failed:", error) return [] } }) export const getAcademicYears = cache(async (): Promise => { try { const rows = await db.select().from(academicYears).orderBy(asc(academicYears.startDate)) return rows.map((r) => ({ id: r.id, name: r.name, startDate: toIso(r.startDate), endDate: toIso(r.endDate), isActive: Boolean(r.isActive), createdAt: toIso(r.createdAt), updatedAt: toIso(r.updatedAt), })) } catch (error) { console.error("getAcademicYears failed:", error) return [] } }) export const getSchools = cache(async (): Promise => { try { const rows = await db.select().from(schools).orderBy(asc(schools.name)) return rows.map((r) => ({ id: r.id, name: r.name, code: r.code ?? null, createdAt: toIso(r.createdAt), updatedAt: toIso(r.updatedAt), })) } catch (error) { console.error("getSchools failed:", error) return [] } }) export const getGrades = cache(async (): Promise => { try { const rows = await db .select({ id: grades.id, name: grades.name, order: grades.order, schoolId: schools.id, schoolName: schools.name, gradeHeadId: grades.gradeHeadId, teachingHeadId: grades.teachingHeadId, createdAt: grades.createdAt, updatedAt: grades.updatedAt, }) .from(grades) .innerJoin(schools, eq(schools.id, grades.schoolId)) .orderBy(asc(schools.name), asc(grades.order), asc(grades.name)) const headIds = Array.from( new Set( rows .flatMap((r) => [r.gradeHeadId, r.teachingHeadId]) .filter((v): v is string => typeof v === "string" && v.length > 0) ) ) const heads = headIds.length ? await db .select({ id: users.id, name: users.name, email: users.email }) .from(users) .where(inArray(users.id, headIds)) : [] const headById = new Map() for (const u of heads) { headById.set(u.id, { id: u.id, name: u.name ?? "Unnamed", email: u.email }) } return rows.map((r) => ({ id: r.id, school: { id: r.schoolId, name: r.schoolName }, name: r.name, order: Number(r.order ?? 0), gradeHead: r.gradeHeadId ? headById.get(r.gradeHeadId) ?? null : null, teachingHead: r.teachingHeadId ? headById.get(r.teachingHeadId) ?? null : null, createdAt: toIso(r.createdAt), updatedAt: toIso(r.updatedAt), })) } catch (error) { console.error("getGrades failed:", error) return [] } }) export const getStaffOptions = cache(async (): Promise => { try { const rows = await db .select({ id: users.id, name: users.name, email: users.email }) .from(users) .innerJoin(usersToRoles, eq(usersToRoles.userId, users.id)) .innerJoin(roles, eq(usersToRoles.roleId, roles.id)) .where(inArray(roles.name, ["teacher", "admin"])) .groupBy(users.id, users.name, users.email) .orderBy(asc(users.name), asc(users.email)) return rows.map((r) => ({ id: r.id, name: r.name ?? "Unnamed", email: r.email, })) } catch (error) { console.error("getStaffOptions failed:", error) return [] } }) export const getGradesForStaff = cache(async (staffId: string): Promise => { const id = staffId.trim() if (!id) return [] try { const rows = await db .select({ id: grades.id, name: grades.name, order: grades.order, schoolId: schools.id, schoolName: schools.name, gradeHeadId: grades.gradeHeadId, teachingHeadId: grades.teachingHeadId, createdAt: grades.createdAt, updatedAt: grades.updatedAt, }) .from(grades) .innerJoin(schools, eq(schools.id, grades.schoolId)) .where(or(eq(grades.gradeHeadId, id), eq(grades.teachingHeadId, id))) .orderBy(asc(schools.name), asc(grades.order), asc(grades.name)) const headIds = Array.from( new Set( rows .flatMap((r) => [r.gradeHeadId, r.teachingHeadId]) .filter((v): v is string => typeof v === "string" && v.length > 0) ) ) const heads = headIds.length ? await db .select({ id: users.id, name: users.name, email: users.email }) .from(users) .where(inArray(users.id, headIds)) : [] const headById = new Map() for (const u of heads) headById.set(u.id, { id: u.id, name: u.name ?? "Unnamed", email: u.email }) return rows.map((r) => ({ id: r.id, school: { id: r.schoolId, name: r.schoolName }, name: r.name, order: Number(r.order ?? 0), gradeHead: r.gradeHeadId ? headById.get(r.gradeHeadId) ?? null : null, teachingHead: r.teachingHeadId ? headById.get(r.teachingHeadId) ?? null : null, createdAt: toIso(r.createdAt), updatedAt: toIso(r.updatedAt), })) } catch (error) { console.error("getGradesForStaff failed:", error) return [] } }) /** * 根据用户角色返回可见的学校列表(权限感知)。 * - admin: 返回全量学校 * - grade_head / teaching_head: 返回其负责年级所在学校 * - teacher: 返回其任课班级所在学校 * - 其他角色: 返回空数组 */ export const getSchoolsForUser = cache(async (userId: string): Promise => { const id = userId.trim() if (!id) return [] try { const roleRows = await db .select({ name: roles.name }) .from(roles) .innerJoin(usersToRoles, eq(usersToRoles.roleId, roles.id)) .where(eq(usersToRoles.userId, id)) const roleNames = new Set(roleRows.map((r) => r.name)) if (roleNames.has("admin")) { return await getSchools() } let schoolIds: string[] = [] if (roleNames.has("grade_head") || roleNames.has("teaching_head")) { const gradeRows = await db .select({ schoolId: grades.schoolId }) .from(grades) .where(or(eq(grades.gradeHeadId, id), eq(grades.teachingHeadId, id))) schoolIds = gradeRows.map((r) => r.schoolId) } else if (roleNames.has("teacher")) { const { getAccessibleClassIdsForTeacher, getGradeIdsByClassIds } = await import("@/modules/classes/data-access") const classIds = await getAccessibleClassIdsForTeacher(id) if (classIds.length === 0) return [] const gradeIds = await getGradeIdsByClassIds(classIds) if (gradeIds.length === 0) return [] const gradeRows = await db .select({ schoolId: grades.schoolId }) .from(grades) .where(inArray(grades.id, gradeIds)) schoolIds = gradeRows.map((r) => r.schoolId) } else { return [] } const uniqueSchoolIds = Array.from( new Set(schoolIds.filter((v): v is string => typeof v === "string" && v.length > 0)) ) if (uniqueSchoolIds.length === 0) return [] const rows = await db .select() .from(schools) .where(inArray(schools.id, uniqueSchoolIds)) .orderBy(asc(schools.name)) return rows.map((r) => ({ id: r.id, name: r.name, code: r.code ?? null, createdAt: toIso(r.createdAt), updatedAt: toIso(r.updatedAt), })) } catch (error) { console.error("getSchoolsForUser failed:", error) return [] } }) /** * 根据用户角色返回可见的年级列表(权限感知)。 * - admin: 返回全量年级 * - grade_head / teaching_head: 返回其负责的年级 * - teacher: 返回其任课班级所在年级 * - 其他角色: 返回空数组 */ export const getGradesForUser = cache(async (userId: string): Promise => { const id = userId.trim() if (!id) return [] try { const roleRows = await db .select({ name: roles.name }) .from(roles) .innerJoin(usersToRoles, eq(usersToRoles.roleId, roles.id)) .where(eq(usersToRoles.userId, id)) const roleNames = new Set(roleRows.map((r) => r.name)) if (roleNames.has("admin")) { return await getGrades() } if (roleNames.has("grade_head") || roleNames.has("teaching_head")) { return await getGradesForStaff(id) } if (roleNames.has("teacher")) { const { getAccessibleClassIdsForTeacher, getGradeIdsByClassIds } = await import("@/modules/classes/data-access") const classIds = await getAccessibleClassIdsForTeacher(id) if (classIds.length === 0) return [] const gradeIds = await getGradeIdsByClassIds(classIds) if (gradeIds.length === 0) return [] const uniqueGradeIds = Array.from(new Set(gradeIds)) if (uniqueGradeIds.length === 0) return [] const rows = await db .select({ id: grades.id, name: grades.name, order: grades.order, schoolId: schools.id, schoolName: schools.name, gradeHeadId: grades.gradeHeadId, teachingHeadId: grades.teachingHeadId, createdAt: grades.createdAt, updatedAt: grades.updatedAt, }) .from(grades) .innerJoin(schools, eq(schools.id, grades.schoolId)) .where(inArray(grades.id, uniqueGradeIds)) .orderBy(asc(schools.name), asc(grades.order), asc(grades.name)) const headIds = Array.from( new Set( rows .flatMap((r) => [r.gradeHeadId, r.teachingHeadId]) .filter((v): v is string => typeof v === "string" && v.length > 0) ) ) const heads = headIds.length ? await db .select({ id: users.id, name: users.name, email: users.email }) .from(users) .where(inArray(users.id, headIds)) : [] const headById = new Map() for (const u of heads) headById.set(u.id, { id: u.id, name: u.name ?? "Unnamed", email: u.email }) return rows.map((r) => ({ id: r.id, school: { id: r.schoolId, name: r.schoolName }, name: r.name, order: Number(r.order ?? 0), gradeHead: r.gradeHeadId ? headById.get(r.gradeHeadId) ?? null : null, teachingHead: r.teachingHeadId ? headById.get(r.teachingHeadId) ?? null : null, createdAt: toIso(r.createdAt), updatedAt: toIso(r.updatedAt), })) } return [] } catch (error) { console.error("getGradesForUser failed:", error) return [] } }) // --------------------------------------------------------------------------- // Mutations — DB write operations (called only from actions.ts) // --------------------------------------------------------------------------- export async function createDepartment(data: DepartmentInsertData): Promise { await db.insert(departments).values({ id: data.id, name: data.name, description: data.description, }) } export async function updateDepartment( id: string, data: DepartmentUpdateData ): Promise { await db .update(departments) .set({ name: data.name, description: data.description }) .where(eq(departments.id, id)) } export async function deleteDepartment(id: string): Promise { await db.delete(departments).where(eq(departments.id, id)) } export async function createSchool(data: SchoolInsertData): Promise { await db.insert(schools).values({ id: data.id, name: data.name, code: data.code, }) } export async function updateSchool( id: string, data: SchoolUpdateData ): Promise { await db .update(schools) .set({ name: data.name, code: data.code }) .where(eq(schools.id, id)) } export async function deleteSchool(id: string): Promise { await db.delete(schools).where(eq(schools.id, id)) } export async function createGrade(data: GradeInsertData): Promise { await db.insert(grades).values({ id: data.id, schoolId: data.schoolId, name: data.name, order: data.order, gradeHeadId: data.gradeHeadId, teachingHeadId: data.teachingHeadId, }) } export async function updateGrade( id: string, data: GradeUpdateData ): Promise { await db .update(grades) .set({ schoolId: data.schoolId, name: data.name, order: data.order, gradeHeadId: data.gradeHeadId, teachingHeadId: data.teachingHeadId, }) .where(eq(grades.id, id)) } export async function deleteGrade(id: string): Promise { await db.delete(grades).where(eq(grades.id, id)) } export async function createAcademicYear( data: AcademicYearInsertData ): Promise { await db.transaction(async (tx) => { if (data.isActive) { await tx.update(academicYears).set({ isActive: false }) } await tx.insert(academicYears).values({ id: data.id, name: data.name, startDate: data.startDate, endDate: data.endDate, isActive: data.isActive, }) }) } export async function updateAcademicYear( id: string, data: AcademicYearUpdateData ): Promise { await db.transaction(async (tx) => { if (data.isActive) { await tx.update(academicYears).set({ isActive: false }) } await tx .update(academicYears) .set({ name: data.name, startDate: data.startDate, endDate: data.endDate, isActive: data.isActive, }) .where(eq(academicYears.id, id)) }) } export async function deleteAcademicYear(id: string): Promise { await db.delete(academicYears).where(eq(academicYears.id, id)) } // --------------------------------------------------------------------------- // Cross-module query interfaces — read-only access for other modules // --------------------------------------------------------------------------- export type SubjectOption = { id: string name: string code: string | null order: number } export type GradeOption = { id: string name: string schoolId: string schoolName: string order: number } export const getSubjectOptions = cache(async (): Promise => { try { const rows = await db .select({ id: subjects.id, name: subjects.name, code: subjects.code, order: subjects.order, }) .from(subjects) .orderBy(asc(subjects.order), asc(subjects.name)) return rows.map((r) => ({ id: r.id, name: r.name, code: r.code ?? null, order: Number(r.order ?? 0), })) } catch (error) { console.error("getSubjectOptions failed:", error) return [] } }) export const getGradeOptions = cache(async (): Promise => { try { const rows = await db .select({ id: grades.id, name: grades.name, order: grades.order, schoolId: schools.id, schoolName: schools.name, }) .from(grades) .innerJoin(schools, eq(schools.id, grades.schoolId)) .orderBy(asc(schools.name), asc(grades.order), asc(grades.name)) return rows.map((r) => ({ id: r.id, name: r.name, schoolId: r.schoolId, schoolName: r.schoolName, order: Number(r.order ?? 0), })) } catch (error) { console.error("getGradeOptions failed:", error) return [] } }) /** * 按 ID 获取单个年级名称。 * 供跨模块调用使用,避免为单个年级拉取全量年级选项。 */ export const getGradeNameById = cache( async (gradeId: string): Promise => { const id = gradeId.trim() if (!id) return null const [row] = await db .select({ name: grades.name }) .from(grades) .where(eq(grades.id, id)) .limit(1) return row?.name ?? null }, ) /** * 按 ID 获取单个科目名称。 * 供跨模块调用使用,避免为单个科目拉取全量科目选项或直接查询 subjects 表。 */ export const getSubjectNameById = cache( async (subjectId: string): Promise => { const id = subjectId.trim() if (!id) return null const [row] = await db .select({ name: subjects.name }) .from(subjects) .where(eq(subjects.id, id)) .limit(1) return row?.name ?? null }, ) /** * 批量获取科目名称映射(subjectId -> name)。 * 供跨模块调用使用,避免直接查询 subjects 表。 */ export const getSubjectNameMapByIds = cache( async (subjectIds: string[]): Promise> => { const ids = subjectIds.filter((id) => id.trim().length > 0) if (ids.length === 0) return new Map() const rows = await db .select({ id: subjects.id, name: subjects.name }) .from(subjects) .where(inArray(subjects.id, ids)) const map = new Map() for (const r of rows) map.set(r.id, r.name) return map }, ) // --------------------------------------------------------------------------- // Cross-module query interfaces — grade head/teaching head verification // --------------------------------------------------------------------------- /** * 校验用户是否为指定年级的年级主任。 * 供 classes 模块跨模块调用使用,避免直接查询 grades 表。 */ export const isGradeHead = cache(async ( gradeId: string, userId: string ): Promise => { const trimmedGradeId = gradeId.trim() const trimmedUserId = userId.trim() if (!trimmedGradeId || !trimmedUserId) return false const [row] = await db .select({ id: grades.id }) .from(grades) .where(and(eq(grades.id, trimmedGradeId), eq(grades.gradeHeadId, trimmedUserId))) .limit(1) return Boolean(row) }) /** * 校验用户是否为指定年级的年级主任或教学主任。 * 供 classes 模块跨模块调用使用,避免直接查询 grades 表。 */ export const isGradeManager = cache(async ( gradeId: string, userId: string ): Promise => { const trimmedGradeId = gradeId.trim() const trimmedUserId = userId.trim() if (!trimmedGradeId || !trimmedUserId) return false const [row] = await db .select({ id: grades.id }) .from(grades) .where( and( eq(grades.id, trimmedGradeId), or(eq(grades.gradeHeadId, trimmedUserId), eq(grades.teachingHeadId, trimmedUserId)) ) ) .limit(1) return Boolean(row) }) /** * 根据年级名称(大小写不敏感)查找用户担任年级主任的年级 ID。 * 供 classes 模块跨模块调用使用,避免直接查询 grades 表。 */ export const findGradeIdByHeadAndName = cache(async ( userId: string, gradeName: string ): Promise => { const trimmedUserId = userId.trim() const normalizedGradeName = gradeName.trim().toLowerCase() if (!trimmedUserId || !normalizedGradeName) return null const [row] = await db .select({ id: grades.id }) .from(grades) .where( and( eq(grades.gradeHeadId, trimmedUserId), sql`LOWER(${grades.name}) = ${normalizedGradeName}` ) ) .limit(1) return row?.id ?? null }) /** * 将年级名称升级:一年级→二年级,1年级→2年级,Grade 1→Grade 2 * 无法识别的名称保持不变。 */ function promoteGradeName(name: string): string { // 中文数字映射 const chineseNums = ["一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "十一", "十二"] for (let i = 0; i < chineseNums.length - 1; i++) { if (name.includes(chineseNums[i]) && !name.includes(chineseNums[i + 1])) { return name.replace(chineseNums[i], chineseNums[i + 1]) } } // 阿拉伯数字 const match = name.match(/(\d+)/) if (match) { const num = parseInt(match[1], 10) if (num > 0 && num < 13) { return name.replace(match[1], String(num + 1)) } } return name } /** * 年级升级:将指定学校下的所有年级 order +1,并更新年级名称(如果符合数字模式)。 * 例如:一年级 → 二年级,order 1 → 2 * * 注意:此函数只更新年级本身的 order 和 name,不迁移班级数据。 * 班级升级应由 classes 模块的独立 Action 处理。 */ export async function promoteGrades(schoolId: string): Promise<{ promoted: number }> { const rows = await db .select({ id: grades.id, name: grades.name, order: grades.order }) .from(grades) .where(eq(grades.schoolId, schoolId)) .orderBy(desc(grades.order)) // 从高到低升级,避免唯一约束冲突 let promoted = 0 for (const row of rows) { const newOrder = (row.order ?? 0) + 1 const newName = promoteGradeName(row.name) await db .update(grades) .set({ order: newOrder, name: newName }) .where(eq(grades.id, row.id)) promoted += 1 } return { promoted } } /** * 获取学校→年级→班级三级组织架构树。 * 班级数据通过 classes 模块的 data-access 动态导入获取,避免循环依赖。 * 任何一层查询失败均返回空数组,保证调用方拿到稳定结构。 */ export const getOrgTree = cache(async (): Promise => { try { const [schoolList, gradeList] = await Promise.all([getSchools(), getGrades()]) const { getAdminClasses } = await import("@/modules/classes/data-access") const allClasses = await getAdminClasses() const classesByGradeId = new Map() for (const cls of allClasses) { const gradeId = cls.gradeId if (typeof gradeId !== "string" || gradeId.length === 0) continue const node: OrgTreeNode = { id: cls.id, name: cls.name, type: "class", } const list = classesByGradeId.get(gradeId) if (list) { list.push(node) } else { classesByGradeId.set(gradeId, [node]) } } const gradesBySchoolId = new Map() for (const grade of gradeList) { const children = classesByGradeId.get(grade.id) ?? [] const node: OrgTreeNode = { id: grade.id, name: grade.name, type: "grade", children, } const list = gradesBySchoolId.get(grade.school.id) if (list) { list.push(node) } else { gradesBySchoolId.set(grade.school.id, [node]) } } return schoolList.map((school) => ({ id: school.id, name: school.name, type: "school", children: gradesBySchoolId.get(school.id) ?? [], })) } catch (error) { console.error("getOrgTree failed:", error) return [] } })