- 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
425 lines
12 KiB
TypeScript
425 lines
12 KiB
TypeScript
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)
|
||
}
|