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 => { 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 { const updateData: Partial = {} 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 { 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 => { 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() 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> => { const uniqueIds = Array.from(new Set(ids.filter((v): v is string => typeof v === "string" && v.length > 0))) const result = new Map() 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 => { 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 => { 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 => { 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 => { 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 { 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() 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 { const rows = await db.select({ name: roles.name }).from(roles) return rows.map((r) => r.name) }