feat(parent,auth,onboarding,files,notifications,adaptive-practice,ai): add module updates
parent: - Add parent-student-attendance-detail component auth: - Add actions, data-access, schema, services, types onboarding: - Add parent-children-form and hooks directory files: - Add actions, schema, hooks directory notifications: - Add schema and schema test adaptive-practice: - Add answer-input, answer-result, practice-result-view, practice-starter-with-nav - Add question-card, question-content, lib and services directories ai: - Add context/create-ai-client-service, hooks/use-drag-position, hooks/use-position-persistence
This commit is contained in:
310
src/modules/files/actions.ts
Normal file
310
src/modules/files/actions.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
"use server"
|
||||
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
|
||||
import {
|
||||
requirePermission,
|
||||
checkPermission,
|
||||
PermissionDeniedError,
|
||||
} from "@/shared/lib/auth-guard"
|
||||
import { trackEvent } from "@/shared/lib/track-event"
|
||||
import { logAudit } from "@/shared/lib/audit-logger"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import { storageProvider } from "@/shared/lib/storage-provider"
|
||||
import {
|
||||
generateStoragePath,
|
||||
isAllowedMimeType,
|
||||
MAX_FILE_SIZE,
|
||||
} from "@/shared/lib/file-storage"
|
||||
|
||||
import { UploadMetadataSchema, BatchDeleteSchema, FileListQuerySchema } from "./schema"
|
||||
import {
|
||||
createFileAttachment,
|
||||
getFileAttachment,
|
||||
getFileAttachmentsWithFilters,
|
||||
getFileStats,
|
||||
getFileAttachmentsByIds,
|
||||
deleteFileAttachment,
|
||||
deleteFileAttachments,
|
||||
} from "./data-access"
|
||||
import type {
|
||||
FileAttachment,
|
||||
FileUploadResult,
|
||||
FileStats as FileStatsType,
|
||||
FileAttachmentQueryParams,
|
||||
BatchDeleteResult,
|
||||
} from "./types"
|
||||
|
||||
function handleActionError(e: unknown): ActionState<never> {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file: persist to storage + create DB record.
|
||||
*
|
||||
* Requires `FILE_UPLOAD` permission. Performs Zod-validated metadata,
|
||||
* MIME/size checks, and writes to disk via the storageProvider abstraction.
|
||||
* Records `file.uploaded` track event + audit log entry.
|
||||
*
|
||||
* @returns ActionState<FileUploadResult>
|
||||
*/
|
||||
export async function uploadFileAction(
|
||||
file: File,
|
||||
rawMetadata: { targetType?: string | null; targetId?: string | null }
|
||||
): Promise<ActionState<FileUploadResult>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.FILE_UPLOAD)
|
||||
|
||||
const meta = UploadMetadataSchema.parse(rawMetadata)
|
||||
|
||||
if (file.size === 0) {
|
||||
return { success: false, message: "File is empty" }
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return { success: false, message: "File size exceeds 10MB limit" }
|
||||
}
|
||||
const mimeType = file.type || "application/octet-stream"
|
||||
if (!isAllowedMimeType(mimeType)) {
|
||||
return { success: false, message: `File type ${mimeType} is not allowed` }
|
||||
}
|
||||
|
||||
const originalName = file.name || "unnamed"
|
||||
const storagePath = generateStoragePath(originalName)
|
||||
const bytes = Buffer.from(await file.arrayBuffer())
|
||||
const url = await storageProvider.save(bytes, storagePath)
|
||||
|
||||
const id = createId()
|
||||
const filename = storagePath.split("/").pop() ?? id
|
||||
const created = await createFileAttachment({
|
||||
id,
|
||||
filename,
|
||||
originalName,
|
||||
mimeType,
|
||||
size: file.size,
|
||||
storagePath,
|
||||
url,
|
||||
uploaderId: ctx.userId,
|
||||
targetType: meta.targetType ?? null,
|
||||
targetId: meta.targetId,
|
||||
})
|
||||
|
||||
if (!created) {
|
||||
return { success: false, message: "Failed to persist file record" }
|
||||
}
|
||||
|
||||
await trackEvent({
|
||||
event: "file.uploaded",
|
||||
userId: ctx.userId,
|
||||
targetId: id,
|
||||
targetType: "file",
|
||||
properties: {
|
||||
filename: originalName,
|
||||
mimeType,
|
||||
size: file.size,
|
||||
targetType: meta.targetType ?? null,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
action: "upload",
|
||||
module: "files",
|
||||
targetId: id,
|
||||
targetType: "file",
|
||||
detail: { filename: originalName, mimeType, size: file.size },
|
||||
})
|
||||
|
||||
const result: FileUploadResult = {
|
||||
id: created.id,
|
||||
url: created.url ?? url,
|
||||
filename: created.filename,
|
||||
originalName: created.originalName,
|
||||
size: created.size,
|
||||
mimeType: created.mimeType,
|
||||
}
|
||||
|
||||
return { success: true, data: result }
|
||||
} catch (e) {
|
||||
await trackEvent({
|
||||
event: "file.upload_failed",
|
||||
targetType: "file",
|
||||
properties: { reason: e instanceof Error ? e.message : "unknown" },
|
||||
}).catch(() => undefined)
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single file by ID.
|
||||
*
|
||||
* Requires `FILE_READ` permission. Non-admin users (those without
|
||||
* `FILE_DELETE`) can only read files they uploaded themselves,
|
||||
* preventing horizontal privilege escalation.
|
||||
*
|
||||
* Records `file.viewed` track event.
|
||||
*/
|
||||
export async function getFileAction(
|
||||
id: string
|
||||
): Promise<ActionState<FileAttachment>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.FILE_READ)
|
||||
const file = await getFileAttachment(id)
|
||||
if (!file) {
|
||||
return { success: false, message: "File not found" }
|
||||
}
|
||||
|
||||
// Data-level permission: non-admins can only read their own uploads.
|
||||
const { allowed: canManage } = await checkPermission(Permissions.FILE_DELETE)
|
||||
if (!canManage && file.uploaderId !== ctx.userId) {
|
||||
return { success: false, message: "Permission denied" }
|
||||
}
|
||||
|
||||
await trackEvent({
|
||||
event: "file.viewed",
|
||||
userId: ctx.userId,
|
||||
targetId: id,
|
||||
targetType: "file",
|
||||
})
|
||||
|
||||
return { success: true, data: file }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single file by ID.
|
||||
*
|
||||
* Requires `FILE_DELETE` permission. Persists removal via storageProvider
|
||||
* abstraction (no direct fs/promises calls). Records `file.deleted` track
|
||||
* event + audit log entry.
|
||||
*/
|
||||
export async function deleteFileAction(
|
||||
id: string
|
||||
): Promise<ActionState<{ id: string }>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.FILE_DELETE)
|
||||
const file = await getFileAttachment(id)
|
||||
if (!file) {
|
||||
return { success: false, message: "File not found" }
|
||||
}
|
||||
|
||||
await storageProvider.delete(file.storagePath)
|
||||
|
||||
const ok = await deleteFileAttachment(id)
|
||||
if (!ok) {
|
||||
return { success: false, message: "Failed to delete file record" }
|
||||
}
|
||||
|
||||
await trackEvent({
|
||||
event: "file.deleted",
|
||||
userId: ctx.userId,
|
||||
targetId: id,
|
||||
targetType: "file",
|
||||
properties: { filename: file.originalName, size: file.size },
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
action: "delete",
|
||||
module: "files",
|
||||
targetId: id,
|
||||
targetType: "file",
|
||||
detail: { filename: file.originalName, mimeType: file.mimeType },
|
||||
})
|
||||
|
||||
return { success: true, data: { id } }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch delete files by IDs.
|
||||
*
|
||||
* Requires `FILE_DELETE` permission. Input is Zod-validated (max 100 ids
|
||||
* per call). Persists storage removal via storageProvider abstraction.
|
||||
* Records `file.batch_deleted` track event + audit log entry.
|
||||
*/
|
||||
export async function batchDeleteFilesAction(
|
||||
rawIds: unknown
|
||||
): Promise<ActionState<BatchDeleteResult>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.FILE_DELETE)
|
||||
|
||||
const { ids } = BatchDeleteSchema.parse({ ids: rawIds })
|
||||
|
||||
const files = await getFileAttachmentsByIds(ids)
|
||||
|
||||
await Promise.all(
|
||||
files.map((f) =>
|
||||
storageProvider.delete(f.storagePath).catch(() => undefined)
|
||||
)
|
||||
)
|
||||
|
||||
const result = await deleteFileAttachments(ids)
|
||||
|
||||
await trackEvent({
|
||||
event: "file.batch_deleted",
|
||||
userId: ctx.userId,
|
||||
targetType: "file",
|
||||
properties: {
|
||||
requestedCount: ids.length,
|
||||
deletedCount: result.deletedCount,
|
||||
failedCount: result.failedIds.length,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
action: "batch_delete",
|
||||
module: "files",
|
||||
targetType: "file",
|
||||
detail: {
|
||||
requestedCount: ids.length,
|
||||
deletedCount: result.deletedCount,
|
||||
failedIds: result.failedIds,
|
||||
},
|
||||
})
|
||||
|
||||
return { success: true, data: result }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file list with filters (admin).
|
||||
*
|
||||
* Requires `FILE_READ` permission. Input is Zod-validated to enforce
|
||||
* limit (1..200) and offset (>=0) bounds.
|
||||
*/
|
||||
export async function getFileListAction(
|
||||
params?: Partial<FileAttachmentQueryParams>
|
||||
): Promise<ActionState<{ files: FileAttachment[] }>> {
|
||||
try {
|
||||
await requirePermission(Permissions.FILE_READ)
|
||||
const query = FileListQuerySchema.parse(params ?? {})
|
||||
const files = await getFileAttachmentsWithFilters(query)
|
||||
return { success: true, data: { files } }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file statistics (admin dashboard).
|
||||
*
|
||||
* Requires `FILE_READ` permission.
|
||||
*/
|
||||
export async function getFileStatsAction(): Promise<ActionState<FileStatsType>> {
|
||||
try {
|
||||
await requirePermission(Permissions.FILE_READ)
|
||||
const stats = await getFileStats()
|
||||
return { success: true, data: stats }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user