refactor: fix all P0/P1/P2 bugs and architecture issues

Bug fixes (from bugs/ directory):

- Fix cross-module DB queries in 9 modules (homework, grades, parent, diagnostic, elective, proctoring, notifications, scheduling, classes) by routing through data-access functions

- Fix shared/lib <-> auth circular dependency via new session.ts module

- Fix divide-by-zero guard in grades data-access

- Fix audit export data truncation (paginated fetch for full datasets)

- Fix missing transactions in homework grading and elective lottery

- Fix missing revalidatePath in course-plans actions

- Fix frontend permission checks using requirePermission instead of requireAuth

- Fix dashboard role routing using session.user.roles

- Fix student auth pattern (migrate getDemoStudentUser to users module)

- Fix ActionState return type handling in components

Code quality fixes:

- Remove 60+ as type assertions (replace with type guards)

- Remove non-null assertions (use optional chaining or explicit checks)

- Convert dynamic imports to static imports (grades, diagnostic)

- Add React.cache() wrapping for read functions

- Parallelize independent queries with Promise.all

- Add explicit return types to 30+ arrow functions

- Replace any with unknown + type guards

- Fix import type for type-only imports

- Add Zod validation schemas for classes and diagnostic modules

- Extract duplicate code (normalizeRoleName, normalizeBcryptHash, logger IP extraction)

- Add console.error to silent catch blocks

- Fix permission naming consistency (exam:proctor_read -> exam:proctor:read)

Architecture doc sync:

- Update 004_architecture_impact_map.md and 005_architecture_data.json

- Update management-modules-audit.md for P0-7 cross-module fix

Moved deleted proctoring event route to deletes/ folder.
This commit is contained in:
SpecialX
2026-06-19 05:13:09 +08:00
parent 063baffe4c
commit 49291fcc31
114 changed files with 12548 additions and 3395 deletions

View File

@@ -3,15 +3,23 @@
import { z } from "zod"
import { revalidatePath } from "next/cache"
import { createId } from "@paralleldrive/cuid2"
import { count, desc, eq } from "drizzle-orm"
import { db } from "@/shared/db"
import { aiProviders } from "@/shared/db/schema"
import type { ActionState } from "@/shared/types/action-state"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { encryptAiApiKey, getAiErrorMessage, testAiProviderById, testAiProviderConfig } from "@/shared/lib/ai"
import {
countDefaultAiProviders,
createAiProvider,
getAiProviderForUpdate,
getAiProviderSummaries as fetchAiProviderSummaries,
updateAiProvider,
} from "./data-access"
import type { AiProviderSummary } from "./types"
export type { AiProviderSummary } from "./types"
const ProviderSchema = z.enum(["zhipu", "openai", "gemini", "custom"])
const AiProviderFormSchema = z.object({
@@ -35,22 +43,12 @@ const AiProviderTestSchema = AiProviderFormSchema.extend({
}
})
export type AiProviderSummary = {
id: string
provider: z.infer<typeof ProviderSchema>
baseUrl: string | null
model: string
apiKeyLast4: string | null
isDefault: boolean
updatedAt: Date
}
const ensureUser = async () => {
const ensureUser = async (): Promise<{ id: string }> => {
const ctx = await requirePermission(Permissions.AI_CONFIGURE)
return { id: ctx.userId }
}
const normalizeBaseUrl = (value: string | undefined) => {
const normalizeBaseUrl = (value: string | undefined): string | null => {
const raw = String(value ?? "").trim()
if (!raw.length) return null
const trimmed = raw.replace(/\/+$/, "")
@@ -61,19 +59,7 @@ const normalizeBaseUrl = (value: string | undefined) => {
export async function getAiProviderSummaries(): Promise<AiProviderSummary[]> {
await ensureUser()
const rows = await db
.select({
id: aiProviders.id,
provider: aiProviders.provider,
baseUrl: aiProviders.baseUrl,
model: aiProviders.model,
apiKeyLast4: aiProviders.apiKeyLast4,
isDefault: aiProviders.isDefault,
updatedAt: aiProviders.updatedAt,
})
.from(aiProviders)
.orderBy(desc(aiProviders.updatedAt))
return rows
return fetchAiProviderSummaries()
}
export async function upsertAiProviderAction(
@@ -92,51 +78,39 @@ export async function upsertAiProviderAction(
return { success: false, message: "Base URL is required for this provider" }
}
const [defaultRow] = await db
.select({ value: count() })
.from(aiProviders)
.where(eq(aiProviders.isDefault, true))
const defaultCount = Number(defaultRow?.value ?? 0)
// Parallelize default-count and existing-provider queries
const [defaultCount, existing] = await Promise.all([
countDefaultAiProviders(),
payload.id ? getAiProviderForUpdate(payload.id) : Promise.resolve(null),
])
const hasDefault = defaultCount > 0
if (payload.id) {
const id = payload.id
const [existing] = await db
.select({
id: aiProviders.id,
apiKeyEncrypted: aiProviders.apiKeyEncrypted,
apiKeyLast4: aiProviders.apiKeyLast4,
isDefault: aiProviders.isDefault,
})
.from(aiProviders)
.where(eq(aiProviders.id, id))
.limit(1)
if (!existing) return { success: false, message: "AI provider not found" }
const nextKey = payload.apiKey?.trim()
const encrypted = nextKey ? encryptAiApiKey(nextKey) : existing.apiKeyEncrypted
const last4 = nextKey ? nextKey.slice(-4) : existing.apiKeyLast4
const nextIsDefault =
payload.isDefault === false && existing.isDefault && defaultCount <= 1 ? true : payload.isDefault ?? existing.isDefault
const isNextDefault =
payload.isDefault === false && existing.isDefault && defaultCount <= 1
? true
: payload.isDefault ?? existing.isDefault
await db.transaction(async (tx) => {
if (payload.isDefault) {
await tx.update(aiProviders).set({ isDefault: false }).where(eq(aiProviders.isDefault, true))
}
await tx
.update(aiProviders)
.set({
provider: payload.provider,
baseUrl,
model: payload.model,
apiKeyEncrypted: encrypted,
apiKeyLast4: last4,
isDefault: nextIsDefault,
updatedBy: user.id,
})
.where(eq(aiProviders.id, id))
})
await updateAiProvider(
id,
{
provider: payload.provider,
baseUrl,
model: payload.model,
apiKeyEncrypted: encrypted,
apiKeyLast4: last4,
isDefault: isNextDefault,
updatedBy: user.id,
},
payload.isDefault === true
)
revalidatePath("/settings")
return { success: true, message: "AI provider updated", data: id }
@@ -149,24 +123,22 @@ export async function upsertAiProviderAction(
const id = createId()
const encrypted = encryptAiApiKey(payload.apiKey.trim())
const last4 = payload.apiKey.trim().slice(-4)
const makeDefault = payload.isDefault ?? !hasDefault
const shouldMakeDefault = payload.isDefault ?? !hasDefault
await db.transaction(async (tx) => {
if (makeDefault) {
await tx.update(aiProviders).set({ isDefault: false }).where(eq(aiProviders.isDefault, true))
}
await tx.insert(aiProviders).values({
await createAiProvider(
{
id,
provider: payload.provider,
baseUrl,
model: payload.model,
apiKeyEncrypted: encrypted,
apiKeyLast4: last4,
isDefault: makeDefault,
isDefault: shouldMakeDefault,
createdBy: user.id,
updatedBy: user.id,
})
})
},
shouldMakeDefault
)
revalidatePath("/settings")
return { success: true, message: "AI provider created", data: id }