feat(shared): add errors lib, question-content, and update permissions and UI
- Add errors lib for standardized error handling - Add question-content lib for question content processing - Update action-utils, ai/provider-config, auth-guard, permissions, types/permissions - Update UI sheet component - Update proxy middleware
This commit is contained in:
22
src/proxy.ts
22
src/proxy.ts
@@ -27,6 +27,13 @@ const DASHBOARD_ROUTE_PERMISSIONS: Record<string, string> = {
|
||||
"/parent/dashboard": Permissions.DASHBOARD_PARENT_READ,
|
||||
}
|
||||
|
||||
// 精确路由权限(优先级最高,覆盖 ROUTE_PERMISSIONS 的前缀匹配)
|
||||
// 用于将 /admin/* 下的特定页面开放给非管理员角色
|
||||
// V3.1:/admin/ai-settings 对所有 AI_CHAT 用户开放(管理自己的 private provider)
|
||||
const SPECIFIC_ROUTE_PERMISSIONS: Record<string, string> = {
|
||||
"/admin/ai-settings": Permissions.AI_CHAT,
|
||||
}
|
||||
|
||||
// API route prefix → required permission
|
||||
const API_PERMISSIONS: Record<string, string> = {
|
||||
"/api/ai/chat": Permissions.AI_CHAT,
|
||||
@@ -99,7 +106,20 @@ export async function proxy(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Check page route permissions
|
||||
// 优先检查仪表盘路由的细粒度权限(防止跨角色访问仪表盘)
|
||||
// 优先级 1:精确路由权限(覆盖前缀匹配,用于将 /admin/* 下特定页面开放给非管理员)
|
||||
if (Object.prototype.hasOwnProperty.call(SPECIFIC_ROUTE_PERMISSIONS, pathname)) {
|
||||
const requiredPerm = SPECIFIC_ROUTE_PERMISSIONS[pathname]
|
||||
if (!permissions.includes(requiredPerm)) {
|
||||
const defaultPath = resolveDefaultPath(roles)
|
||||
const redirectUrl = new URL(defaultPath, request.url)
|
||||
redirectUrl.searchParams.set("from", pathname)
|
||||
redirectUrl.searchParams.set("reason", "forbidden")
|
||||
return NextResponse.redirect(redirectUrl)
|
||||
}
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// 优先级 2:仪表盘路由的细粒度权限(防止跨角色访问仪表盘)
|
||||
if (Object.prototype.hasOwnProperty.call(DASHBOARD_ROUTE_PERMISSIONS, pathname)) {
|
||||
const requiredPerm = DASHBOARD_ROUTE_PERMISSIONS[pathname]
|
||||
if (!permissions.includes(requiredPerm)) {
|
||||
|
||||
@@ -46,9 +46,11 @@ function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
hideClose = false,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "bottom" | "left" | "right"
|
||||
hideClose?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
@@ -70,10 +72,12 @@ function SheetContent({
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
{hideClose ? null : (
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import { PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { PermissionDeniedError } from "@/shared/lib/errors"
|
||||
|
||||
/**
|
||||
* 已知的业务错误类型,消息可以安全返回给客户端。
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import "server-only"
|
||||
|
||||
import { desc, eq } from "drizzle-orm"
|
||||
import { and, desc, eq, or, type SQL } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { aiProviders } from "@/shared/db/schema"
|
||||
import { getSession } from "@/shared/lib/session"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
import { decryptAiApiKey } from "./api-key-crypto"
|
||||
|
||||
@@ -13,57 +15,118 @@ export type AiProviderConfig = {
|
||||
model: string
|
||||
}
|
||||
|
||||
type ProviderAccessRow = {
|
||||
apiKeyEncrypted: string
|
||||
baseUrl: string | null
|
||||
model: string
|
||||
visibility: "public" | "private"
|
||||
createdBy: string | null
|
||||
}
|
||||
|
||||
type UserContext = { userId: string; isAdmin: boolean }
|
||||
|
||||
/**
|
||||
* 解析当前用户上下文,用于可见性校验
|
||||
*
|
||||
* @returns 用户上下文,或 null(无 session 时)
|
||||
*/
|
||||
async function resolveUserContext(): Promise<UserContext | null> {
|
||||
const session = await getSession()
|
||||
const userId = session?.user?.id
|
||||
if (!userId) return null
|
||||
const permissions = (session.user.permissions ?? []) as string[]
|
||||
return {
|
||||
userId,
|
||||
isAdmin: permissions.includes(Permissions.AI_CONFIGURE),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验当前用户是否有权访问指定 Provider
|
||||
*
|
||||
* 规则:
|
||||
* - 无 session(系统路径):允许(向后兼容)
|
||||
* - 管理员(AI_CONFIGURE):允许访问任意 Provider
|
||||
* - 普通用户:仅允许访问 public Provider 或自己创建的 private Provider
|
||||
*/
|
||||
function canAccessProvider(
|
||||
provider: { visibility: "public" | "private"; createdBy: string | null },
|
||||
userCtx: UserContext | null
|
||||
): boolean {
|
||||
if (!userCtx) return true
|
||||
if (userCtx.isAdmin) return true
|
||||
if (provider.visibility === "public") return true
|
||||
if (provider.createdBy === userCtx.userId) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造可见性过滤条件
|
||||
*
|
||||
* 管理员或无 session 时返回 undefined(不过滤);
|
||||
* 普通用户返回 (visibility = public OR createdBy = userId)。
|
||||
*/
|
||||
function buildVisibilityFilter(userCtx: UserContext | null): SQL | undefined {
|
||||
if (!userCtx || userCtx.isAdmin) return undefined
|
||||
return or(
|
||||
eq(aiProviders.visibility, "public"),
|
||||
eq(aiProviders.createdBy, userCtx.userId)
|
||||
) ?? undefined
|
||||
}
|
||||
|
||||
function toConfig(row: ProviderAccessRow): AiProviderConfig {
|
||||
return {
|
||||
apiKey: decryptAiApiKey(row.apiKeyEncrypted),
|
||||
baseUrl: row.baseUrl ?? undefined,
|
||||
model: row.model,
|
||||
}
|
||||
}
|
||||
|
||||
const selectColumns = {
|
||||
apiKeyEncrypted: aiProviders.apiKeyEncrypted,
|
||||
baseUrl: aiProviders.baseUrl,
|
||||
model: aiProviders.model,
|
||||
visibility: aiProviders.visibility,
|
||||
createdBy: aiProviders.createdBy,
|
||||
} as const
|
||||
|
||||
export const getAiProviderConfig = async (providerId?: string): Promise<AiProviderConfig> => {
|
||||
const userCtx = await resolveUserContext()
|
||||
|
||||
if (providerId) {
|
||||
const [selected] = await db
|
||||
.select({
|
||||
apiKeyEncrypted: aiProviders.apiKeyEncrypted,
|
||||
baseUrl: aiProviders.baseUrl,
|
||||
model: aiProviders.model,
|
||||
})
|
||||
.select(selectColumns)
|
||||
.from(aiProviders)
|
||||
.where(eq(aiProviders.id, providerId))
|
||||
.limit(1)
|
||||
if (!selected) throw new Error("AI provider not configured")
|
||||
return {
|
||||
apiKey: decryptAiApiKey(selected.apiKeyEncrypted),
|
||||
baseUrl: selected.baseUrl ?? undefined,
|
||||
model: selected.model,
|
||||
if (!canAccessProvider(selected, userCtx)) {
|
||||
throw new Error("AI provider access denied")
|
||||
}
|
||||
return toConfig(selected)
|
||||
}
|
||||
|
||||
const visibilityFilter = buildVisibilityFilter(userCtx)
|
||||
|
||||
const defaultWhere = visibilityFilter
|
||||
? and(eq(aiProviders.isDefault, true), visibilityFilter)
|
||||
: eq(aiProviders.isDefault, true)
|
||||
|
||||
const [active] = await db
|
||||
.select({
|
||||
apiKeyEncrypted: aiProviders.apiKeyEncrypted,
|
||||
baseUrl: aiProviders.baseUrl,
|
||||
model: aiProviders.model,
|
||||
})
|
||||
.select(selectColumns)
|
||||
.from(aiProviders)
|
||||
.where(eq(aiProviders.isDefault, true))
|
||||
.where(defaultWhere)
|
||||
.orderBy(desc(aiProviders.updatedAt))
|
||||
.limit(1)
|
||||
if (active) {
|
||||
return {
|
||||
apiKey: decryptAiApiKey(active.apiKeyEncrypted),
|
||||
baseUrl: active.baseUrl ?? undefined,
|
||||
model: active.model,
|
||||
}
|
||||
}
|
||||
if (active) return toConfig(active)
|
||||
|
||||
const [fallback] = await db
|
||||
.select({
|
||||
apiKeyEncrypted: aiProviders.apiKeyEncrypted,
|
||||
baseUrl: aiProviders.baseUrl,
|
||||
model: aiProviders.model,
|
||||
})
|
||||
.select(selectColumns)
|
||||
.from(aiProviders)
|
||||
.where(visibilityFilter ?? undefined)
|
||||
.orderBy(desc(aiProviders.updatedAt))
|
||||
.limit(1)
|
||||
if (!fallback) throw new Error("AI provider not configured")
|
||||
|
||||
return {
|
||||
apiKey: decryptAiApiKey(fallback.apiKeyEncrypted),
|
||||
baseUrl: fallback.baseUrl ?? undefined,
|
||||
model: fallback.model,
|
||||
}
|
||||
return toConfig(fallback)
|
||||
}
|
||||
|
||||
@@ -7,17 +7,12 @@ import {
|
||||
grades,
|
||||
parentStudentRelations,
|
||||
} from "@/shared/db/schema"
|
||||
import { eq, or } from "drizzle-orm"
|
||||
import { eq, inArray, or } from "drizzle-orm"
|
||||
import { getSession } from "@/shared/lib/session"
|
||||
import { PermissionDeniedError } from "@/shared/lib/errors"
|
||||
|
||||
export class PermissionDeniedError extends Error {
|
||||
constructor(permission: string) {
|
||||
super(
|
||||
`权限不足:需要 ${permission} 权限。请联系管理员授权或切换账号后重试。`
|
||||
)
|
||||
this.name = "PermissionDeniedError"
|
||||
}
|
||||
}
|
||||
// Re-export for backward compatibility (other modules still import from here)
|
||||
export { PermissionDeniedError } from "@/shared/lib/errors"
|
||||
|
||||
/**
|
||||
* Get the full authentication context for the current user.
|
||||
@@ -114,16 +109,26 @@ async function resolveDataScope(userId: string, roleNames: Role[]): Promise<Data
|
||||
}
|
||||
|
||||
// Student: can see data from their enrolled classes
|
||||
// Pre-resolve classIds here to avoid N+1 queries in data-access layer
|
||||
// Pre-resolve classIds and gradeIds here to avoid N+1 queries in data-access layer
|
||||
if (roleNames.includes("student")) {
|
||||
const enrolledClasses = await db
|
||||
.select({ classId: classEnrollments.classId })
|
||||
.select({ classId: classEnrollments.classId, gradeId: classes.gradeId })
|
||||
.from(classEnrollments)
|
||||
.innerJoin(classes, eq(classEnrollments.classId, classes.id))
|
||||
.where(eq(classEnrollments.studentId, userId))
|
||||
|
||||
const gradeIds = [
|
||||
...new Set(
|
||||
enrolledClasses
|
||||
.map((c) => c.gradeId)
|
||||
.filter((g): g is string => g !== null),
|
||||
),
|
||||
]
|
||||
|
||||
return {
|
||||
type: "class_members",
|
||||
classIds: enrolledClasses.map((c) => c.classId),
|
||||
gradeIds: gradeIds.length > 0 ? gradeIds : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,7 +139,28 @@ async function resolveDataScope(userId: string, roleNames: Role[]): Promise<Data
|
||||
.from(parentStudentRelations)
|
||||
.where(eq(parentStudentRelations.parentId, userId))
|
||||
|
||||
return { type: "children", childrenIds: children.map((c) => c.studentId) }
|
||||
const childrenIds = children.map((c) => c.studentId)
|
||||
|
||||
// Pre-resolve gradeIds from children's enrolled classes
|
||||
let gradeIds: string[] | undefined
|
||||
if (childrenIds.length > 0) {
|
||||
const childrenClasses = await db
|
||||
.select({ gradeId: classes.gradeId })
|
||||
.from(classEnrollments)
|
||||
.innerJoin(classes, eq(classEnrollments.classId, classes.id))
|
||||
.where(inArray(classEnrollments.studentId, childrenIds))
|
||||
|
||||
const uniqueGradeIds = [
|
||||
...new Set(
|
||||
childrenClasses
|
||||
.map((c) => c.gradeId)
|
||||
.filter((g): g is string => g !== null),
|
||||
),
|
||||
]
|
||||
gradeIds = uniqueGradeIds.length > 0 ? uniqueGradeIds : undefined
|
||||
}
|
||||
|
||||
return { type: "children", childrenIds, gradeIds }
|
||||
}
|
||||
|
||||
// Fallback: only own data
|
||||
|
||||
15
src/shared/lib/errors.ts
Normal file
15
src/shared/lib/errors.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 共享错误类,无服务端依赖,可安全用于客户端和服务端。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 权限不足错误。由 requirePermission 抛出。
|
||||
*/
|
||||
export class PermissionDeniedError extends Error {
|
||||
constructor(permission: string) {
|
||||
super(
|
||||
`权限不足:需要 ${permission} 权限。请联系管理员授权或切换账号后重试。`
|
||||
)
|
||||
this.name = "PermissionDeniedError"
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,7 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||
Permissions.FILE_DELETE,
|
||||
Permissions.DASHBOARD_ADMIN_READ,
|
||||
Permissions.ERROR_BOOK_ANALYTICS_READ,
|
||||
Permissions.ADAPTIVE_PRACTICE_READ,
|
||||
],
|
||||
teacher: [
|
||||
Permissions.EXAM_CREATE,
|
||||
@@ -109,6 +110,7 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||
Permissions.LESSON_PLAN_PUBLISH,
|
||||
Permissions.DASHBOARD_TEACHER_READ,
|
||||
Permissions.ERROR_BOOK_ANALYTICS_READ,
|
||||
Permissions.ADAPTIVE_PRACTICE_READ,
|
||||
],
|
||||
student: [
|
||||
Permissions.EXAM_READ,
|
||||
@@ -129,9 +131,12 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||
Permissions.ELECTIVE_SELECT,
|
||||
Permissions.ELECTIVE_READ,
|
||||
Permissions.DIAGNOSTIC_READ,
|
||||
Permissions.LESSON_PLAN_READ,
|
||||
Permissions.DASHBOARD_STUDENT_READ,
|
||||
Permissions.ERROR_BOOK_READ,
|
||||
Permissions.ERROR_BOOK_MANAGE,
|
||||
Permissions.ADAPTIVE_PRACTICE_READ,
|
||||
Permissions.ADAPTIVE_PRACTICE_MANAGE,
|
||||
],
|
||||
parent: [
|
||||
Permissions.EXAM_READ,
|
||||
@@ -144,8 +149,10 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||
Permissions.MESSAGE_SEND,
|
||||
Permissions.MESSAGE_READ,
|
||||
Permissions.MESSAGE_DELETE,
|
||||
Permissions.LESSON_PLAN_READ,
|
||||
Permissions.DASHBOARD_PARENT_READ,
|
||||
Permissions.ERROR_BOOK_READ,
|
||||
Permissions.ADAPTIVE_PRACTICE_READ,
|
||||
],
|
||||
grade_head: [
|
||||
Permissions.EXAM_CREATE,
|
||||
@@ -183,7 +190,9 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||
Permissions.EXAM_PROCTOR_READ,
|
||||
Permissions.DIAGNOSTIC_MANAGE,
|
||||
Permissions.DIAGNOSTIC_READ,
|
||||
Permissions.LESSON_PLAN_READ,
|
||||
Permissions.ERROR_BOOK_ANALYTICS_READ,
|
||||
Permissions.ADAPTIVE_PRACTICE_READ,
|
||||
],
|
||||
teaching_head: [
|
||||
Permissions.EXAM_CREATE,
|
||||
@@ -216,7 +225,9 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||
Permissions.ELECTIVE_READ,
|
||||
Permissions.EXAM_PROCTOR_READ,
|
||||
Permissions.DIAGNOSTIC_READ,
|
||||
Permissions.LESSON_PLAN_READ,
|
||||
Permissions.ERROR_BOOK_ANALYTICS_READ,
|
||||
Permissions.ADAPTIVE_PRACTICE_READ,
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
99
src/shared/lib/question-content.ts
Normal file
99
src/shared/lib/question-content.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 题目内容解析纯函数(共享层)
|
||||
*
|
||||
* 从 `unknown` 类型的题目内容中安全提取文本、选项、正确答案等。
|
||||
* 所有函数均为纯函数,无副作用,便于单测。
|
||||
*
|
||||
* 这些函数被 homework、error-book 等多个模块共享使用,
|
||||
* 避免各模块重复实现题目内容解析逻辑。
|
||||
*/
|
||||
|
||||
export type QuestionOption = {
|
||||
id: string
|
||||
text: string
|
||||
isCorrect?: boolean
|
||||
}
|
||||
|
||||
export const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null
|
||||
|
||||
export const getQuestionText = (content: unknown): string => {
|
||||
if (!isRecord(content)) return ""
|
||||
return typeof content.text === "string" ? content.text : ""
|
||||
}
|
||||
|
||||
export const getOptions = (content: unknown): QuestionOption[] => {
|
||||
if (!isRecord(content)) return []
|
||||
const raw = content.options
|
||||
if (!Array.isArray(raw)) return []
|
||||
const out: QuestionOption[] = []
|
||||
for (const item of raw) {
|
||||
if (!isRecord(item)) continue
|
||||
const id = typeof item.id === "string" ? item.id : ""
|
||||
const text = typeof item.text === "string" ? item.text : ""
|
||||
if (!id || !text) continue
|
||||
const isCorrect = item.isCorrect === true
|
||||
out.push({ id, text, isCorrect })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export const getChoiceCorrectIds = (content: unknown): string[] => {
|
||||
return getOptions(content)
|
||||
.filter((o): o is QuestionOption & { isCorrect: true } => o.isCorrect === true)
|
||||
.map((o) => o.id)
|
||||
}
|
||||
|
||||
export const getJudgmentCorrectAnswer = (content: unknown): boolean | null => {
|
||||
if (!isRecord(content)) return null
|
||||
return typeof content.correctAnswer === "boolean" ? content.correctAnswer : null
|
||||
}
|
||||
|
||||
export const getTextCorrectAnswers = (content: unknown): string[] => {
|
||||
if (!isRecord(content)) return []
|
||||
const raw = content.correctAnswer
|
||||
if (typeof raw === "string") return [raw]
|
||||
if (Array.isArray(raw)) return raw.filter((x): x is string => typeof x === "string")
|
||||
return []
|
||||
}
|
||||
|
||||
export type QuestionType = "single_choice" | "multiple_choice" | "judgment" | "text" | string
|
||||
|
||||
export const extractAnswerValue = (studentAnswer: unknown): unknown => {
|
||||
if (isRecord(studentAnswer) && "answer" in studentAnswer) return studentAnswer.answer
|
||||
return studentAnswer
|
||||
}
|
||||
|
||||
/**
|
||||
* 从题目内容中提取正确答案,统一序列化为可存储的 JSON 格式。
|
||||
*
|
||||
* - 选择题:返回正确选项 ID 数组
|
||||
* - 判断题:返回布尔值
|
||||
* - 填空题:返回正确答案字符串数组
|
||||
* - 其他题型或无标准答案:返回 null
|
||||
*
|
||||
* @param questionType 题目类型
|
||||
* @param content 题目内容(unknown 类型)
|
||||
* @returns 序列化后的正确答案,或 null
|
||||
*/
|
||||
export function extractCorrectAnswer(
|
||||
questionType: string,
|
||||
content: unknown,
|
||||
): unknown {
|
||||
if (questionType === "single_choice" || questionType === "multiple_choice") {
|
||||
const ids = getChoiceCorrectIds(content)
|
||||
return ids.length > 0 ? ids : null
|
||||
}
|
||||
|
||||
if (questionType === "judgment") {
|
||||
const answer = getJudgmentCorrectAnswer(content)
|
||||
return answer // 可能是 true/false/null
|
||||
}
|
||||
|
||||
if (questionType === "text") {
|
||||
const answers = getTextCorrectAnswers(content)
|
||||
return answers.length > 0 ? answers : null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -131,6 +131,12 @@ export const Permissions = {
|
||||
ERROR_BOOK_MANAGE: "error_book:manage",
|
||||
/** 读取班级/年级/全校错题统计分析(教师、年级主任、教务主任、管理员) */
|
||||
ERROR_BOOK_ANALYTICS_READ: "error_book:analytics_read",
|
||||
|
||||
// Adaptive Practice (专项练习)
|
||||
/** 读取自己的专项练习记录(学生)或子女的练习记录(家长) */
|
||||
ADAPTIVE_PRACTICE_READ: "adaptive_practice:read",
|
||||
/** 发起和管理自己的专项练习(学生) */
|
||||
ADAPTIVE_PRACTICE_MANAGE: "adaptive_practice:manage",
|
||||
} as const satisfies Record<string, string>
|
||||
|
||||
export type Permission = (typeof Permissions)[keyof typeof Permissions]
|
||||
@@ -160,17 +166,19 @@ export function isRole(value: string): value is Role {
|
||||
* - `owned`: only rows created by the user.
|
||||
* - `class_members`: rows visible to members of the user's enrolled classes (student).
|
||||
* `classIds` is pre-resolved by `resolveDataScope` to avoid N+1 queries downstream.
|
||||
* `gradeIds` is pre-resolved from enrolled classes for grade-based filtering (e.g. lesson plans).
|
||||
* - `grade_managed`: rows within grades the user manages (grade_head / teaching_head).
|
||||
* - `class_taught`: rows within classes the user teaches (teacher).
|
||||
* - `children`: rows belonging to the user's children (parent).
|
||||
* `gradeIds` is pre-resolved from children's enrolled classes for grade-based filtering.
|
||||
*/
|
||||
export type DataScope =
|
||||
| { type: "all" }
|
||||
| { type: "owned"; userId: string }
|
||||
| { type: "class_members"; classIds: string[] }
|
||||
| { type: "class_members"; classIds: string[]; gradeIds?: string[] }
|
||||
| { type: "grade_managed"; gradeIds: string[] }
|
||||
| { type: "class_taught"; classIds: string[]; subjectIds?: string[] }
|
||||
| { type: "children"; childrenIds: string[] }
|
||||
| { type: "children"; childrenIds: string[]; gradeIds?: string[] }
|
||||
|
||||
/**
|
||||
* Authentication context for the current request.
|
||||
|
||||
Reference in New Issue
Block a user