Files
NextEdu/src/modules/users/data-access.ts
SpecialX 4f0ef217a0 refactor(modules): update existing module implementations across attendance, audit, auth, classes, course-plans, exams, files, homework, layout, proctoring, questions, scheduling, textbooks, users
- Update attendance components and data-access for record management

- Update audit log views, filters, and data-access

- Update auth login and register forms

- Update classes actions, components, and data-access (admin, schedule, stats)

- Update course-plans actions, form, list, progress, and schema

- Update exams actions, AI pipeline, preview components, and hooks

- Update files components (icon, list, preview, upload) and data-access

- Update homework assignment form, review view, auto-save hook, and stats-service

- Update layout sidebar, header, and navigation config

- Update proctoring actions, anti-cheat monitor, and data-access

- Update questions actions, components (dialog, actions, columns, filters), and data-access

- Update scheduling actions, auto-scheduler, components, and schema

- Update textbooks constants and text-selection hook

- Update users class-registration, import-dialog, data-access, and user-service
2026-06-23 17:38:56 +08:00

425 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import "server-only"
import { cache } from "react"
import { and, count, desc, eq, gt, ilike, inArray, or } from "drizzle-orm"
import { auth } from "@/auth"
import { db } from "@/shared/db"
import { roles, sessions, users, usersToRoles } from "@/shared/db/schema"
import { resolvePrimaryRole } from "@/shared/lib/role-utils"
export type UserProfile = {
id: string
name: string | null
email: string
image: string | null
role: string | null
phone: string | null
address: string | null
gender: string | null
age: number | null
onboardedAt: Date | null
createdAt: Date
updatedAt: Date
}
export const getUserProfile = cache(async (userId: string): Promise<UserProfile | null> => {
const user = await db.query.users.findFirst({
where: eq(users.id, userId),
})
if (!user) return null
const roleRows = await db
.select({ name: roles.name })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(eq(usersToRoles.userId, userId))
const role = resolvePrimaryRole(roleRows.map((r) => r.name))
return {
id: user.id,
name: user.name,
email: user.email,
image: user.image,
role,
phone: user.phone,
address: user.address,
gender: user.gender,
age: user.age,
onboardedAt: user.onboardedAt,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
}
})
/** Input for updating a user's profile (self-service) */
export type UpdateUserProfileInput = {
name?: string
phone?: string
address?: string
gender?: string
age?: number
}
/**
* Update a user's profile by id (self-service fields only).
* Returns the updated user profile or null if the user was not found.
*/
export async function updateUserProfileById(
userId: string,
data: UpdateUserProfileInput
): Promise<UserProfile | null> {
const updateData: Partial<typeof users.$inferInsert> = {}
if (data.name !== undefined) updateData.name = data.name
// Convert empty strings to null for cleaner DB
if (data.phone !== undefined) updateData.phone = data.phone || null
if (data.address !== undefined) updateData.address = data.address || null
if (data.gender !== undefined) updateData.gender = data.gender || null
if (data.age !== undefined) updateData.age = data.age
if (Object.keys(updateData).length === 0) {
return await getUserProfile(userId)
}
await db
.update(users)
.set(updateData)
.where(eq(users.id, userId))
// Invalidate cache by re-querying (cache is per-request anyway)
return await getUserProfile(userId)
}
/**
* Update a user's avatar image URL.
* Returns the updated user profile or null if the user was not found.
*/
export async function updateUserAvatar(
userId: string,
imageUrl: string | null
): Promise<UserProfile | null> {
await db
.update(users)
.set({ image: imageUrl })
.where(eq(users.id, userId))
return await getUserProfile(userId)
}
export type UsersDashboardStats = {
userCount: number
activeSessionsCount: number
userRoleCounts: Array<{ role: string; count: number }>
recentUsers: Array<{
id: string
name: string | null
email: string
role: string | null
createdAt: string
}>
}
export const getUsersDashboardStats = cache(async (): Promise<UsersDashboardStats> => {
const now = new Date()
const [userCountRow, activeSessionsRow, userRoleCountRows, recentUserRows] = await Promise.all([
db.select({ value: count() }).from(users),
db.select({ value: count() }).from(sessions).where(gt(sessions.expires, now)),
db
.select({ role: roles.name, value: count() })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.groupBy(roles.name),
db
.select({
id: users.id,
name: users.name,
email: users.email,
createdAt: users.createdAt,
})
.from(users)
.orderBy(desc(users.createdAt))
.limit(8),
])
const userCount = Number(userCountRow[0]?.value ?? 0)
const activeSessionsCount = Number(activeSessionsRow[0]?.value ?? 0)
const userRoleCounts = userRoleCountRows
.map((r) => ({ role: r.role ?? "unknown", count: Number(r.value ?? 0) }))
.sort((a, b) => b.count - a.count)
const recentUserIds = recentUserRows.map((u) => u.id)
const recentRoleRows = recentUserIds.length
? await db
.select({
userId: usersToRoles.userId,
roleName: roles.name,
})
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(inArray(usersToRoles.userId, recentUserIds))
: []
const rolesByUserId = new Map<string, string[]>()
for (const row of recentRoleRows) {
const list = rolesByUserId.get(row.userId) ?? []
list.push(row.roleName)
rolesByUserId.set(row.userId, list)
}
const recentUsers = recentUserRows.map((u) => {
const roleNames = rolesByUserId.get(u.id) ?? []
return {
id: u.id,
name: u.name,
email: u.email,
role: resolvePrimaryRole(roleNames),
createdAt: u.createdAt.toISOString(),
}
})
return {
userCount,
activeSessionsCount,
userRoleCounts,
recentUsers,
}
})
// ---------------------------------------------------------------------------
// Cross-module query interfaces — read-only access for other modules
// ---------------------------------------------------------------------------
export type UserNameOption = {
id: string
name: string | null
email: string
}
export type TeacherOption = {
id: string
name: string | null
email: string
}
/** Returns the user row if the user has the specified role, otherwise null. */
export const getUserWithRole = cache(
async (userId: string, roleName: string): Promise<{ id: string; name: string | null; email: string } | null> => {
const id = userId.trim()
if (!id) return null
const [row] = 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(and(eq(users.id, id), eq(roles.name, roleName)))
.limit(1)
return row ?? null
}
)
/**
* Returns the current authenticated student user (id + name) by reading the
* session and verifying the "student" role via JOIN users + usersToRoles + roles.
* Returns null if not authenticated or the user does not have the student role.
*/
export const getCurrentStudentUser = cache(async (): Promise<{ id: string; name: string; gradeId: string | null } | null> => {
const session = await auth()
const userId = String(session?.user?.id ?? "").trim()
if (!userId) return null
const student = await getUserWithRole(userId, "student")
if (!student) return null
// 查询学生的 gradeId关联 grades 表)
const [userRow] = await db
.select({ gradeId: users.gradeId })
.from(users)
.where(eq(users.id, student.id))
.limit(1)
return { id: student.id, name: student.name || "Student", gradeId: userRow?.gradeId ?? null }
})
/** Returns a map of userId -> { name, email } for the given user ids. */
export const getUserNamesByIds = cache(
async (ids: string[]): Promise<Map<string, UserNameOption>> => {
const uniqueIds = Array.from(new Set(ids.filter((v): v is string => typeof v === "string" && v.length > 0)))
const result = new Map<string, UserNameOption>()
if (uniqueIds.length === 0) return result
const rows = await db
.select({ id: users.id, name: users.name, email: users.email })
.from(users)
.where(inArray(users.id, uniqueIds))
for (const r of rows) {
result.set(r.id, { id: r.id, name: r.name, email: r.email })
}
return result
}
)
/** Returns teachers (users with the "teacher" role) for the given user ids. */
export const getTeachersByIds = cache(
async (teacherIds: string[]): Promise<TeacherOption[]> => {
const uniqueIds = Array.from(new Set(teacherIds.filter((v): v is string => typeof v === "string" && v.length > 0)))
if (uniqueIds.length === 0) return []
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(and(eq(roles.name, "teacher"), inArray(users.id, uniqueIds)))
.groupBy(users.id, users.name, users.email)
return rows.map((r) => ({ id: r.id, name: r.name, email: r.email }))
}
)
export type UserBasicInfo = {
id: string
name: string | null
email: string
image: string | null
gradeId: string | null
}
/** Returns basic user info (id, name, email, image, gradeId) for a single user. */
export const getUserBasicInfo = cache(
async (userId: string): Promise<UserBasicInfo | null> => {
const id = userId.trim()
if (!id) return null
const [row] = await db
.select({
id: users.id,
name: users.name,
email: users.email,
image: users.image,
gradeId: users.gradeId,
})
.from(users)
.where(eq(users.id, id))
.limit(1)
return row ?? null
}
)
/** Returns user IDs for all users with the given gradeId. */
export const getUserIdsByGradeId = cache(
async (gradeId: string): Promise<string[]> => {
const id = gradeId.trim()
if (!id) return []
const rows = await db
.select({ id: users.id })
.from(users)
.where(eq(users.gradeId, id))
return rows.map((r) => r.id)
}
)
/** Returns all user IDs (used for school-wide announcement notifications). */
export const getAllUserIds = cache(async (): Promise<string[]> => {
const rows = await db.select({ id: users.id }).from(users)
return rows.map((r) => r.id)
})
export type AdminUserListItem = {
id: string
name: string | null
email: string
roles: string[]
phone: string | null
createdAt: Date
}
export type AdminUserListResult = {
items: AdminUserListItem[]
total: number
page: number
pageSize: number
totalPages: number
}
export async function getAdminUsers(params: {
page?: number
pageSize?: number
search?: string
role?: string
}): Promise<AdminUserListResult> {
const page = Math.max(1, params.page ?? 1)
const pageSize = Math.min(100, Math.max(1, params.pageSize ?? 20))
const offset = (page - 1) * pageSize
const conditions = []
if (params.search) {
const search = `%${params.search}%`
conditions.push(
or(ilike(users.name, search), ilike(users.email, search))
)
}
const whereClause = conditions.length > 0 ? and(...conditions) : undefined
const [userRows, countRow] = await Promise.all([
db
.select()
.from(users)
.where(whereClause)
.orderBy(desc(users.createdAt))
.limit(pageSize)
.offset(offset),
db.select({ value: count() }).from(users).where(whereClause),
])
const userIds = userRows.map((u) => u.id)
const roleRows = userIds.length
? await db
.select({ userId: usersToRoles.userId, roleName: roles.name })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(inArray(usersToRoles.userId, userIds))
: []
const rolesByUserId = new Map<string, string[]>()
for (const row of roleRows) {
const list = rolesByUserId.get(row.userId) ?? []
list.push(row.roleName)
rolesByUserId.set(row.userId, list)
}
const items = userRows.map((u) => ({
id: u.id,
name: u.name,
email: u.email,
roles: rolesByUserId.get(u.id) ?? [],
phone: u.phone,
createdAt: u.createdAt,
}))
const total = Number(countRow[0]?.value ?? 0)
return {
items,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
}
}
export async function getAdminUserRoles(): Promise<string[]> {
const rows = await db.select({ name: roles.name }).from(roles)
return rows.map((r) => r.name)
}