feat : add audit logs
This commit is contained in:
+51
-34
@@ -9,6 +9,7 @@ import {
|
|||||||
deleteGlossaryAlias,
|
deleteGlossaryAlias,
|
||||||
} from "@/lib/glossary-repository";
|
} from "@/lib/glossary-repository";
|
||||||
import { requireAdmin } from "@/lib/auth/server";
|
import { requireAdmin } from "@/lib/auth/server";
|
||||||
|
import { auditActor } from "@/lib/audit-log";
|
||||||
import { publicGuideSections } from "@/lib/guides/public-sections";
|
import { publicGuideSections } from "@/lib/guides/public-sections";
|
||||||
import { publicationIssues } from "@/lib/guides/publication";
|
import { publicationIssues } from "@/lib/guides/publication";
|
||||||
import { getAdminGuideContentById } from "@/lib/guides/queries";
|
import { getAdminGuideContentById } from "@/lib/guides/queries";
|
||||||
@@ -47,10 +48,11 @@ type GuideActionResult =
|
|||||||
type CreateGuideActionResult = Exclude<GuideActionResult, { status: "conflict" }>;
|
type CreateGuideActionResult = Exclude<GuideActionResult, { status: "conflict" }>;
|
||||||
type SaveIntent = { overwrite?: boolean };
|
type SaveIntent = { overwrite?: boolean };
|
||||||
|
|
||||||
function overwriteDetails(admin: Awaited<ReturnType<typeof requireAdmin>>, intent?: SaveIntent) {
|
function mutationContext(
|
||||||
return intent?.overwrite
|
admin: Awaited<ReturnType<typeof requireAdmin>>,
|
||||||
? { operation: "overwrite" as const, actorName: (admin.user.name?.trim() || admin.user.email).slice(0, 100) }
|
intent?: SaveIntent,
|
||||||
: undefined;
|
) {
|
||||||
|
return { actor: auditActor(admin.user), overwrite: intent?.overwrite };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runGuideAction(action: () => Promise<{ version?: number }>): Promise<GuideActionResult> {
|
async function runGuideAction(action: () => Promise<{ version?: number }>): Promise<GuideActionResult> {
|
||||||
@@ -69,9 +71,9 @@ async function runGuideAction(action: () => Promise<{ version?: number }>): Prom
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createStructuredGuide(input: unknown): Promise<CreateGuideActionResult> {
|
export async function createStructuredGuide(input: unknown): Promise<CreateGuideActionResult> {
|
||||||
await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
try {
|
try {
|
||||||
const guide = await createGuide(input);
|
const guide = await createGuide(input, mutationContext(admin));
|
||||||
return { status: "saved", version: guide.version, location: `/admin/${guide.characterKey}` };
|
return { status: "saved", version: guide.version, location: `/admin/${guide.characterKey}` };
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
return { status: "error", message: cause instanceof Error ? cause.message : "สร้าง Guide ไม่สำเร็จ" };
|
return { status: "error", message: cause instanceof Error ? cause.message : "สร้าง Guide ไม่สำเร็จ" };
|
||||||
@@ -80,27 +82,27 @@ export async function createStructuredGuide(input: unknown): Promise<CreateGuide
|
|||||||
|
|
||||||
export async function saveWeaponSection(guideId: string, input: unknown, intent?: SaveIntent) {
|
export async function saveWeaponSection(guideId: string, input: unknown, intent?: SaveIntent) {
|
||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
return runGuideAction(() => saveWeapons(guideId, input, overwriteDetails(admin, intent)));
|
return runGuideAction(() => saveWeapons(guideId, input, mutationContext(admin, intent)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveGuideOverview(guideId: string, input: unknown, intent?: SaveIntent) {
|
export async function saveGuideOverview(guideId: string, input: unknown, intent?: SaveIntent) {
|
||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
return runGuideAction(() => updateGuideBasics(guideId, input, overwriteDetails(admin, intent)));
|
return runGuideAction(() => updateGuideBasics(guideId, input, mutationContext(admin, intent)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveArtifactSection(guideId: string, input: unknown, intent?: SaveIntent) {
|
export async function saveArtifactSection(guideId: string, input: unknown, intent?: SaveIntent) {
|
||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
return runGuideAction(() => saveArtifacts(guideId, input, overwriteDetails(admin, intent)));
|
return runGuideAction(() => saveArtifacts(guideId, input, mutationContext(admin, intent)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveConstellationSection(guideId: string, input: unknown, intent?: SaveIntent) {
|
export async function saveConstellationSection(guideId: string, input: unknown, intent?: SaveIntent) {
|
||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
return runGuideAction(() => saveConstellations(guideId, input, overwriteDetails(admin, intent)));
|
return runGuideAction(() => saveConstellations(guideId, input, mutationContext(admin, intent)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveTeamSection(guideId: string, input: unknown, intent?: SaveIntent) {
|
export async function saveTeamSection(guideId: string, input: unknown, intent?: SaveIntent) {
|
||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
return runGuideAction(() => saveTeams(guideId, input, overwriteDetails(admin, intent)));
|
return runGuideAction(() => saveTeams(guideId, input, mutationContext(admin, intent)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listTeamImportGuides(currentGuideId: string) {
|
export async function listTeamImportGuides(currentGuideId: string) {
|
||||||
@@ -129,9 +131,13 @@ export async function loadTeamImportGuide(guideId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createGuideExtra(guideId: string, input: unknown) {
|
export async function createGuideExtra(guideId: string, input: unknown) {
|
||||||
await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
try {
|
try {
|
||||||
const section = await createExtraSection(z.string().uuid().parse(guideId), input);
|
const section = await createExtraSection(
|
||||||
|
z.string().uuid().parse(guideId),
|
||||||
|
input,
|
||||||
|
mutationContext(admin),
|
||||||
|
);
|
||||||
updateTag(pageCacheTag(guideId));
|
updateTag(pageCacheTag(guideId));
|
||||||
return {
|
return {
|
||||||
status: "saved" as const,
|
status: "saved" as const,
|
||||||
@@ -160,18 +166,19 @@ export async function saveGuideExtra(
|
|||||||
const parsedGuideId = z.string().uuid().parse(guideId);
|
const parsedGuideId = z.string().uuid().parse(guideId);
|
||||||
const parsedSectionId = z.string().uuid().parse(sectionId);
|
const parsedSectionId = z.string().uuid().parse(sectionId);
|
||||||
const result = await runGuideAction(() =>
|
const result = await runGuideAction(() =>
|
||||||
saveExtraSection(parsedGuideId, parsedSectionId, input, overwriteDetails(admin, intent)),
|
saveExtraSection(parsedGuideId, parsedSectionId, input, mutationContext(admin, intent)),
|
||||||
);
|
);
|
||||||
if (result.status === "saved") updateTag(pageCacheTag(guideId));
|
if (result.status === "saved") updateTag(pageCacheTag(guideId));
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteGuideExtra(guideId: string, sectionId: string) {
|
export async function deleteGuideExtra(guideId: string, sectionId: string) {
|
||||||
await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
const result = await runGuideAction(() =>
|
const result = await runGuideAction(() =>
|
||||||
deleteExtraSection(
|
deleteExtraSection(
|
||||||
z.string().uuid().parse(guideId),
|
z.string().uuid().parse(guideId),
|
||||||
z.string().uuid().parse(sectionId),
|
z.string().uuid().parse(sectionId),
|
||||||
|
mutationContext(admin),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (result.status === "saved") updateTag(pageCacheTag(guideId));
|
if (result.status === "saved") updateTag(pageCacheTag(guideId));
|
||||||
@@ -179,36 +186,40 @@ export async function deleteGuideExtra(guideId: string, sectionId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function reorderGuideExtras(guideId: string, input: unknown) {
|
export async function reorderGuideExtras(guideId: string, input: unknown) {
|
||||||
await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
const result = await runGuideAction(() =>
|
const result = await runGuideAction(() =>
|
||||||
reorderExtraSections(z.string().uuid().parse(guideId), input),
|
reorderExtraSections(
|
||||||
|
z.string().uuid().parse(guideId),
|
||||||
|
input,
|
||||||
|
mutationContext(admin),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (result.status === "saved") updateTag(pageCacheTag(guideId));
|
if (result.status === "saved") updateTag(pageCacheTag(guideId));
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function moveGuideToTrash(guideId: string) {
|
export async function moveGuideToTrash(guideId: string) {
|
||||||
await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
return runGuideAction(() => trashGuide(guideId));
|
return runGuideAction(() => trashGuide(guideId, mutationContext(admin)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function restoreStructuredGuide(guideId: string) {
|
export async function restoreStructuredGuide(guideId: string) {
|
||||||
await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
return runGuideAction(() => restoreGuide(guideId));
|
return runGuideAction(() => restoreGuide(guideId, mutationContext(admin)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setStructuredGuideVisibility(
|
export async function setStructuredGuideVisibility(
|
||||||
guideId: string,
|
guideId: string,
|
||||||
input: unknown,
|
input: unknown,
|
||||||
) {
|
) {
|
||||||
await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
const parsed = z.strictObject({
|
const parsed = z.strictObject({
|
||||||
isPublic: z.boolean(),
|
isPublic: z.boolean(),
|
||||||
expectedVersion: z.number().int().positive(),
|
expectedVersion: z.number().int().positive(),
|
||||||
}).safeParse(input);
|
}).safeParse(input);
|
||||||
if (!parsed.success) return { status: "error" as const, message: "ข้อมูลการเผยแพร่ไม่ถูกต้อง" };
|
if (!parsed.success) return { status: "error" as const, message: "ข้อมูลการเผยแพร่ไม่ถูกต้อง" };
|
||||||
if (!parsed.data.isPublic) {
|
if (!parsed.data.isPublic) {
|
||||||
return runGuideAction(() => setGuideVisibility(guideId, parsed.data));
|
return runGuideAction(() => setGuideVisibility(guideId, parsed.data, mutationContext(admin)));
|
||||||
}
|
}
|
||||||
|
|
||||||
const guide = await getAdminGuideContentById(guideId);
|
const guide = await getAdminGuideContentById(guideId);
|
||||||
@@ -223,18 +234,21 @@ export async function setStructuredGuideVisibility(
|
|||||||
if (issues.length) {
|
if (issues.length) {
|
||||||
return { status: "error" as const, message: issues.join(" • "), issues };
|
return { status: "error" as const, message: issues.join(" • "), issues };
|
||||||
}
|
}
|
||||||
return runGuideAction(() => setGuideVisibility(guideId, parsed.data));
|
return runGuideAction(() => setGuideVisibility(guideId, parsed.data, mutationContext(admin)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteStructuredGuide(guideId: string) {
|
export async function deleteStructuredGuide(guideId: string) {
|
||||||
await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
return runGuideAction(() => permanentlyDeleteGuide(guideId));
|
return runGuideAction(() => permanentlyDeleteGuide(guideId, mutationContext(admin)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startCatalogSync() {
|
export async function startCatalogSync() {
|
||||||
await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
try {
|
try {
|
||||||
return { status: "started" as const, job: await startCatalogSyncJob() };
|
return {
|
||||||
|
status: "started" as const,
|
||||||
|
job: await startCatalogSyncJob(auditActor(admin.user)),
|
||||||
|
};
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
return { status: "error" as const, message: cause instanceof Error ? cause.message : "เริ่ม Sync ไม่สำเร็จ" };
|
return { status: "error" as const, message: cause instanceof Error ? cause.message : "เริ่ม Sync ไม่สำเร็จ" };
|
||||||
}
|
}
|
||||||
@@ -246,14 +260,14 @@ export async function getCatalogSyncProgress(jobId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function cancelCatalogSync(jobId: string) {
|
export async function cancelCatalogSync(jobId: string) {
|
||||||
await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
return cancelCatalogSyncJob(jobId);
|
return cancelCatalogSyncJob(jobId, auditActor(admin.user));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addGlossaryAlias(input: unknown) {
|
export async function addGlossaryAlias(input: unknown) {
|
||||||
await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
try {
|
try {
|
||||||
const alias = await createGlossaryAlias(input);
|
const alias = await createGlossaryAlias(input, auditActor(admin.user));
|
||||||
updateTag(GLOSSARY_CACHE_TAG);
|
updateTag(GLOSSARY_CACHE_TAG);
|
||||||
return { status: "saved" as const, alias };
|
return { status: "saved" as const, alias };
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
@@ -269,9 +283,12 @@ export async function addGlossaryAlias(input: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function removeGlossaryAlias(id: string) {
|
export async function removeGlossaryAlias(id: string) {
|
||||||
await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
try {
|
try {
|
||||||
await deleteGlossaryAlias(z.string().uuid().parse(id));
|
await deleteGlossaryAlias(
|
||||||
|
z.string().uuid().parse(id),
|
||||||
|
auditActor(admin.user),
|
||||||
|
);
|
||||||
updateTag(GLOSSARY_CACHE_TAG);
|
updateTag(GLOSSARY_CACHE_TAG);
|
||||||
return { status: "saved" as const };
|
return { status: "saved" as const };
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { connection } from "next/server";
|
||||||
|
import { ClipboardListIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { AdminHeader } from "@/components/admin/admin-header";
|
||||||
|
import { AuditLogFilters } from "@/components/admin/audit-log-filters";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Empty,
|
||||||
|
EmptyDescription,
|
||||||
|
EmptyHeader,
|
||||||
|
EmptyMedia,
|
||||||
|
EmptyTitle,
|
||||||
|
} from "@/components/ui/empty";
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from "@/components/ui/pagination";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
AUDIT_ACTION_LABELS,
|
||||||
|
AUDIT_TARGET_LABELS,
|
||||||
|
AUDIT_TARGET_TYPES,
|
||||||
|
isAuditTargetType,
|
||||||
|
listAuditActors,
|
||||||
|
listAuditLogs,
|
||||||
|
type AuditAction,
|
||||||
|
} from "@/lib/audit-log";
|
||||||
|
import { getAdminSession } from "@/lib/auth/server";
|
||||||
|
|
||||||
|
function pageHref(
|
||||||
|
page: number,
|
||||||
|
actorId?: string,
|
||||||
|
targetType?: string,
|
||||||
|
) {
|
||||||
|
const query = new URLSearchParams({ page: String(page) });
|
||||||
|
if (actorId) query.set("actor", actorId);
|
||||||
|
if (targetType) query.set("type", targetType);
|
||||||
|
return `/admin/activity?${query}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventSummary(metadata: Record<string, string | number | boolean | null>) {
|
||||||
|
const label = metadata.sectionTitle
|
||||||
|
?? metadata.guideName
|
||||||
|
?? metadata.alias
|
||||||
|
?? metadata.fileName
|
||||||
|
?? metadata.accountLabel;
|
||||||
|
if (typeof label === "string") return label;
|
||||||
|
if (typeof metadata.error === "string") return metadata.error;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AuditLogPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
}) {
|
||||||
|
await connection();
|
||||||
|
if (!(await getAdminSession())) redirect("/admin/login");
|
||||||
|
const query = await searchParams;
|
||||||
|
const rawPage = Array.isArray(query.page) ? query.page[0] : query.page;
|
||||||
|
const parsedPage = Number(rawPage);
|
||||||
|
const page = Number.isSafeInteger(parsedPage) && parsedPage > 0
|
||||||
|
? parsedPage
|
||||||
|
: 1;
|
||||||
|
const rawActorId = Array.isArray(query.actor) ? query.actor[0] : query.actor;
|
||||||
|
const actorId = rawActorId && rawActorId.length <= 128
|
||||||
|
? rawActorId
|
||||||
|
: undefined;
|
||||||
|
const rawTargetType = Array.isArray(query.type) ? query.type[0] : query.type;
|
||||||
|
const targetType = isAuditTargetType(rawTargetType)
|
||||||
|
? rawTargetType
|
||||||
|
: undefined;
|
||||||
|
const [{ events, pageCount, total }, actors] = await Promise.all([
|
||||||
|
listAuditLogs({ page, pageSize: 50, actorId, targetType }),
|
||||||
|
listAuditActors(),
|
||||||
|
]);
|
||||||
|
const dateTime = new Intl.DateTimeFormat("th-TH", {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "medium",
|
||||||
|
timeZone: "Asia/Bangkok",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-svh">
|
||||||
|
<AdminHeader />
|
||||||
|
<main className="mx-auto flex w-full max-w-7xl flex-col gap-6 p-4 sm:p-6 lg:p-8">
|
||||||
|
<header>
|
||||||
|
<p className="text-sm font-medium text-primary">Admin audit trail</p>
|
||||||
|
<h1 className="font-heading text-3xl font-semibold">Activity</h1>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
ประวัติการเปลี่ยนแปลงที่บันทึกสำเร็จทั้งหมด {total.toLocaleString("th-TH")} รายการ
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<AuditLogFilters
|
||||||
|
key={`${actorId ?? "all"}:${targetType ?? "all"}`}
|
||||||
|
actors={actors}
|
||||||
|
targetTypes={AUDIT_TARGET_TYPES.map((value) => ({
|
||||||
|
value,
|
||||||
|
label: AUDIT_TARGET_LABELS[value],
|
||||||
|
}))}
|
||||||
|
initialActorId={actorId}
|
||||||
|
initialTargetType={targetType}
|
||||||
|
/>
|
||||||
|
{events.length ? (
|
||||||
|
<>
|
||||||
|
<div className="rounded-xl border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>เวลา</TableHead>
|
||||||
|
<TableHead>ผู้ดูแล</TableHead>
|
||||||
|
<TableHead>การทำงาน</TableHead>
|
||||||
|
<TableHead>เป้าหมาย</TableHead>
|
||||||
|
<TableHead>รายละเอียด</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{events.map((event) => {
|
||||||
|
const action = event.action as AuditAction;
|
||||||
|
const summary = eventSummary(event.metadata);
|
||||||
|
return (
|
||||||
|
<TableRow key={event.id}>
|
||||||
|
<TableCell>{dateTime.format(event.createdAt)}</TableCell>
|
||||||
|
<TableCell>{event.actorLabel}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{AUDIT_ACTION_LABELS[action] ?? event.action}</span>
|
||||||
|
{event.metadata.overwrite === true ? (
|
||||||
|
<Badge variant="outline">Overwrite</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{isAuditTargetType(event.targetType)
|
||||||
|
? AUDIT_TARGET_LABELS[event.targetType]
|
||||||
|
: event.targetType}
|
||||||
|
</Badge>
|
||||||
|
<span className="max-w-48 truncate font-mono text-xs text-muted-foreground">
|
||||||
|
{event.targetId}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex max-w-80 flex-col gap-1">
|
||||||
|
{summary ? <span className="truncate">{summary}</span> : null}
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{event.scope ? `${event.scope} · ` : ""}
|
||||||
|
{event.resultingVersion
|
||||||
|
? `v${event.resultingVersion}`
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
{pageCount > 1 ? (
|
||||||
|
<Pagination>
|
||||||
|
<PaginationContent>
|
||||||
|
{page > 1 ? (
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
href={pageHref(page - 1, actorId, targetType)}
|
||||||
|
text="ก่อนหน้า"
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
) : null}
|
||||||
|
{page < pageCount ? (
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
href={pageHref(page + 1, actorId, targetType)}
|
||||||
|
text="ถัดไป"
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
) : null}
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Empty className="border">
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyMedia variant="icon">
|
||||||
|
<ClipboardListIcon />
|
||||||
|
</EmptyMedia>
|
||||||
|
<EmptyTitle>ยังไม่มีกิจกรรม</EmptyTitle>
|
||||||
|
<EmptyDescription>
|
||||||
|
ลองเปลี่ยนตัวกรอง หรือกลับมาหลังจากมีการแก้ไขข้อมูล
|
||||||
|
</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
</Empty>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,10 @@ import { z } from "zod";
|
|||||||
import { getDb } from "@/db";
|
import { getDb } from "@/db";
|
||||||
import { users } from "@/db/schema";
|
import { users } from "@/db/schema";
|
||||||
import { getAuth, requireAdmin } from "@/lib/auth/server";
|
import { getAuth, requireAdmin } from "@/lib/auth/server";
|
||||||
import { securityLog } from "@/lib/security/http";
|
import {
|
||||||
|
auditActor,
|
||||||
|
writeAuditLogBestEffort,
|
||||||
|
} from "@/lib/audit-log";
|
||||||
|
|
||||||
export interface CreateAccountState {
|
export interface CreateAccountState {
|
||||||
status: "idle" | "error" | "success";
|
status: "idle" | "error" | "success";
|
||||||
@@ -45,7 +48,7 @@ export async function createAccount(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await getAuth().api.createUser({
|
const created = await getAuth().api.createUser({
|
||||||
body: {
|
body: {
|
||||||
name: parsed.data.name,
|
name: parsed.data.name,
|
||||||
email: parsed.data.email,
|
email: parsed.data.email,
|
||||||
@@ -55,12 +58,19 @@ export async function createAccount(
|
|||||||
},
|
},
|
||||||
headers: await headers(),
|
headers: await headers(),
|
||||||
});
|
});
|
||||||
|
await writeAuditLogBestEffort(auditActor(admin.user), {
|
||||||
|
action: "admin_account.created",
|
||||||
|
targetType: "admin_account",
|
||||||
|
targetId: created.user.id,
|
||||||
|
metadata: {
|
||||||
|
accountLabel: created.user.name?.trim() || created.user.email,
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return { status: "error", message: "สร้างบัญชีไม่สำเร็จ โปรดลองอีกครั้ง" };
|
return { status: "error", message: "สร้างบัญชีไม่สำเร็จ โปรดลองอีกครั้ง" };
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath("/admin/register");
|
revalidatePath("/admin/register");
|
||||||
securityLog("account-created", { actorId: admin.user.id });
|
|
||||||
return { status: "success", message: `สร้างบัญชี ${parsed.data.email} แล้ว` };
|
return { status: "success", message: `สร้างบัญชี ${parsed.data.email} แล้ว` };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,16 +85,30 @@ export async function removeAccount(
|
|||||||
return { status: "error", message: "ไม่สามารถลบบัญชีที่กำลังใช้งานอยู่" };
|
return { status: "error", message: "ไม่สามารถลบบัญชีที่กำลังใช้งานอยู่" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [target] = await getDb()
|
||||||
|
.select({ name: users.name, email: users.email })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
if (!target) return { status: "error", message: "ไม่พบบัญชีนี้" };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await getAuth().api.removeUser({
|
await getAuth().api.removeUser({
|
||||||
body: { userId },
|
body: { userId },
|
||||||
headers: await headers(),
|
headers: await headers(),
|
||||||
});
|
});
|
||||||
|
await writeAuditLogBestEffort(auditActor(session.user), {
|
||||||
|
action: "admin_account.removed",
|
||||||
|
targetType: "admin_account",
|
||||||
|
targetId: userId,
|
||||||
|
metadata: {
|
||||||
|
accountLabel: target.name.trim() || target.email,
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return { status: "error", message: "ลบบัญชีไม่สำเร็จ โปรดลองอีกครั้ง" };
|
return { status: "error", message: "ลบบัญชีไม่สำเร็จ โปรดลองอีกครั้ง" };
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath("/admin/register");
|
revalidatePath("/admin/register");
|
||||||
securityLog("account-removed", { actorId: session.user.id, targetId: userId });
|
|
||||||
return { status: "success" };
|
return { status: "success" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as z from "zod";
|
|||||||
|
|
||||||
import { requireAdmin } from "@/lib/auth/server";
|
import { requireAdmin } from "@/lib/auth/server";
|
||||||
import { completePendingMedia } from "@/lib/media/repository";
|
import { completePendingMedia } from "@/lib/media/repository";
|
||||||
|
import { auditActor } from "@/lib/audit-log";
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -17,7 +18,9 @@ export async function POST(
|
|||||||
if (!z.string().uuid().safeParse(id).success) {
|
if (!z.string().uuid().safeParse(id).success) {
|
||||||
return Response.json({ error: "invalid-id" }, { status: 400 });
|
return Response.json({ error: "invalid-id" }, { status: 400 });
|
||||||
}
|
}
|
||||||
const result = await withUploadSlot(() => completePendingMedia(id));
|
const result = await withUploadSlot(() =>
|
||||||
|
completePendingMedia(id, auditActor(admin.user)),
|
||||||
|
);
|
||||||
if (result.status === "ready") {
|
if (result.status === "ready") {
|
||||||
return Response.json({
|
return Response.json({
|
||||||
media: {
|
media: {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as z from "zod";
|
|||||||
|
|
||||||
import { requireAdmin } from "@/lib/auth/server";
|
import { requireAdmin } from "@/lib/auth/server";
|
||||||
import { discardUnreferencedMedia } from "@/lib/media/repository";
|
import { discardUnreferencedMedia } from "@/lib/media/repository";
|
||||||
|
import { auditActor } from "@/lib/audit-log";
|
||||||
|
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -17,7 +18,7 @@ export async function DELETE(
|
|||||||
if (!z.string().uuid().safeParse(id).success) {
|
if (!z.string().uuid().safeParse(id).success) {
|
||||||
return Response.json({ error: "invalid-id" }, { status: 400 });
|
return Response.json({ error: "invalid-id" }, { status: 400 });
|
||||||
}
|
}
|
||||||
const deleted = await discardUnreferencedMedia(id);
|
const deleted = await discardUnreferencedMedia(id, auditActor(admin.user));
|
||||||
return deleted
|
return deleted
|
||||||
? new Response(null, { status: 204 })
|
? new Response(null, { status: 204 })
|
||||||
: Response.json({ error: "media-is-referenced-or-missing" }, { status: 409 });
|
: Response.json({ error: "media-is-referenced-or-missing" }, { status: 409 });
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { requireAdmin } from "@/lib/auth/server";
|
import { requireAdmin } from "@/lib/auth/server";
|
||||||
import { uploadPublicMedia } from "@/lib/media/repository";
|
import { uploadPublicMedia } from "@/lib/media/repository";
|
||||||
import { MAX_MEDIA_BYTES, mediaUploadSchema } from "@/lib/media/validation";
|
import { MAX_MEDIA_BYTES, mediaUploadSchema } from "@/lib/media/validation";
|
||||||
import { boundedBody, errorResponse, HttpError, requireSameOrigin, securityLog, withUploadSlot } from "@/lib/security/http";
|
import { boundedBody, errorResponse, HttpError, requireSameOrigin, withUploadSlot } from "@/lib/security/http";
|
||||||
import { limitRequest } from "@/lib/security/rate-limit";
|
import { limitRequest } from "@/lib/security/rate-limit";
|
||||||
|
import { auditActor } from "@/lib/audit-log";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
@@ -26,8 +27,7 @@ export async function POST(request: Request) {
|
|||||||
if (!mediaUploadSchema.safeParse({ fileName: file.name, mimeType: file.type, byteSize: file.size }).success) {
|
if (!mediaUploadSchema.safeParse({ fileName: file.name, mimeType: file.type, byteSize: file.size }).success) {
|
||||||
throw new HttpError(415, "invalid-image");
|
throw new HttpError(415, "invalid-image");
|
||||||
}
|
}
|
||||||
const result = await uploadPublicMedia(file, admin.user.id);
|
const result = await uploadPublicMedia(file, auditActor(admin.user));
|
||||||
securityLog("media-uploaded", { actorId: admin.user.id, targetId: result.id });
|
|
||||||
return Response.json(result, { status: 201, headers: { "Cache-Control": "no-store" } });
|
return Response.json(result, { status: 201, headers: { "Cache-Control": "no-store" } });
|
||||||
});
|
});
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ImageIcon,
|
ImageIcon,
|
||||||
LayoutDashboardIcon,
|
LayoutDashboardIcon,
|
||||||
BookOpenIcon,
|
BookOpenIcon,
|
||||||
|
ClipboardListIcon,
|
||||||
LogOutIcon,
|
LogOutIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
UserPlusIcon,
|
UserPlusIcon,
|
||||||
@@ -31,6 +32,15 @@ export function AdminHeader() {
|
|||||||
<span className="hidden sm:inline">ไกด์เกนชินไม่ใช่เกมมือถือ</span>
|
<span className="hidden sm:inline">ไกด์เกนชินไม่ใช่เกมมือถือ</span>
|
||||||
</Link>
|
</Link>
|
||||||
<nav aria-label="Admin navigation" className="flex items-center gap-1">
|
<nav aria-label="Admin navigation" className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant={pathname === "/admin/activity" ? "secondary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
nativeButton={false}
|
||||||
|
render={<Link href="/admin/activity" />}
|
||||||
|
>
|
||||||
|
<ClipboardListIcon data-icon="inline-start" />
|
||||||
|
<span className="hidden sm:inline">Activity</span>
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={pathname === "/admin/glossary" ? "secondary" : "ghost"}
|
variant={pathname === "/admin/glossary" ? "secondary" : "ghost"}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { FilterIcon, RotateCcwIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
const ALL = "all";
|
||||||
|
|
||||||
|
export function AuditLogFilters({
|
||||||
|
actors,
|
||||||
|
targetTypes,
|
||||||
|
initialActorId,
|
||||||
|
initialTargetType,
|
||||||
|
}: {
|
||||||
|
actors: Array<{ id: string; label: string }>;
|
||||||
|
targetTypes: Array<{ value: string; label: string }>;
|
||||||
|
initialActorId?: string;
|
||||||
|
initialTargetType?: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [actorId, setActorId] = useState(initialActorId ?? ALL);
|
||||||
|
const [targetType, setTargetType] = useState(initialTargetType ?? ALL);
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (actorId !== ALL) query.set("actor", actorId);
|
||||||
|
if (targetType !== ALL) query.set("type", targetType);
|
||||||
|
const suffix = query.toString();
|
||||||
|
router.push(suffix ? `/admin/activity?${suffix}` : "/admin/activity");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FieldGroup className="rounded-xl border p-4 md:flex-row md:items-end">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="audit-actor">ผู้ดูแล</FieldLabel>
|
||||||
|
<Select value={actorId} onValueChange={(value) => value && setActorId(value)}>
|
||||||
|
<SelectTrigger id="audit-actor" className="w-full md:w-64">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value={ALL}>ทั้งหมด</SelectItem>
|
||||||
|
{actors.map((actor) => (
|
||||||
|
<SelectItem key={actor.id} value={actor.id}>
|
||||||
|
{actor.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="audit-target-type">ประเภทเป้าหมาย</FieldLabel>
|
||||||
|
<Select value={targetType} onValueChange={(value) => value && setTargetType(value)}>
|
||||||
|
<SelectTrigger id="audit-target-type" className="w-full md:w-56">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value={ALL}>ทั้งหมด</SelectItem>
|
||||||
|
{targetTypes.map((type) => (
|
||||||
|
<SelectItem key={type.value} value={type.value}>
|
||||||
|
{type.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="button" onClick={applyFilters}>
|
||||||
|
<FilterIcon data-icon="inline-start" />
|
||||||
|
กรอง
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setActorId(ALL);
|
||||||
|
setTargetType(ALL);
|
||||||
|
router.push("/admin/activity");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCcwIcon data-icon="inline-start" />
|
||||||
|
ล้าง
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FieldGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -177,6 +177,45 @@ export const outboxEvents = pgTable(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const auditLogs = pgTable(
|
||||||
|
"audit_log",
|
||||||
|
{
|
||||||
|
id: bigint("id", { mode: "number" })
|
||||||
|
.primaryKey()
|
||||||
|
.generatedAlwaysAsIdentity(),
|
||||||
|
actorId: text("actor_id").notNull(),
|
||||||
|
actorLabel: text("actor_label").notNull(),
|
||||||
|
action: varchar("action", { length: 64 }).notNull(),
|
||||||
|
targetType: varchar("target_type", { length: 32 }).notNull(),
|
||||||
|
targetId: text("target_id").notNull(),
|
||||||
|
scope: varchar("scope", { length: 64 }),
|
||||||
|
resultingVersion: integer("resulting_version"),
|
||||||
|
metadata: jsonb("metadata")
|
||||||
|
.$type<Record<string, string | number | boolean | null>>()
|
||||||
|
.default({})
|
||||||
|
.notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.defaultNow()
|
||||||
|
.notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("audit_log_created_idx").on(table.createdAt, table.id),
|
||||||
|
index("audit_log_actor_created_idx").on(
|
||||||
|
table.actorId,
|
||||||
|
table.createdAt,
|
||||||
|
),
|
||||||
|
index("audit_log_target_created_idx").on(
|
||||||
|
table.targetType,
|
||||||
|
table.targetId,
|
||||||
|
table.createdAt,
|
||||||
|
),
|
||||||
|
index("audit_log_action_created_idx").on(
|
||||||
|
table.action,
|
||||||
|
table.createdAt,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
export const catalogCharacters = catalogSchema.table(
|
export const catalogCharacters = catalogSchema.table(
|
||||||
"character",
|
"character",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
CREATE TABLE "audit_log" (
|
||||||
|
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "audit_log_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
||||||
|
"actor_id" text NOT NULL,
|
||||||
|
"actor_label" text NOT NULL,
|
||||||
|
"action" varchar(64) NOT NULL,
|
||||||
|
"target_type" varchar(32) NOT NULL,
|
||||||
|
"target_id" text NOT NULL,
|
||||||
|
"scope" varchar(64),
|
||||||
|
"resulting_version" integer,
|
||||||
|
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "audit_log_created_idx" ON "audit_log" USING btree ("created_at","id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "audit_log_actor_created_idx" ON "audit_log" USING btree ("actor_id","created_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "audit_log_target_created_idx" ON "audit_log" USING btree ("target_type","target_id","created_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "audit_log_action_created_idx" ON "audit_log" USING btree ("action","created_at");
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -372,6 +372,13 @@
|
|||||||
"when": 1790498384479,
|
"when": 1790498384479,
|
||||||
"tag": "0052_round_blazing_skull",
|
"tag": "0052_round_blazing_skull",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 53,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1790499026606,
|
||||||
|
"tag": "0053_woozy_callisto",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("server-only", () => ({}));
|
||||||
|
|
||||||
|
import { auditLogs } from "@/db/schema";
|
||||||
|
import {
|
||||||
|
AUDIT_ACTION_LABELS,
|
||||||
|
AUDIT_ACTIONS,
|
||||||
|
AUDIT_TARGET_LABELS,
|
||||||
|
AUDIT_TARGET_TYPES,
|
||||||
|
auditActor,
|
||||||
|
isAuditTargetType,
|
||||||
|
writeAuditLog,
|
||||||
|
} from "@/lib/audit-log";
|
||||||
|
|
||||||
|
describe("audit log", () => {
|
||||||
|
it("builds a stable actor snapshot", () => {
|
||||||
|
expect(auditActor({ id: "admin-1", name: " Editor ", email: "[email protected]" }))
|
||||||
|
.toEqual({ id: "admin-1", label: "Editor" });
|
||||||
|
expect(auditActor({ id: "admin-2", name: "", email: "[email protected]" }))
|
||||||
|
.toEqual({ id: "admin-2", label: "[email protected]" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps action and target labels complete", () => {
|
||||||
|
expect(Object.keys(AUDIT_ACTION_LABELS)).toHaveLength(AUDIT_ACTIONS.length);
|
||||||
|
expect(Object.keys(AUDIT_TARGET_LABELS)).toHaveLength(AUDIT_TARGET_TYPES.length);
|
||||||
|
expect(isAuditTargetType("guide")).toBe(true);
|
||||||
|
expect(isAuditTargetType("unknown")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes only the explicit compact event envelope", async () => {
|
||||||
|
const values = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const insert = vi.fn(() => ({ values }));
|
||||||
|
|
||||||
|
await writeAuditLog(
|
||||||
|
{ insert } as never,
|
||||||
|
{ id: "admin-1", label: "Editor" },
|
||||||
|
{
|
||||||
|
action: "guide.weapon.saved",
|
||||||
|
targetType: "guide",
|
||||||
|
targetId: "guide-1",
|
||||||
|
scope: "weapon",
|
||||||
|
resultingVersion: 4,
|
||||||
|
metadata: { guideName: "Amber", overwrite: true },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(insert).toHaveBeenCalledWith(auditLogs);
|
||||||
|
expect(values).toHaveBeenCalledWith({
|
||||||
|
actorId: "admin-1",
|
||||||
|
actorLabel: "Editor",
|
||||||
|
action: "guide.weapon.saved",
|
||||||
|
targetType: "guide",
|
||||||
|
targetId: "guide-1",
|
||||||
|
scope: "weapon",
|
||||||
|
resultingVersion: 4,
|
||||||
|
metadata: { guideName: "Amber", overwrite: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { and, count, desc, eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
import { getDb, type Database } from "@/db";
|
||||||
|
import { auditLogs } from "@/db/schema";
|
||||||
|
import { securityLog } from "@/lib/security/http";
|
||||||
|
|
||||||
|
export const AUDIT_ACTIONS = [
|
||||||
|
"guide.created",
|
||||||
|
"guide.overview.saved",
|
||||||
|
"guide.weapon.saved",
|
||||||
|
"guide.artifact.saved",
|
||||||
|
"guide.constellations.saved",
|
||||||
|
"guide.team.saved",
|
||||||
|
"guide.extra.created",
|
||||||
|
"guide.extra.saved",
|
||||||
|
"guide.extra.deleted",
|
||||||
|
"guide.extra.reordered",
|
||||||
|
"guide.trashed",
|
||||||
|
"guide.restored",
|
||||||
|
"guide.published",
|
||||||
|
"guide.unpublished",
|
||||||
|
"guide.permanently_deleted",
|
||||||
|
"glossary_alias.created",
|
||||||
|
"glossary_alias.deleted",
|
||||||
|
"media.uploaded",
|
||||||
|
"media.deleted",
|
||||||
|
"admin_account.created",
|
||||||
|
"admin_account.removed",
|
||||||
|
"catalog_sync.requested",
|
||||||
|
"catalog_sync.cancel_requested",
|
||||||
|
"catalog_sync.completed",
|
||||||
|
"catalog_sync.unchanged",
|
||||||
|
"catalog_sync.cancelled",
|
||||||
|
"catalog_sync.failed",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const AUDIT_TARGET_TYPES = [
|
||||||
|
"guide",
|
||||||
|
"extra_section",
|
||||||
|
"glossary_alias",
|
||||||
|
"media",
|
||||||
|
"admin_account",
|
||||||
|
"catalog_sync",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
|
||||||
|
export type AuditTargetType = (typeof AUDIT_TARGET_TYPES)[number];
|
||||||
|
export type AuditMetadata = Record<
|
||||||
|
string,
|
||||||
|
string | number | boolean | null
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface AuditActor {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogInput {
|
||||||
|
action: AuditAction;
|
||||||
|
targetType: AuditTargetType;
|
||||||
|
targetId: string;
|
||||||
|
scope?: string;
|
||||||
|
resultingVersion?: number;
|
||||||
|
metadata?: AuditMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuditWriter = Pick<Database, "insert">;
|
||||||
|
|
||||||
|
export function auditActor(user: {
|
||||||
|
id: string;
|
||||||
|
name?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
}): AuditActor {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
label: (user.name?.trim() || user.email?.trim() || user.id).slice(0, 160),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeAuditLog(
|
||||||
|
writer: AuditWriter,
|
||||||
|
actor: AuditActor,
|
||||||
|
event: AuditLogInput,
|
||||||
|
): Promise<void> {
|
||||||
|
await writer.insert(auditLogs).values({
|
||||||
|
actorId: actor.id,
|
||||||
|
actorLabel: actor.label,
|
||||||
|
action: event.action,
|
||||||
|
targetType: event.targetType,
|
||||||
|
targetId: event.targetId,
|
||||||
|
scope: event.scope,
|
||||||
|
resultingVersion: event.resultingVersion,
|
||||||
|
metadata: event.metadata ?? {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeAuditLogBestEffort(
|
||||||
|
actor: AuditActor,
|
||||||
|
event: AuditLogInput,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await writeAuditLog(getDb(), actor, event);
|
||||||
|
} catch {
|
||||||
|
securityLog("audit-write-failed", {
|
||||||
|
actorId: actor.id,
|
||||||
|
targetId: event.targetId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogFilters {
|
||||||
|
actorId?: string;
|
||||||
|
targetType?: AuditTargetType;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAuditLogs(filters: AuditLogFilters = {}) {
|
||||||
|
const page = Math.max(1, Math.floor(filters.page ?? 1));
|
||||||
|
const pageSize = Math.max(1, Math.min(100, Math.floor(filters.pageSize ?? 50)));
|
||||||
|
const conditions = [
|
||||||
|
filters.actorId ? eq(auditLogs.actorId, filters.actorId) : undefined,
|
||||||
|
filters.targetType
|
||||||
|
? eq(auditLogs.targetType, filters.targetType)
|
||||||
|
: undefined,
|
||||||
|
].filter((condition) => condition !== undefined);
|
||||||
|
const where = conditions.length ? and(...conditions) : undefined;
|
||||||
|
const db = getDb();
|
||||||
|
const [events, [total]] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(auditLogs)
|
||||||
|
.where(where)
|
||||||
|
.orderBy(desc(auditLogs.createdAt), desc(auditLogs.id))
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset((page - 1) * pageSize),
|
||||||
|
db
|
||||||
|
.select({ value: count() })
|
||||||
|
.from(auditLogs)
|
||||||
|
.where(where),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
events,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total: total.value,
|
||||||
|
pageCount: Math.max(1, Math.ceil(total.value / pageSize)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAuditActors() {
|
||||||
|
const rows = await getDb()
|
||||||
|
.selectDistinctOn([auditLogs.actorId], {
|
||||||
|
id: auditLogs.actorId,
|
||||||
|
label: auditLogs.actorLabel,
|
||||||
|
})
|
||||||
|
.from(auditLogs)
|
||||||
|
.orderBy(auditLogs.actorId, desc(auditLogs.id));
|
||||||
|
return rows.sort((left, right) =>
|
||||||
|
left.label.localeCompare(right.label, "th"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AUDIT_TARGET_LABELS: Record<
|
||||||
|
AuditTargetType,
|
||||||
|
string
|
||||||
|
> = {
|
||||||
|
guide: "Guide",
|
||||||
|
extra_section: "Extra section",
|
||||||
|
glossary_alias: "Glossary",
|
||||||
|
media: "Media",
|
||||||
|
admin_account: "Admin account",
|
||||||
|
catalog_sync: "Catalog sync",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AUDIT_ACTION_LABELS: Record<AuditAction, string> = {
|
||||||
|
"guide.created": "สร้าง Guide",
|
||||||
|
"guide.overview.saved": "บันทึก Overview",
|
||||||
|
"guide.weapon.saved": "บันทึก Weapons",
|
||||||
|
"guide.artifact.saved": "บันทึก Artifacts",
|
||||||
|
"guide.constellations.saved": "บันทึก Constellations",
|
||||||
|
"guide.team.saved": "บันทึก Team",
|
||||||
|
"guide.extra.created": "สร้าง Extra section",
|
||||||
|
"guide.extra.saved": "บันทึก Extra section",
|
||||||
|
"guide.extra.deleted": "ลบ Extra section",
|
||||||
|
"guide.extra.reordered": "เรียง Extra sections",
|
||||||
|
"guide.trashed": "ย้าย Guide ไปถังขยะ",
|
||||||
|
"guide.restored": "กู้คืน Guide",
|
||||||
|
"guide.published": "เผยแพร่ Guide",
|
||||||
|
"guide.unpublished": "ยกเลิกเผยแพร่ Guide",
|
||||||
|
"guide.permanently_deleted": "ลบ Guide ถาวร",
|
||||||
|
"glossary_alias.created": "เพิ่ม Glossary shortcut",
|
||||||
|
"glossary_alias.deleted": "ลบ Glossary shortcut",
|
||||||
|
"media.uploaded": "อัปโหลด Media",
|
||||||
|
"media.deleted": "ลบ Media",
|
||||||
|
"admin_account.created": "สร้างบัญชี Admin",
|
||||||
|
"admin_account.removed": "ลบบัญชี Admin",
|
||||||
|
"catalog_sync.requested": "เริ่ม Catalog sync",
|
||||||
|
"catalog_sync.cancel_requested": "ขอยกเลิก Catalog sync",
|
||||||
|
"catalog_sync.completed": "Catalog sync สำเร็จ",
|
||||||
|
"catalog_sync.unchanged": "Catalog เป็นเวอร์ชันล่าสุด",
|
||||||
|
"catalog_sync.cancelled": "ยกเลิก Catalog sync แล้ว",
|
||||||
|
"catalog_sync.failed": "Catalog sync ล้มเหลว",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isAuditTargetType(
|
||||||
|
value: string | undefined,
|
||||||
|
): value is AuditTargetType {
|
||||||
|
return AUDIT_TARGET_TYPES.some((type) => type === value);
|
||||||
|
}
|
||||||
+42
-5
@@ -38,6 +38,11 @@ import {
|
|||||||
lunarisVersionSchema,
|
lunarisVersionSchema,
|
||||||
} from "@/lib/catalog/version";
|
} from "@/lib/catalog/version";
|
||||||
import { getMediaStorage } from "@/lib/media/storage";
|
import { getMediaStorage } from "@/lib/media/storage";
|
||||||
|
import {
|
||||||
|
writeAuditLog,
|
||||||
|
writeAuditLogBestEffort,
|
||||||
|
type AuditActor,
|
||||||
|
} from "@/lib/audit-log";
|
||||||
|
|
||||||
const VERSION_URL = "https://api.lunaris.moe/data/version.json";
|
const VERSION_URL = "https://api.lunaris.moe/data/version.json";
|
||||||
const API_ROOT = "https://api.lunaris.moe/data";
|
const API_ROOT = "https://api.lunaris.moe/data";
|
||||||
@@ -676,20 +681,33 @@ export async function getCatalogSyncJob(id: string): Promise<CatalogSyncJob | nu
|
|||||||
return value ? JSON.parse(value) as CatalogSyncJob : null;
|
return value ? JSON.parse(value) as CatalogSyncJob : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cancelCatalogSyncJob(id: string): Promise<CatalogSyncJob | null> {
|
export async function cancelCatalogSyncJob(
|
||||||
|
id: string,
|
||||||
|
actor: AuditActor,
|
||||||
|
): Promise<CatalogSyncJob | null> {
|
||||||
const job = await getCatalogSyncJob(id);
|
const job = await getCatalogSyncJob(id);
|
||||||
if (!job || ["completed", "unchanged", "cancelled", "error"].includes(job.status)) return job;
|
if (!job || ["completed", "unchanged", "cancelled", "error"].includes(job.status)) return job;
|
||||||
|
await writeAuditLog(getDb(), actor, {
|
||||||
|
action: "catalog_sync.cancel_requested",
|
||||||
|
targetType: "catalog_sync",
|
||||||
|
targetId: id,
|
||||||
|
});
|
||||||
return writeSyncJob({ ...job, message: "กำลังยกเลิก Sync…", status: "cancelled" });
|
return writeSyncJob({ ...job, message: "กำลังยกเลิก Sync…", status: "cancelled" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startCatalogSyncJob(): Promise<CatalogSyncJob> {
|
export async function startCatalogSyncJob(actor: AuditActor): Promise<CatalogSyncJob> {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
|
await writeAuditLog(getDb(), actor, {
|
||||||
|
action: "catalog_sync.requested",
|
||||||
|
targetType: "catalog_sync",
|
||||||
|
targetId: id,
|
||||||
|
});
|
||||||
const job = await writeSyncJob({ id, status: "queued", phase: "queued", completed: 0, total: 0, message: "กำลังเตรียม Sync" });
|
const job = await writeSyncJob({ id, status: "queued", phase: "queued", completed: 0, total: 0, message: "กำลังเตรียม Sync" });
|
||||||
void executeCatalogSyncJob(job).catch(() => undefined);
|
void executeCatalogSyncJob(job, actor).catch(() => undefined);
|
||||||
return job;
|
return job;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeCatalogSyncJob(initial: CatalogSyncJob) {
|
async function executeCatalogSyncJob(initial: CatalogSyncJob, actor: AuditActor) {
|
||||||
let lastCancellationCheck = 0;
|
let lastCancellationCheck = 0;
|
||||||
let cancellationCheck: Promise<void> | null = null;
|
let cancellationCheck: Promise<void> | null = null;
|
||||||
const checkCancelled = async () => {
|
const checkCancelled = async () => {
|
||||||
@@ -714,11 +732,30 @@ async function executeCatalogSyncJob(initial: CatalogSyncJob) {
|
|||||||
await writeSyncJob({ ...initial, status: "running", phase: "version", message: "กำลังตรวจสอบเวอร์ชัน Lunaris" });
|
await writeSyncJob({ ...initial, status: "running", phase: "version", message: "กำลังตรวจสอบเวอร์ชัน Lunaris" });
|
||||||
const result = await processCatalogSync(undefined, { checkCancelled, report });
|
const result = await processCatalogSync(undefined, { checkCancelled, report });
|
||||||
await writeSyncJob({ ...initial, status: result === "unchanged" ? "unchanged" : "completed", phase: "done", completed: 1, total: 1, message: result === "unchanged" ? "Catalog เป็นเวอร์ชันล่าสุดแล้ว" : "Sync Catalog สำเร็จ" });
|
await writeSyncJob({ ...initial, status: result === "unchanged" ? "unchanged" : "completed", phase: "done", completed: 1, total: 1, message: result === "unchanged" ? "Catalog เป็นเวอร์ชันล่าสุดแล้ว" : "Sync Catalog สำเร็จ" });
|
||||||
|
await writeAuditLogBestEffort(actor, {
|
||||||
|
action: result === "unchanged"
|
||||||
|
? "catalog_sync.unchanged"
|
||||||
|
: "catalog_sync.completed",
|
||||||
|
targetType: "catalog_sync",
|
||||||
|
targetId: initial.id,
|
||||||
|
});
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (cause instanceof Error && cause.message === "CATALOG_SYNC_CANCELLED") {
|
if (cause instanceof Error && cause.message === "CATALOG_SYNC_CANCELLED") {
|
||||||
await writeSyncJob({ ...initial, status: "cancelled", phase: "cancelled", message: "ยกเลิก Sync แล้ว" });
|
await writeSyncJob({ ...initial, status: "cancelled", phase: "cancelled", message: "ยกเลิก Sync แล้ว" });
|
||||||
|
await writeAuditLogBestEffort(actor, {
|
||||||
|
action: "catalog_sync.cancelled",
|
||||||
|
targetType: "catalog_sync",
|
||||||
|
targetId: initial.id,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
await writeSyncJob({ ...initial, status: "error", phase: "error", message: "Sync ไม่สำเร็จ", error: cause instanceof Error ? cause.message : "Unknown sync error" });
|
const error = cause instanceof Error ? cause.message : "Unknown sync error";
|
||||||
|
await writeSyncJob({ ...initial, status: "error", phase: "error", message: "Sync ไม่สำเร็จ", error });
|
||||||
|
await writeAuditLogBestEffort(actor, {
|
||||||
|
action: "catalog_sync.failed",
|
||||||
|
targetType: "catalog_sync",
|
||||||
|
targetId: initial.id,
|
||||||
|
metadata: { error: error.slice(0, 500) },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-20
@@ -13,6 +13,7 @@ import {
|
|||||||
glossaryAliasInputSchema,
|
glossaryAliasInputSchema,
|
||||||
normalizeGlossaryAlias,
|
normalizeGlossaryAlias,
|
||||||
} from "@/lib/glossary";
|
} from "@/lib/glossary";
|
||||||
|
import { writeAuditLog, type AuditActor } from "@/lib/audit-log";
|
||||||
|
|
||||||
const catalogTables = {
|
const catalogTables = {
|
||||||
character: catalogCharacters,
|
character: catalogCharacters,
|
||||||
@@ -27,28 +28,52 @@ export async function listGlossaryAliases() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createGlossaryAlias(input: unknown) {
|
export async function createGlossaryAlias(input: unknown, actor: AuditActor) {
|
||||||
const data = glossaryAliasInputSchema.parse(input);
|
const data = glossaryAliasInputSchema.parse(input);
|
||||||
const table = catalogTables[data.kind];
|
const table = catalogTables[data.kind];
|
||||||
const [item] = await getDb()
|
return getDb().transaction(async (tx) => {
|
||||||
.select({ key: table.key })
|
const [item] = await tx
|
||||||
.from(table)
|
.select({ key: table.key })
|
||||||
.where(eq(table.key, data.catalogKey))
|
.from(table)
|
||||||
.limit(1);
|
.where(eq(table.key, data.catalogKey))
|
||||||
if (!item) throw new Error("ไม่พบรายการใน Catalog");
|
.limit(1);
|
||||||
const [created] = await getDb().insert(glossaryAliases).values({
|
if (!item) throw new Error("ไม่พบรายการใน Catalog");
|
||||||
...data,
|
const [created] = await tx.insert(glossaryAliases).values({
|
||||||
alias: data.alias.trim(),
|
...data,
|
||||||
normalizedAlias: normalizeGlossaryAlias(data.alias),
|
alias: data.alias.trim(),
|
||||||
}).returning();
|
normalizedAlias: normalizeGlossaryAlias(data.alias),
|
||||||
return created;
|
}).returning();
|
||||||
|
await writeAuditLog(tx, actor, {
|
||||||
|
action: "glossary_alias.created",
|
||||||
|
targetType: "glossary_alias",
|
||||||
|
targetId: created.id,
|
||||||
|
metadata: {
|
||||||
|
alias: created.alias,
|
||||||
|
kind: created.kind,
|
||||||
|
catalogKey: created.catalogKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return created;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteGlossaryAlias(id: string) {
|
export async function deleteGlossaryAlias(id: string, actor: AuditActor) {
|
||||||
const [deleted] = await getDb()
|
return getDb().transaction(async (tx) => {
|
||||||
.delete(glossaryAliases)
|
const [deleted] = await tx
|
||||||
.where(eq(glossaryAliases.id, id))
|
.delete(glossaryAliases)
|
||||||
.returning();
|
.where(eq(glossaryAliases.id, id))
|
||||||
if (!deleted) throw new Error("ไม่พบ Shortcut");
|
.returning();
|
||||||
return deleted;
|
if (!deleted) throw new Error("ไม่พบ Shortcut");
|
||||||
|
await writeAuditLog(tx, actor, {
|
||||||
|
action: "glossary_alias.deleted",
|
||||||
|
targetType: "glossary_alias",
|
||||||
|
targetId: deleted.id,
|
||||||
|
metadata: {
|
||||||
|
alias: deleted.alias,
|
||||||
|
kind: deleted.kind,
|
||||||
|
catalogKey: deleted.catalogKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return deleted;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+140
-20
@@ -40,6 +40,11 @@ import {
|
|||||||
} from "@/lib/guides/schemas";
|
} from "@/lib/guides/schemas";
|
||||||
import { normalizeExtraRichText } from "@/lib/rich-text";
|
import { normalizeExtraRichText } from "@/lib/rich-text";
|
||||||
import type { AdminEditorScope } from "@/lib/events/invalidation";
|
import type { AdminEditorScope } from "@/lib/events/invalidation";
|
||||||
|
import {
|
||||||
|
writeAuditLog,
|
||||||
|
type AuditAction,
|
||||||
|
type AuditActor,
|
||||||
|
} from "@/lib/audit-log";
|
||||||
|
|
||||||
const DEFAULT_SECTIONS = [
|
const DEFAULT_SECTIONS = [
|
||||||
{ kind: "weapon", slug: "weapon", title: "Weapons", sortOrder: 0 },
|
{ kind: "weapon", slug: "weapon", title: "Weapons", sortOrder: 0 },
|
||||||
@@ -53,6 +58,40 @@ export interface GuideEventDetails {
|
|||||||
actorName: string;
|
actorName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GuideMutationContext {
|
||||||
|
actor: AuditActor;
|
||||||
|
overwrite?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidationDetails(
|
||||||
|
context: GuideMutationContext,
|
||||||
|
): GuideEventDetails | undefined {
|
||||||
|
return context.overwrite
|
||||||
|
? { operation: "overwrite", actorName: context.actor.label.slice(0, 100) }
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function auditGuide(
|
||||||
|
tx: Parameters<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0],
|
||||||
|
context: GuideMutationContext,
|
||||||
|
action: AuditAction,
|
||||||
|
guide: { id: string; name: string; characterKey: string; version: number },
|
||||||
|
scope?: AdminEditorScope,
|
||||||
|
) {
|
||||||
|
await writeAuditLog(tx, context.actor, {
|
||||||
|
action,
|
||||||
|
targetType: "guide",
|
||||||
|
targetId: guide.id,
|
||||||
|
scope,
|
||||||
|
resultingVersion: guide.version,
|
||||||
|
metadata: {
|
||||||
|
guideName: guide.name,
|
||||||
|
characterKey: guide.characterKey,
|
||||||
|
...(context.overwrite ? { overwrite: true } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export class GuideVersionConflictError extends Error {
|
export class GuideVersionConflictError extends Error {
|
||||||
constructor(readonly currentVersion: number) {
|
constructor(readonly currentVersion: number) {
|
||||||
super("ข้อมูลถูกแก้ไขจากแท็บอื่น กด Save now เพื่อบันทึก Local draft ทับเวอร์ชันล่าสุด");
|
super("ข้อมูลถูกแก้ไขจากแท็บอื่น กด Save now เพื่อบันทึก Local draft ทับเวอร์ชันล่าสุด");
|
||||||
@@ -70,7 +109,7 @@ async function emitGuideEvents(tx: Parameters<Parameters<ReturnType<typeof getDb
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createGuide(input: unknown) {
|
export async function createGuide(input: unknown, context: GuideMutationContext) {
|
||||||
const parsed = createGuideSchema.parse(input);
|
const parsed = createGuideSchema.parse(input);
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const [character] = await db.select().from(catalogCharacters).where(eq(catalogCharacters.key, parsed.characterKey)).limit(1);
|
const [character] = await db.select().from(catalogCharacters).where(eq(catalogCharacters.key, parsed.characterKey)).limit(1);
|
||||||
@@ -103,6 +142,7 @@ export async function createGuide(input: unknown) {
|
|||||||
await tx.insert(artifactProfiles).values({ guideId: guide.id });
|
await tx.insert(artifactProfiles).values({ guideId: guide.id });
|
||||||
await tx.update(media).set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` }).where(eq(media.id, parsed.coverMediaId));
|
await tx.update(media).set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` }).where(eq(media.id, parsed.coverMediaId));
|
||||||
await emitGuideEvents(tx, guide.id, guide.version, true);
|
await emitGuideEvents(tx, guide.id, guide.version, true);
|
||||||
|
await auditGuide(tx, context, "guide.created", guide);
|
||||||
return guide;
|
return guide;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -118,23 +158,24 @@ async function advanceGuideVersion(tx: Parameters<Parameters<ReturnType<typeof g
|
|||||||
return guide;
|
return guide;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveWeapons(guideId: string, input: unknown, details?: GuideEventDetails) {
|
export async function saveWeapons(guideId: string, input: unknown, context: GuideMutationContext) {
|
||||||
const data = weaponSectionSchema.parse(input);
|
const data = weaponSectionSchema.parse(input);
|
||||||
const [guide] = await getDb().select().from(guides).where(eq(guides.id, guideId)).limit(1);
|
const [guide] = await getDb().select().from(guides).where(eq(guides.id, guideId)).limit(1);
|
||||||
if (!guide) throw new Error("ไม่พบ Guide");
|
if (!guide) throw new Error("ไม่พบ Guide");
|
||||||
return getDb().transaction(async (tx) => {
|
return getDb().transaction(async (tx) => {
|
||||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "weapon", details);
|
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "weapon", invalidationDetails(context));
|
||||||
await tx.update(guideSections).set({ note: data.note }).where(and(eq(guideSections.guideId, guideId), eq(guideSections.slug, "weapon")));
|
await tx.update(guideSections).set({ note: data.note }).where(and(eq(guideSections.guideId, guideId), eq(guideSections.slug, "weapon")));
|
||||||
await tx.delete(weaponConditions).where(eq(weaponConditions.guideId, guideId));
|
await tx.delete(weaponConditions).where(eq(weaponConditions.guideId, guideId));
|
||||||
for (const [sortOrder, group] of data.groups.entries()) {
|
for (const [sortOrder, group] of data.groups.entries()) {
|
||||||
const [created] = await tx.insert(weaponConditions).values({ guideId, name: group.name, note: group.note, sortOrder }).returning();
|
const [created] = await tx.insert(weaponConditions).values({ guideId, name: group.name, note: group.note, sortOrder }).returning();
|
||||||
if (group.rows.length) await tx.insert(weaponRecommendations).values(group.rows.map((row, rowOrder) => ({ ...row, refinement: row.refinement, overallPercent: row.overallPercent?.toString() ?? null, personalPercent: row.personalPercent?.toString() ?? null, conditionId: created.id, sortOrder: rowOrder })));
|
if (group.rows.length) await tx.insert(weaponRecommendations).values(group.rows.map((row, rowOrder) => ({ ...row, refinement: row.refinement, overallPercent: row.overallPercent?.toString() ?? null, personalPercent: row.personalPercent?.toString() ?? null, conditionId: created.id, sortOrder: rowOrder })));
|
||||||
}
|
}
|
||||||
|
await auditGuide(tx, context, "guide.weapon.saved", updated, "weapon");
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateGuideBasics(guideId: string, input: unknown, details?: GuideEventDetails) {
|
export async function updateGuideBasics(guideId: string, input: unknown, context: GuideMutationContext) {
|
||||||
const data = updateGuideSchema.parse(input);
|
const data = updateGuideSchema.parse(input);
|
||||||
const [current] = await getDb().select().from(guides).where(eq(guides.id, guideId)).limit(1);
|
const [current] = await getDb().select().from(guides).where(eq(guides.id, guideId)).limit(1);
|
||||||
if (!current) throw new Error("ไม่พบ Guide");
|
if (!current) throw new Error("ไม่พบ Guide");
|
||||||
@@ -159,7 +200,7 @@ export async function updateGuideBasics(guideId: string, input: unknown, details
|
|||||||
: [];
|
: [];
|
||||||
if (bleedingMedia.length !== bleedingMediaIds.length) throw new Error("ไม่พบภาพ Bleeding ที่อัปโหลดแล้ว");
|
if (bleedingMedia.length !== bleedingMediaIds.length) throw new Error("ไม่พบภาพ Bleeding ที่อัปโหลดแล้ว");
|
||||||
}
|
}
|
||||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, true, "overview", details);
|
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, true, "overview", invalidationDetails(context));
|
||||||
await tx.update(guides).set({
|
await tx.update(guides).set({
|
||||||
overview: data.overview,
|
overview: data.overview,
|
||||||
coverMediaId: data.coverMediaId,
|
coverMediaId: data.coverMediaId,
|
||||||
@@ -204,16 +245,17 @@ export async function updateGuideBasics(guideId: string, input: unknown, details
|
|||||||
await tx.update(media).set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` }).where(eq(media.id, posterArtMediaId));
|
await tx.update(media).set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` }).where(eq(media.id, posterArtMediaId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await auditGuide(tx, context, "guide.overview.saved", updated, "overview");
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveArtifacts(guideId: string, input: unknown, details?: GuideEventDetails) {
|
export async function saveArtifacts(guideId: string, input: unknown, context: GuideMutationContext) {
|
||||||
const data = artifactSectionSchema.parse(input);
|
const data = artifactSectionSchema.parse(input);
|
||||||
const [guide] = await getDb().select().from(guides).where(eq(guides.id, guideId)).limit(1);
|
const [guide] = await getDb().select().from(guides).where(eq(guides.id, guideId)).limit(1);
|
||||||
if (!guide) throw new Error("ไม่พบ Guide");
|
if (!guide) throw new Error("ไม่พบ Guide");
|
||||||
return getDb().transaction(async (tx) => {
|
return getDb().transaction(async (tx) => {
|
||||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "artifact", details);
|
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "artifact", invalidationDetails(context));
|
||||||
await tx.delete(artifactConditions).where(eq(artifactConditions.guideId, guideId));
|
await tx.delete(artifactConditions).where(eq(artifactConditions.guideId, guideId));
|
||||||
for (const [conditionOrder, condition] of data.groups.entries()) {
|
for (const [conditionOrder, condition] of data.groups.entries()) {
|
||||||
const [createdCondition] = await tx.insert(artifactConditions).values({ guideId, name: condition.name, note: condition.note, sortOrder: conditionOrder }).returning();
|
const [createdCondition] = await tx.insert(artifactConditions).values({ guideId, name: condition.name, note: condition.note, sortOrder: conditionOrder }).returning();
|
||||||
@@ -223,25 +265,27 @@ export async function saveArtifacts(guideId: string, input: unknown, details?: G
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await tx.insert(artifactProfiles).values({ guideId, sands: data.sands, goblet: data.goblet, circlet: data.circlet, substats: data.substats, recommendedConditions: data.recommendedGroups ?? [{ name: null, note: null, sands: data.sands, goblet: data.goblet, circlet: data.circlet, substats: data.substats }], targets: data.targetGroups[0]?.rows ?? [], targetConditions: data.targetGroups, talents: data.talents, note: data.note }).onConflictDoUpdate({ target: artifactProfiles.guideId, set: { sands: data.sands, goblet: data.goblet, circlet: data.circlet, substats: data.substats, recommendedConditions: data.recommendedGroups ?? [{ name: null, note: null, sands: data.sands, goblet: data.goblet, circlet: data.circlet, substats: data.substats }], targets: data.targetGroups[0]?.rows ?? [], targetConditions: data.targetGroups, talents: data.talents, note: data.note } });
|
await tx.insert(artifactProfiles).values({ guideId, sands: data.sands, goblet: data.goblet, circlet: data.circlet, substats: data.substats, recommendedConditions: data.recommendedGroups ?? [{ name: null, note: null, sands: data.sands, goblet: data.goblet, circlet: data.circlet, substats: data.substats }], targets: data.targetGroups[0]?.rows ?? [], targetConditions: data.targetGroups, talents: data.talents, note: data.note }).onConflictDoUpdate({ target: artifactProfiles.guideId, set: { sands: data.sands, goblet: data.goblet, circlet: data.circlet, substats: data.substats, recommendedConditions: data.recommendedGroups ?? [{ name: null, note: null, sands: data.sands, goblet: data.goblet, circlet: data.circlet, substats: data.substats }], targets: data.targetGroups[0]?.rows ?? [], targetConditions: data.targetGroups, talents: data.talents, note: data.note } });
|
||||||
|
await auditGuide(tx, context, "guide.artifact.saved", updated, "artifact");
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveConstellations(guideId: string, input: unknown, details?: GuideEventDetails) {
|
export async function saveConstellations(guideId: string, input: unknown, context: GuideMutationContext) {
|
||||||
const data = constellationSectionSchema.parse(input);
|
const data = constellationSectionSchema.parse(input);
|
||||||
return getDb().transaction(async (tx) => {
|
return getDb().transaction(async (tx) => {
|
||||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "constellations", details);
|
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "constellations", invalidationDetails(context));
|
||||||
await tx.update(guideSections).set({ note: data.note }).where(and(eq(guideSections.guideId, guideId), eq(guideSections.slug, "constellations")));
|
await tx.update(guideSections).set({ note: data.note }).where(and(eq(guideSections.guideId, guideId), eq(guideSections.slug, "constellations")));
|
||||||
await tx.delete(constellationConditions).where(eq(constellationConditions.guideId, guideId));
|
await tx.delete(constellationConditions).where(eq(constellationConditions.guideId, guideId));
|
||||||
for (const [sortOrder, group] of data.groups.entries()) {
|
for (const [sortOrder, group] of data.groups.entries()) {
|
||||||
const [created] = await tx.insert(constellationConditions).values({ guideId, name: group.name, note: group.note, sortOrder }).returning();
|
const [created] = await tx.insert(constellationConditions).values({ guideId, name: group.name, note: group.note, sortOrder }).returning();
|
||||||
if (group.rows.length) await tx.insert(constellationRecommendations).values(group.rows.map((row) => ({ ...row, cumulativePercent: row.cumulativePercent.toString(), conditionId: created.id })));
|
if (group.rows.length) await tx.insert(constellationRecommendations).values(group.rows.map((row) => ({ ...row, cumulativePercent: row.cumulativePercent.toString(), conditionId: created.id })));
|
||||||
}
|
}
|
||||||
|
await auditGuide(tx, context, "guide.constellations.saved", updated, "constellations");
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveTeams(guideId: string, input: unknown, details?: GuideEventDetails) {
|
export async function saveTeams(guideId: string, input: unknown, context: GuideMutationContext) {
|
||||||
const data = teamSectionSchema.parse(input);
|
const data = teamSectionSchema.parse(input);
|
||||||
const [guide] = await getDb().select().from(guides).where(eq(guides.id, guideId)).limit(1);
|
const [guide] = await getDb().select().from(guides).where(eq(guides.id, guideId)).limit(1);
|
||||||
if (!guide) throw new Error("ไม่พบ Guide");
|
if (!guide) throw new Error("ไม่พบ Guide");
|
||||||
@@ -265,7 +309,7 @@ export async function saveTeams(guideId: string, input: unknown, details?: Guide
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return getDb().transaction(async (tx) => {
|
return getDb().transaction(async (tx) => {
|
||||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "team", details);
|
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "team", invalidationDetails(context));
|
||||||
await tx.delete(teamConditions).where(eq(teamConditions.guideId, guideId));
|
await tx.delete(teamConditions).where(eq(teamConditions.guideId, guideId));
|
||||||
for (const [groupOrder, group] of data.groups.entries()) {
|
for (const [groupOrder, group] of data.groups.entries()) {
|
||||||
const [condition] = await tx.insert(teamConditions).values({ guideId, name: group.name, note: group.note, sortOrder: groupOrder }).returning();
|
const [condition] = await tx.insert(teamConditions).values({ guideId, name: group.name, note: group.note, sortOrder: groupOrder }).returning();
|
||||||
@@ -274,11 +318,16 @@ export async function saveTeams(guideId: string, input: unknown, details?: Guide
|
|||||||
await tx.insert(teamMembers).values(team.members.map((member, position) => ({ ...member, teamId: created.id, position, damage: member.damage.toString() })));
|
await tx.insert(teamMembers).values(team.members.map((member, position) => ({ ...member, teamId: created.id, position, damage: member.damage.toString() })));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await auditGuide(tx, context, "guide.team.saved", updated, "team");
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createExtraSection(guideId: string, input: unknown) {
|
export async function createExtraSection(
|
||||||
|
guideId: string,
|
||||||
|
input: unknown,
|
||||||
|
context: GuideMutationContext,
|
||||||
|
) {
|
||||||
const data = createExtraSectionSchema.parse(input);
|
const data = createExtraSectionSchema.parse(input);
|
||||||
return getDb().transaction(async (tx) => {
|
return getDb().transaction(async (tx) => {
|
||||||
const existing = await tx
|
const existing = await tx
|
||||||
@@ -313,6 +362,19 @@ export async function createExtraSection(guideId: string, input: unknown) {
|
|||||||
.returning();
|
.returning();
|
||||||
await tx.insert(extraSections).values({ sectionId: section.id });
|
await tx.insert(extraSections).values({ sectionId: section.id });
|
||||||
await emitGuideEvents(tx, guideId, updated.version, false, "extras:list");
|
await emitGuideEvents(tx, guideId, updated.version, false, "extras:list");
|
||||||
|
await writeAuditLog(tx, context.actor, {
|
||||||
|
action: "guide.extra.created",
|
||||||
|
targetType: "extra_section",
|
||||||
|
targetId: section.id,
|
||||||
|
scope: "extras:list",
|
||||||
|
resultingVersion: updated.version,
|
||||||
|
metadata: {
|
||||||
|
guideId,
|
||||||
|
guideName: updated.name,
|
||||||
|
characterKey: updated.characterKey,
|
||||||
|
sectionTitle: section.title,
|
||||||
|
},
|
||||||
|
});
|
||||||
return { ...section, version: updated.version };
|
return { ...section, version: updated.version };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -321,7 +383,7 @@ export async function saveExtraSection(
|
|||||||
guideId: string,
|
guideId: string,
|
||||||
sectionId: string,
|
sectionId: string,
|
||||||
input: unknown,
|
input: unknown,
|
||||||
details?: GuideEventDetails,
|
context: GuideMutationContext,
|
||||||
) {
|
) {
|
||||||
const data = saveExtraSectionSchema.parse(input);
|
const data = saveExtraSectionSchema.parse(input);
|
||||||
return getDb().transaction(async (tx) => {
|
return getDb().transaction(async (tx) => {
|
||||||
@@ -336,7 +398,7 @@ export async function saveExtraSection(
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
if (!target) throw new Error("ไม่พบ Extra section");
|
if (!target) throw new Error("ไม่พบ Extra section");
|
||||||
|
|
||||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, `extra:${sectionId}`, details);
|
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, `extra:${sectionId}`, invalidationDetails(context));
|
||||||
await tx
|
await tx
|
||||||
.update(guideSections)
|
.update(guideSections)
|
||||||
.set({ title: data.section.title, enabled: true })
|
.set({ title: data.section.title, enabled: true })
|
||||||
@@ -352,14 +414,32 @@ export async function saveExtraSection(
|
|||||||
sortOrder,
|
sortOrder,
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
await writeAuditLog(tx, context.actor, {
|
||||||
|
action: "guide.extra.saved",
|
||||||
|
targetType: "extra_section",
|
||||||
|
targetId: sectionId,
|
||||||
|
scope: `extra:${sectionId}`,
|
||||||
|
resultingVersion: updated.version,
|
||||||
|
metadata: {
|
||||||
|
guideId,
|
||||||
|
guideName: updated.name,
|
||||||
|
characterKey: updated.characterKey,
|
||||||
|
sectionTitle: data.section.title,
|
||||||
|
...(context.overwrite ? { overwrite: true } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteExtraSection(guideId: string, sectionId: string) {
|
export async function deleteExtraSection(
|
||||||
|
guideId: string,
|
||||||
|
sectionId: string,
|
||||||
|
context: GuideMutationContext,
|
||||||
|
) {
|
||||||
return getDb().transaction(async (tx) => {
|
return getDb().transaction(async (tx) => {
|
||||||
const [target] = await tx
|
const [target] = await tx
|
||||||
.select({ id: guideSections.id })
|
.select({ id: guideSections.id, title: guideSections.title })
|
||||||
.from(guideSections)
|
.from(guideSections)
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(guideSections.id, sectionId),
|
eq(guideSections.id, sectionId),
|
||||||
@@ -377,11 +457,28 @@ export async function deleteExtraSection(guideId: string, sectionId: string) {
|
|||||||
if (!updated) throw new Error("ไม่พบ Guide");
|
if (!updated) throw new Error("ไม่พบ Guide");
|
||||||
await tx.delete(guideSections).where(eq(guideSections.id, sectionId));
|
await tx.delete(guideSections).where(eq(guideSections.id, sectionId));
|
||||||
await emitGuideEvents(tx, guideId, updated.version);
|
await emitGuideEvents(tx, guideId, updated.version);
|
||||||
|
await writeAuditLog(tx, context.actor, {
|
||||||
|
action: "guide.extra.deleted",
|
||||||
|
targetType: "extra_section",
|
||||||
|
targetId: sectionId,
|
||||||
|
scope: "extras:list",
|
||||||
|
resultingVersion: updated.version,
|
||||||
|
metadata: {
|
||||||
|
guideId,
|
||||||
|
guideName: updated.name,
|
||||||
|
characterKey: updated.characterKey,
|
||||||
|
sectionTitle: target.title,
|
||||||
|
},
|
||||||
|
});
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reorderExtraSections(guideId: string, input: unknown) {
|
export async function reorderExtraSections(
|
||||||
|
guideId: string,
|
||||||
|
input: unknown,
|
||||||
|
context: GuideMutationContext,
|
||||||
|
) {
|
||||||
const data = reorderExtraSectionsSchema.parse(input);
|
const data = reorderExtraSectionsSchema.parse(input);
|
||||||
return getDb().transaction(async (tx) => {
|
return getDb().transaction(async (tx) => {
|
||||||
const existing = await tx
|
const existing = await tx
|
||||||
@@ -403,24 +500,27 @@ export async function reorderExtraSections(guideId: string, input: unknown) {
|
|||||||
.set({ sortOrder: 4 + index })
|
.set({ sortOrder: 4 + index })
|
||||||
.where(eq(guideSections.id, sectionId));
|
.where(eq(guideSections.id, sectionId));
|
||||||
}
|
}
|
||||||
|
await auditGuide(tx, context, "guide.extra.reordered", updated, "extras:list");
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function trashGuide(guideId: string) {
|
export async function trashGuide(guideId: string, context: GuideMutationContext) {
|
||||||
return getDb().transaction(async (tx) => {
|
return getDb().transaction(async (tx) => {
|
||||||
const [guide] = await tx.update(guides).set({ trashedAt: new Date(), version: sql`${guides.version} + 1` }).where(eq(guides.id, guideId)).returning();
|
const [guide] = await tx.update(guides).set({ trashedAt: new Date(), version: sql`${guides.version} + 1` }).where(eq(guides.id, guideId)).returning();
|
||||||
if (!guide) throw new Error("ไม่พบ Guide");
|
if (!guide) throw new Error("ไม่พบ Guide");
|
||||||
await emitGuideEvents(tx, guide.id, guide.version, true);
|
await emitGuideEvents(tx, guide.id, guide.version, true);
|
||||||
|
await auditGuide(tx, context, "guide.trashed", guide);
|
||||||
return guide;
|
return guide;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function restoreGuide(guideId: string) {
|
export async function restoreGuide(guideId: string, context: GuideMutationContext) {
|
||||||
return getDb().transaction(async (tx) => {
|
return getDb().transaction(async (tx) => {
|
||||||
const [guide] = await tx.update(guides).set({ trashedAt: null, version: sql`${guides.version} + 1` }).where(eq(guides.id, guideId)).returning();
|
const [guide] = await tx.update(guides).set({ trashedAt: null, version: sql`${guides.version} + 1` }).where(eq(guides.id, guideId)).returning();
|
||||||
if (!guide) throw new Error("ไม่พบ Guide");
|
if (!guide) throw new Error("ไม่พบ Guide");
|
||||||
await emitGuideEvents(tx, guide.id, guide.version, true);
|
await emitGuideEvents(tx, guide.id, guide.version, true);
|
||||||
|
await auditGuide(tx, context, "guide.restored", guide);
|
||||||
return guide;
|
return guide;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -431,6 +531,7 @@ export async function setGuideVisibility(
|
|||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
expectedVersion: number;
|
expectedVersion: number;
|
||||||
},
|
},
|
||||||
|
context: GuideMutationContext,
|
||||||
) {
|
) {
|
||||||
return getDb().transaction(async (tx) => {
|
return getDb().transaction(async (tx) => {
|
||||||
const [guide] = await tx
|
const [guide] = await tx
|
||||||
@@ -458,11 +559,20 @@ export async function setGuideVisibility(
|
|||||||
throw new GuideVersionConflictError(current.version);
|
throw new GuideVersionConflictError(current.version);
|
||||||
}
|
}
|
||||||
await emitGuideEvents(tx, guide.id, guide.version, true);
|
await emitGuideEvents(tx, guide.id, guide.version, true);
|
||||||
|
await auditGuide(
|
||||||
|
tx,
|
||||||
|
context,
|
||||||
|
input.isPublic ? "guide.published" : "guide.unpublished",
|
||||||
|
guide,
|
||||||
|
);
|
||||||
return guide;
|
return guide;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function permanentlyDeleteGuide(guideId: string) {
|
export async function permanentlyDeleteGuide(
|
||||||
|
guideId: string,
|
||||||
|
context: GuideMutationContext,
|
||||||
|
) {
|
||||||
return getDb().transaction(async (tx) => {
|
return getDb().transaction(async (tx) => {
|
||||||
const [guide] = await tx.select().from(guides).where(eq(guides.id, guideId)).limit(1);
|
const [guide] = await tx.select().from(guides).where(eq(guides.id, guideId)).limit(1);
|
||||||
if (!guide?.trashedAt) throw new Error("Guide ต้องอยู่ในถังขยะก่อนลบถาวร");
|
if (!guide?.trashedAt) throw new Error("Guide ต้องอยู่ในถังขยะก่อนลบถาวร");
|
||||||
@@ -476,6 +586,16 @@ export async function permanentlyDeleteGuide(guideId: string) {
|
|||||||
await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, layer.mediaId));
|
await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, layer.mediaId));
|
||||||
}
|
}
|
||||||
await emitGuideEvents(tx, guide.id, guide.version + 1, true);
|
await emitGuideEvents(tx, guide.id, guide.version + 1, true);
|
||||||
|
await writeAuditLog(tx, context.actor, {
|
||||||
|
action: "guide.permanently_deleted",
|
||||||
|
targetType: "guide",
|
||||||
|
targetId: guide.id,
|
||||||
|
resultingVersion: guide.version + 1,
|
||||||
|
metadata: {
|
||||||
|
guideName: guide.name,
|
||||||
|
characterKey: guide.characterKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
return { version: guide.version + 1 };
|
return { version: guide.version + 1 };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+79
-27
@@ -20,6 +20,7 @@ import {
|
|||||||
safeObjectFileName,
|
safeObjectFileName,
|
||||||
type MediaUploadInput,
|
type MediaUploadInput,
|
||||||
} from "@/lib/media/validation";
|
} from "@/lib/media/validation";
|
||||||
|
import { writeAuditLog, type AuditActor } from "@/lib/audit-log";
|
||||||
|
|
||||||
export async function createPendingMedia(
|
export async function createPendingMedia(
|
||||||
input: MediaUploadInput,
|
input: MediaUploadInput,
|
||||||
@@ -48,7 +49,7 @@ export async function createPendingMedia(
|
|||||||
|
|
||||||
export async function uploadPublicMedia(
|
export async function uploadPublicMedia(
|
||||||
file: File,
|
file: File,
|
||||||
authorId: string,
|
actor: AuditActor,
|
||||||
): Promise<{ id: string; url: string; fileName: string; width: number; height: number }> {
|
): Promise<{ id: string; url: string; fileName: string; width: number; height: number }> {
|
||||||
const parsed = mediaUploadSchema.parse({
|
const parsed = mediaUploadSchema.parse({
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
@@ -63,17 +64,29 @@ export async function uploadPublicMedia(
|
|||||||
const storage = await getMediaStorage();
|
const storage = await getMediaStorage();
|
||||||
await storage.write(objectKey, bytes, { type: parsed.mimeType, acl: "public-read" });
|
await storage.write(objectKey, bytes, { type: parsed.mimeType, acl: "public-read" });
|
||||||
try {
|
try {
|
||||||
await getDb().insert(media).values({
|
await getDb().transaction(async (tx) => {
|
||||||
id,
|
await tx.insert(media).values({
|
||||||
objectKey,
|
id,
|
||||||
fileName: parsed.fileName,
|
objectKey,
|
||||||
mimeType: parsed.mimeType,
|
fileName: parsed.fileName,
|
||||||
byteSize: parsed.byteSize,
|
mimeType: parsed.mimeType,
|
||||||
width,
|
byteSize: parsed.byteSize,
|
||||||
height,
|
width,
|
||||||
status: "ready",
|
height,
|
||||||
currentReferenceCount: 0,
|
status: "ready",
|
||||||
createdById: authorId,
|
currentReferenceCount: 0,
|
||||||
|
createdById: actor.id,
|
||||||
|
});
|
||||||
|
await writeAuditLog(tx, actor, {
|
||||||
|
action: "media.uploaded",
|
||||||
|
targetType: "media",
|
||||||
|
targetId: id,
|
||||||
|
metadata: {
|
||||||
|
fileName: parsed.fileName,
|
||||||
|
mimeType: parsed.mimeType,
|
||||||
|
byteSize: parsed.byteSize,
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
await storage.delete(objectKey).catch(() => undefined);
|
await storage.delete(objectKey).catch(() => undefined);
|
||||||
@@ -88,7 +101,7 @@ export async function uploadPublicMedia(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function completePendingMedia(mediaId: string) {
|
export async function completePendingMedia(mediaId: string, actor: AuditActor) {
|
||||||
const [record] = await getDb()
|
const [record] = await getDb()
|
||||||
.select()
|
.select()
|
||||||
.from(media)
|
.from(media)
|
||||||
@@ -124,17 +137,38 @@ export async function completePendingMedia(mediaId: string) {
|
|||||||
// A still-valid presigned PUT must not be able to overwrite verified bytes.
|
// A still-valid presigned PUT must not be able to overwrite verified bytes.
|
||||||
const objectKey = `media/${record.id}/verified/${crypto.randomUUID()}/${safeObjectFileName(record.fileName)}`;
|
const objectKey = `media/${record.id}/verified/${crypto.randomUUID()}/${safeObjectFileName(record.fileName)}`;
|
||||||
await storage.write(objectKey, bytes, { type: record.mimeType, acl: "private" });
|
await storage.write(objectKey, bytes, { type: record.mimeType, acl: "private" });
|
||||||
const [updated] = await getDb()
|
let updated: typeof record | undefined;
|
||||||
.update(media)
|
try {
|
||||||
.set({
|
updated = await getDb().transaction(async (tx) => {
|
||||||
status: "ready",
|
const [ready] = await tx
|
||||||
byteSize: bytes.byteLength,
|
.update(media)
|
||||||
checksum: createHash("sha256").update(bytes).digest("hex"),
|
.set({
|
||||||
objectKey,
|
status: "ready",
|
||||||
...dimensions,
|
byteSize: bytes.byteLength,
|
||||||
})
|
checksum: createHash("sha256").update(bytes).digest("hex"),
|
||||||
.where(and(eq(media.id, record.id), eq(media.status, "pending")))
|
objectKey,
|
||||||
.returning();
|
...dimensions,
|
||||||
|
})
|
||||||
|
.where(and(eq(media.id, record.id), eq(media.status, "pending")))
|
||||||
|
.returning();
|
||||||
|
if (ready) {
|
||||||
|
await writeAuditLog(tx, actor, {
|
||||||
|
action: "media.uploaded",
|
||||||
|
targetType: "media",
|
||||||
|
targetId: ready.id,
|
||||||
|
metadata: {
|
||||||
|
fileName: ready.fileName,
|
||||||
|
mimeType: ready.mimeType,
|
||||||
|
byteSize: ready.byteSize,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ready;
|
||||||
|
});
|
||||||
|
} catch (cause) {
|
||||||
|
await storage.delete(objectKey).catch(() => undefined);
|
||||||
|
throw cause;
|
||||||
|
}
|
||||||
if (!updated) await storage.delete(objectKey).catch(() => undefined);
|
if (!updated) await storage.delete(objectKey).catch(() => undefined);
|
||||||
else await file.delete().catch(() => undefined);
|
else await file.delete().catch(() => undefined);
|
||||||
return updated
|
return updated
|
||||||
@@ -145,10 +179,28 @@ export async function completePendingMedia(mediaId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function discardUnreferencedMedia(mediaId: string): Promise<boolean> {
|
export async function discardUnreferencedMedia(
|
||||||
|
mediaId: string,
|
||||||
|
actor: AuditActor,
|
||||||
|
): Promise<boolean> {
|
||||||
// Delete atomically under the FK/reference-count constraints before storage.
|
// Delete atomically under the FK/reference-count constraints before storage.
|
||||||
const [record] = await getDb().delete(media)
|
const record = await getDb().transaction(async (tx) => {
|
||||||
.where(and(eq(media.id, mediaId), eq(media.currentReferenceCount, 0))).returning();
|
const [deleted] = await tx.delete(media)
|
||||||
|
.where(and(eq(media.id, mediaId), eq(media.currentReferenceCount, 0))).returning();
|
||||||
|
if (deleted) {
|
||||||
|
await writeAuditLog(tx, actor, {
|
||||||
|
action: "media.deleted",
|
||||||
|
targetType: "media",
|
||||||
|
targetId: deleted.id,
|
||||||
|
metadata: {
|
||||||
|
fileName: deleted.fileName,
|
||||||
|
mimeType: deleted.mimeType,
|
||||||
|
byteSize: deleted.byteSize,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return deleted;
|
||||||
|
});
|
||||||
if (!record) return false;
|
if (!record) return false;
|
||||||
await (await getMediaStorage()).delete(record.objectKey).catch(() => undefined);
|
await (await getMediaStorage()).delete(record.objectKey).catch(() => undefined);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
Reference in New Issue
Block a user