feat : dont conflit with other editor
This commit is contained in:
@@ -92,6 +92,7 @@ export default async function StructuredEditorPage({
|
||||
extras={extraSections.map(({ id, slug, title }) => ({ id, slug, title }))}
|
||||
/>
|
||||
<GuideSectionEditor
|
||||
key={`${editorSection}:${extraSection?.id ?? "fixed"}:${guide.version}`}
|
||||
section={editorSection}
|
||||
extraSectionId={extraSection?.id}
|
||||
guide={guide}
|
||||
|
||||
@@ -54,7 +54,7 @@ export default async function GuideOverviewPage({ params }: { params: Promise<{
|
||||
<GuideVisibilityControl guideId={guide.id} guideName={guide.name} isPublic={guide.isPublic} />
|
||||
</header>
|
||||
<GuideSectionNav guideId={guide.id} characterKey={guide.characterKey} current="overview" extras={extras} />
|
||||
<GuideOverviewForm guide={guide} />
|
||||
<GuideOverviewForm key={guide.version} guide={guide} />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{sections.map(({ slug, title, description }) => (
|
||||
<Link key={slug} href={`/admin/${guide.characterKey}/${slug}`} prefetch={false} className="rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { connection } from "next/server";
|
||||
|
||||
import { StructuredDashboard } from "@/components/admin/structured-dashboard";
|
||||
import { AdminHeader } from "@/components/admin/admin-header";
|
||||
import { LiveRefresh } from "@/components/live-refresh";
|
||||
import { getAdminSession } from "@/lib/auth/server";
|
||||
import { getCurrentCatalogVersion } from "@/lib/catalog/sync";
|
||||
import { listGuideCards } from "@/lib/guides/queries";
|
||||
@@ -19,6 +20,7 @@ export default async function AdminPage() {
|
||||
]);
|
||||
return (
|
||||
<div className="min-h-svh">
|
||||
<LiveRefresh endpoint="/api/events/admin" />
|
||||
<AdminHeader />
|
||||
<main className="mx-auto flex w-full max-w-7xl flex-col gap-8 p-4 sm:p-6 lg:p-8">
|
||||
<header>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type { AutosaveState } from "@/hooks/use-autosave";
|
||||
|
||||
export function AutosaveStatus({ state, issues = [], onSave }: { state: AutosaveState; issues?: readonly string[]; onSave?: () => void }) {
|
||||
export function AutosaveStatus({ state, issues = [], onSave, conflictVersion, canOverwrite = false, onReloadLatest, onOverwrite }: { state: AutosaveState; issues?: readonly string[]; onSave?: () => void; conflictVersion?: number | null; canOverwrite?: boolean; onReloadLatest?: () => void; onOverwrite?: () => void }) {
|
||||
const [showIssues, setShowIssues] = useState(false);
|
||||
|
||||
if (state === "saving") return <Badge variant="secondary"><Spinner data-icon="inline-start" />Saving…</Badge>;
|
||||
@@ -27,6 +27,19 @@ export function AutosaveStatus({ state, issues = [], onSave }: { state: Autosave
|
||||
</AlertDescription>
|
||||
</Alert> : null}
|
||||
</div>;
|
||||
if (state === "conflict") return <Alert variant="destructive" className="max-w-xl">
|
||||
<AlertCircleIcon aria-hidden="true" />
|
||||
<AlertTitle>มีการบันทึกข้อมูลใหม่จากผู้แก้ไขคนอื่น</AlertTitle>
|
||||
<AlertDescription>
|
||||
<div className="flex flex-col gap-3">
|
||||
<p>ระบบหยุด Autosave ไว้เพื่อไม่ให้ Draft นี้เขียนทับข้อมูลล่าสุด{conflictVersion ? ` (version ${conflictVersion})` : ""}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{onReloadLatest ? <Button type="button" variant="outline" size="sm" onClick={onReloadLatest}>โหลดข้อมูลล่าสุด</Button> : null}
|
||||
{onOverwrite ? <Button type="button" variant="destructive" size="sm" disabled={!canOverwrite} onClick={onOverwrite}>เขียนทับด้วย Draft นี้</Button> : null}
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>;
|
||||
if (state === "error") return <div className="flex items-center gap-2"><Badge variant="destructive"><AlertCircleIcon data-icon="inline-start" />Autosave failed</Badge>{onSave ? <Button type="button" variant="outline" size="sm" onClick={onSave}>Save now</Button> : null}</div>;
|
||||
return <Badge variant="secondary"><CheckIcon data-icon="inline-start" />Saved</Badge>;
|
||||
}
|
||||
|
||||
@@ -203,6 +203,8 @@ export function GuideOverviewForm({
|
||||
const invalidOverview = !overview.trim();
|
||||
const localDraft = { overview, youtubeLinks, cover, posterArt, posterArtOffset, posterArtScale, posterTopColors, posterBottomColors, bleeding };
|
||||
const autosave = useAutosave({
|
||||
guideId: guide.id,
|
||||
scope: "overview",
|
||||
value,
|
||||
valid: Boolean(cover.id && overview.trim() && !invalidYoutubeLinks),
|
||||
initialVersion: guide.version,
|
||||
@@ -512,6 +514,10 @@ export function GuideOverviewForm({
|
||||
<CardFooter className="justify-end">
|
||||
<AutosaveStatus
|
||||
state={autosave.state}
|
||||
conflictVersion={autosave.conflictVersion}
|
||||
canOverwrite={autosave.canOverwrite}
|
||||
onReloadLatest={autosave.reloadLatest}
|
||||
onOverwrite={autosave.overwrite}
|
||||
issues={incompleteIssues}
|
||||
onSave={autosave.saveNow}
|
||||
/>
|
||||
|
||||
@@ -1375,6 +1375,8 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
|
||||
!validation.success && hasIssuePrefix(validation.error.issues, ...path);
|
||||
const localDraft = { note, groups, activeGroupIndex };
|
||||
const autosave = useAutosave({
|
||||
guideId: guide.id,
|
||||
scope: "weapon",
|
||||
value,
|
||||
valid: validation.success,
|
||||
initialVersion: guide.version,
|
||||
@@ -1728,6 +1730,10 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
|
||||
</Button>
|
||||
<AutosaveStatus
|
||||
state={autosave.state}
|
||||
conflictVersion={autosave.conflictVersion}
|
||||
canOverwrite={autosave.canOverwrite}
|
||||
onReloadLatest={autosave.reloadLatest}
|
||||
onOverwrite={autosave.overwrite}
|
||||
issues={
|
||||
validation.success
|
||||
? []
|
||||
@@ -1874,6 +1880,8 @@ function ArtifactEditor({
|
||||
note,
|
||||
};
|
||||
const autosave = useAutosave({
|
||||
guideId: guide.id,
|
||||
scope: "artifact",
|
||||
value,
|
||||
valid: validation.success,
|
||||
initialVersion: guide.version,
|
||||
@@ -2494,6 +2502,10 @@ function ArtifactEditor({
|
||||
<CardFooter className="justify-end">
|
||||
<AutosaveStatus
|
||||
state={autosave.state}
|
||||
conflictVersion={autosave.conflictVersion}
|
||||
canOverwrite={autosave.canOverwrite}
|
||||
onReloadLatest={autosave.reloadLatest}
|
||||
onOverwrite={autosave.overwrite}
|
||||
issues={
|
||||
validation.success
|
||||
? []
|
||||
@@ -2571,6 +2583,8 @@ function ConstellationEditor({
|
||||
!validation.success && hasIssuePrefix(validation.error.issues, ...path);
|
||||
const localDraft = { note, groups };
|
||||
const autosave = useAutosave({
|
||||
guideId: guide.id,
|
||||
scope: "constellations",
|
||||
value,
|
||||
valid: validation.success,
|
||||
initialVersion: guide.version,
|
||||
@@ -2789,6 +2803,10 @@ function ConstellationEditor({
|
||||
<CardFooter className="justify-end">
|
||||
<AutosaveStatus
|
||||
state={autosave.state}
|
||||
conflictVersion={autosave.conflictVersion}
|
||||
canOverwrite={autosave.canOverwrite}
|
||||
onReloadLatest={autosave.reloadLatest}
|
||||
onOverwrite={autosave.overwrite}
|
||||
issues={
|
||||
validation.success
|
||||
? []
|
||||
@@ -3011,6 +3029,8 @@ function TeamEditor({
|
||||
}
|
||||
}, [guide.id, presets, presetsGuideId, presetsHydrated]);
|
||||
const autosave = useAutosave({
|
||||
guideId: guide.id,
|
||||
scope: "team",
|
||||
value,
|
||||
valid: validation.success,
|
||||
initialVersion: guide.version,
|
||||
@@ -3918,6 +3938,10 @@ function TeamEditor({
|
||||
</Button>
|
||||
<AutosaveStatus
|
||||
state={autosave.state}
|
||||
conflictVersion={autosave.conflictVersion}
|
||||
canOverwrite={autosave.canOverwrite}
|
||||
onReloadLatest={autosave.reloadLatest}
|
||||
onOverwrite={autosave.overwrite}
|
||||
issues={
|
||||
validation.success
|
||||
? []
|
||||
@@ -4448,6 +4472,8 @@ function ExtraEditor({ guide, extraSectionId, initial, characters, weapons, arti
|
||||
!validation.success && hasIssuePrefix(validation.error.issues, ...path);
|
||||
const localDraft = { sections };
|
||||
const autosave = useAutosave({
|
||||
guideId: guide.id,
|
||||
scope: `extra:${sectionId}`,
|
||||
value,
|
||||
valid: validation.success,
|
||||
initialVersion: guide.version,
|
||||
@@ -5058,6 +5084,10 @@ function ExtraEditor({ guide, extraSectionId, initial, characters, weapons, arti
|
||||
<CardFooter className="flex-wrap justify-end gap-3">
|
||||
<AutosaveStatus
|
||||
state={autosave.state}
|
||||
conflictVersion={autosave.conflictVersion}
|
||||
canOverwrite={autosave.canOverwrite}
|
||||
onReloadLatest={autosave.reloadLatest}
|
||||
onOverwrite={autosave.overwrite}
|
||||
issues={
|
||||
validation.success
|
||||
? []
|
||||
|
||||
+76
-11
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useEffectEvent, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { saveWithConflictRetry } from "@/lib/editor/autosave";
|
||||
import { parseLocalDraft as parseStoredDraft, serializeLocalDraft } from "@/lib/editor/local-draft";
|
||||
import { parseInvalidationEvent, type AdminEditorScope } from "@/lib/events/invalidation";
|
||||
|
||||
export type AutosaveState = "saved" | "unsaved" | "saving" | "incomplete" | "error";
|
||||
export type AutosaveState = "saved" | "unsaved" | "saving" | "incomplete" | "error" | "conflict";
|
||||
|
||||
interface SaveResult {
|
||||
status: string;
|
||||
@@ -25,6 +26,8 @@ export function useAutosave<Value, Draft = Value>({
|
||||
localDraft,
|
||||
parseLocalDraft,
|
||||
restoreLocalDraft,
|
||||
guideId,
|
||||
scope,
|
||||
}: {
|
||||
value: Value;
|
||||
valid: boolean;
|
||||
@@ -35,7 +38,10 @@ export function useAutosave<Value, Draft = Value>({
|
||||
localDraft?: Draft;
|
||||
parseLocalDraft?: (value: unknown) => Draft | null;
|
||||
restoreLocalDraft?: (draft: Draft) => void;
|
||||
guideId: string;
|
||||
scope: AdminEditorScope;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const fingerprint = JSON.stringify(value);
|
||||
const draftFingerprint = JSON.stringify(localDraft ?? value);
|
||||
const [savedFingerprint, setSavedFingerprint] = useState(fingerprint);
|
||||
@@ -45,6 +51,8 @@ export function useAutosave<Value, Draft = Value>({
|
||||
const [version, setVersion] = useState(initialVersion);
|
||||
const [hydrated, setHydrated] = useState(!storageKey);
|
||||
const [manualRequest, setManualRequest] = useState(0);
|
||||
const [conflictVersion, setConflictVersion] = useState<number | null>(null);
|
||||
const [overwriteRequest, setOverwriteRequest] = useState<number | null>(null);
|
||||
const handledManualRequest = useRef(0);
|
||||
const storageWarningShown = useRef(false);
|
||||
|
||||
@@ -117,15 +125,17 @@ export function useAutosave<Value, Draft = Value>({
|
||||
}
|
||||
}, [draftFingerprint, hydrated, localDraft, savedDraftFingerprint, storageKey, version]);
|
||||
|
||||
const save = useEffectEvent(async (snapshot: Value, snapshotFingerprint: string, snapshotDraftFingerprint: string) => {
|
||||
const save = useEffectEvent(async (snapshot: Value, snapshotFingerprint: string, snapshotDraftFingerprint: string, expectedVersion = version) => {
|
||||
setSaving(true);
|
||||
setErrorFingerprint(null);
|
||||
try {
|
||||
const attempt = await saveWithConflictRetry(persist, snapshot, version);
|
||||
const result = attempt.response;
|
||||
if (attempt.retried) setVersion(attempt.version);
|
||||
if (result.status === "error" || result.status === "conflict") {
|
||||
const result = await persist(snapshot, expectedVersion);
|
||||
if (result.status === "conflict") {
|
||||
if (result.currentVersion) setVersion(result.currentVersion);
|
||||
setConflictVersion(result.currentVersion ?? expectedVersion);
|
||||
return;
|
||||
}
|
||||
if (result.status === "error") {
|
||||
setErrorFingerprint(snapshotFingerprint);
|
||||
toast.error(result.message ?? "บันทึกอัตโนมัติไม่สำเร็จ");
|
||||
return;
|
||||
@@ -133,6 +143,7 @@ export function useAutosave<Value, Draft = Value>({
|
||||
if (result.version) setVersion(result.version);
|
||||
setSavedFingerprint(snapshotFingerprint);
|
||||
setSavedDraftFingerprint(snapshotDraftFingerprint);
|
||||
setConflictVersion(null);
|
||||
} catch (cause) {
|
||||
setErrorFingerprint(snapshotFingerprint);
|
||||
toast.error(cause instanceof Error ? cause.message : "บันทึกอัตโนมัติไม่สำเร็จ");
|
||||
@@ -141,8 +152,35 @@ export function useAutosave<Value, Draft = Value>({
|
||||
}
|
||||
});
|
||||
|
||||
const receiveInvalidation = useEffectEvent((message: Event) => {
|
||||
if (!(message instanceof MessageEvent) || typeof message.data !== "string") return;
|
||||
const event = parseInvalidationEvent(message.data);
|
||||
if (!event || event.type !== "admin.updated" || event.id !== guideId || event.version <= version) return;
|
||||
if (event.scope && event.scope !== scope) {
|
||||
setVersion((current) => Math.max(current, event.version));
|
||||
setConflictVersion((current) => current === null ? null : Math.max(current, event.version));
|
||||
return;
|
||||
}
|
||||
if (hydrated && valid && !saving && fingerprint === savedFingerprint) {
|
||||
setVersion((current) => Math.max(current, event.version));
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
setVersion((current) => Math.max(current, event.version));
|
||||
setConflictVersion((current) => Math.max(current ?? 0, event.version));
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || !valid || saving) return;
|
||||
const source = new EventSource("/api/events/admin");
|
||||
source.addEventListener("invalidation", receiveInvalidation);
|
||||
return () => {
|
||||
source.removeEventListener("invalidation", receiveInvalidation);
|
||||
source.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || !valid || saving || conflictVersion !== null) return;
|
||||
const manual = manualRequest !== handledManualRequest.current;
|
||||
if (!manual && (fingerprint === savedFingerprint || fingerprint === errorFingerprint)) return;
|
||||
if (manual) handledManualRequest.current = manualRequest;
|
||||
@@ -150,10 +188,24 @@ export function useAutosave<Value, Draft = Value>({
|
||||
void save(JSON.parse(fingerprint) as Value, fingerprint, draftFingerprint);
|
||||
}, manual ? 0 : delay);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [delay, draftFingerprint, errorFingerprint, fingerprint, hydrated, manualRequest, savedFingerprint, saving, valid]);
|
||||
}, [conflictVersion, delay, draftFingerprint, errorFingerprint, fingerprint, hydrated, manualRequest, savedFingerprint, saving, valid]);
|
||||
|
||||
useEffect(() => {
|
||||
if (overwriteRequest === null || !valid || saving) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
void save(
|
||||
JSON.parse(fingerprint) as Value,
|
||||
fingerprint,
|
||||
draftFingerprint,
|
||||
overwriteRequest,
|
||||
).finally(() => setOverwriteRequest(null));
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [draftFingerprint, fingerprint, overwriteRequest, saving, valid]);
|
||||
|
||||
let state: AutosaveState;
|
||||
if (!valid) state = "incomplete";
|
||||
if (conflictVersion !== null) state = "conflict";
|
||||
else if (!valid) state = "incomplete";
|
||||
else if (saving) state = "saving";
|
||||
else if (fingerprint === errorFingerprint) state = "error";
|
||||
else if (fingerprint !== savedFingerprint) state = "unsaved";
|
||||
@@ -179,9 +231,22 @@ export function useAutosave<Value, Draft = Value>({
|
||||
return {
|
||||
state,
|
||||
saveNow: () => {
|
||||
if (!valid || saving) return;
|
||||
if (!valid || saving || conflictVersion !== null) return;
|
||||
setErrorFingerprint(null);
|
||||
setManualRequest((current) => current + 1);
|
||||
},
|
||||
conflictVersion,
|
||||
canOverwrite: valid && !saving && overwriteRequest === null,
|
||||
reloadLatest: () => {
|
||||
try {
|
||||
if (storageKey) window.localStorage.removeItem(storageKey);
|
||||
} finally {
|
||||
window.location.reload();
|
||||
}
|
||||
},
|
||||
overwrite: () => {
|
||||
if (!valid || saving || conflictVersion === null) return;
|
||||
setOverwriteRequest(conflictVersion);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
AutosaveCoordinator,
|
||||
saveWithConflictRetry,
|
||||
type AutosaveResponse,
|
||||
type AutosaveStatus,
|
||||
} from "./autosave";
|
||||
@@ -115,24 +114,3 @@ describe("AutosaveCoordinator", () => {
|
||||
expect(invalid.statuses.at(-1)).toMatchObject({ state: "error", issues: ["title"] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveWithConflictRetry", () => {
|
||||
it("retries one version conflict against the current server version", async () => {
|
||||
const calls: number[] = [];
|
||||
const save = vi.fn(async (_value: string, expectedVersion: number) => {
|
||||
calls.push(expectedVersion);
|
||||
if (calls.length === 1) return { status: "conflict" as const, currentVersion: 8 };
|
||||
return { status: "saved" as const, version: 9 };
|
||||
});
|
||||
const result = await saveWithConflictRetry(save, "constellation draft", 7);
|
||||
expect(calls).toEqual([7, 8]);
|
||||
expect(result).toEqual({ response: { status: "saved", version: 9 }, version: 8, retried: true });
|
||||
});
|
||||
|
||||
it("stops after one retry when another concurrent save wins", async () => {
|
||||
const save = vi.fn(async (_value: string, expectedVersion: number) => ({ status: "conflict" as const, currentVersion: expectedVersion + 1 }));
|
||||
const result = await saveWithConflictRetry(save, "constellation draft", 3);
|
||||
expect(save).toHaveBeenCalledTimes(2);
|
||||
expect(result.response).toEqual({ status: "conflict", currentVersion: 5 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,22 +12,6 @@ export type AutosaveStatus =
|
||||
| { state: "conflict"; currentVersion: number }
|
||||
| { state: "error"; message: string; issues?: string[] };
|
||||
|
||||
export async function saveWithConflictRetry<Value, Response extends { status: string; currentVersion?: number }>(
|
||||
save: (value: Value, expectedVersion: number) => Promise<Response>,
|
||||
value: Value,
|
||||
expectedVersion: number,
|
||||
): Promise<{ response: Response; version: number; retried: boolean }> {
|
||||
const first = await save(value, expectedVersion);
|
||||
if (first.status !== "conflict" || !first.currentVersion || first.currentVersion === expectedVersion) {
|
||||
return { response: first, version: expectedVersion, retried: false };
|
||||
}
|
||||
return {
|
||||
response: await save(value, first.currentVersion),
|
||||
version: first.currentVersion,
|
||||
retried: true,
|
||||
};
|
||||
}
|
||||
|
||||
interface AutosaveCoordinatorOptions<Draft, Payload, Snapshot> {
|
||||
initialVersion: number;
|
||||
createPayload: (draft: Draft, expectedVersion: number) => Payload;
|
||||
|
||||
@@ -41,4 +41,24 @@ describe("invalidation event envelopes", () => {
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps valid scopes only on authenticated admin events", () => {
|
||||
const id = "4afeea7b-6f24-43f3-b747-2db98335e01e";
|
||||
expect(toInvalidationEvent({
|
||||
topic: "admin",
|
||||
aggregateId: id,
|
||||
eventType: "admin.updated",
|
||||
payload: { id, version: 4, scope: "team" },
|
||||
})).toEqual({ type: "admin.updated", id, version: 4, scope: "team" });
|
||||
expect(parseInvalidationEvent(JSON.stringify({ type: "admin.updated", id, version: 5, scope: `extra:${id}` })))
|
||||
.toEqual({ type: "admin.updated", id, version: 5, scope: `extra:${id}` });
|
||||
expect(parseInvalidationEvent(JSON.stringify({ type: "page.updated", id, version: 5, scope: "team" })))
|
||||
.toBeNull();
|
||||
expect(toInvalidationEvent({
|
||||
topic: "admin",
|
||||
aggregateId: id,
|
||||
eventType: "admin.updated",
|
||||
payload: { id, version: 6, scope: "unknown" },
|
||||
})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,10 +6,20 @@ export const INVALIDATION_TYPES = [
|
||||
|
||||
export type InvalidationType = (typeof INVALIDATION_TYPES)[number];
|
||||
|
||||
export type AdminEditorScope =
|
||||
| "overview"
|
||||
| "weapon"
|
||||
| "artifact"
|
||||
| "constellations"
|
||||
| "team"
|
||||
| "extras:list"
|
||||
| `extra:${string}`;
|
||||
|
||||
export interface InvalidationEvent {
|
||||
type: InvalidationType;
|
||||
id: string;
|
||||
version: number;
|
||||
scope?: AdminEditorScope;
|
||||
}
|
||||
|
||||
export interface OutboxEventLike {
|
||||
@@ -23,6 +33,13 @@ function isInvalidationType(value: string): value is InvalidationType {
|
||||
return INVALIDATION_TYPES.some((type) => type === value);
|
||||
}
|
||||
|
||||
function isAdminEditorScope(value: unknown): value is AdminEditorScope {
|
||||
return typeof value === "string" && (
|
||||
["overview", "weapon", "artifact", "constellations", "team", "extras:list"].includes(value)
|
||||
|| /^extra:[0-9a-f-]{36}$/iu.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
export function expectedTopic(event: InvalidationEvent): string {
|
||||
switch (event.type) {
|
||||
case "directory.updated":
|
||||
@@ -38,6 +55,11 @@ export function toInvalidationEvent(
|
||||
record: OutboxEventLike,
|
||||
): InvalidationEvent | null {
|
||||
if (!isInvalidationType(record.eventType)) return null;
|
||||
if (
|
||||
record.eventType === "admin.updated"
|
||||
&& record.payload.scope !== undefined
|
||||
&& !isAdminEditorScope(record.payload.scope)
|
||||
) return null;
|
||||
const id = record.payload.id;
|
||||
const version = record.payload.version;
|
||||
if (
|
||||
@@ -53,6 +75,9 @@ export function toInvalidationEvent(
|
||||
type: record.eventType,
|
||||
id,
|
||||
version,
|
||||
...(record.eventType === "admin.updated" && isAdminEditorScope(record.payload.scope)
|
||||
? { scope: record.payload.scope }
|
||||
: {}),
|
||||
};
|
||||
return expectedTopic(event) === record.topic ? event : null;
|
||||
}
|
||||
@@ -63,6 +88,7 @@ export function parseInvalidationEvent(value: string): InvalidationEvent | null
|
||||
const type = parsed.type;
|
||||
const id = parsed.id;
|
||||
const version = parsed.version;
|
||||
const scope = parsed.scope;
|
||||
if (
|
||||
typeof type !== "string" ||
|
||||
!isInvalidationType(type) ||
|
||||
@@ -73,7 +99,10 @@ export function parseInvalidationEvent(value: string): InvalidationEvent | null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { type, id, version };
|
||||
if (scope !== undefined && (type !== "admin.updated" || !isAdminEditorScope(scope))) {
|
||||
return null;
|
||||
}
|
||||
return { type, id, version, ...(scope === undefined ? {} : { scope }) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
+14
-13
@@ -40,6 +40,7 @@ import {
|
||||
} from "@/lib/guides/schemas";
|
||||
import { processCatalogSync } from "@/lib/catalog/sync";
|
||||
import { normalizeExtraRichText } from "@/lib/rich-text";
|
||||
import type { AdminEditorScope } from "@/lib/events/invalidation";
|
||||
|
||||
const DEFAULT_SECTIONS = [
|
||||
{ kind: "weapon", slug: "weapon", title: "Weapons", sortOrder: 0 },
|
||||
@@ -55,13 +56,13 @@ export class GuideVersionConflictError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
async function emitGuideEvents(tx: Parameters<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0], guideId: string, version: number, directoryChanged = false) {
|
||||
async function emitGuideEvents(tx: Parameters<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0], guideId: string, version: number, directoryChanged = false, scope?: AdminEditorScope) {
|
||||
await tx.insert(outboxEvents).values([
|
||||
...(directoryChanged
|
||||
? [{ topic: "directory", aggregateId: guideId, eventType: "directory.updated", payload: { id: guideId, version } }]
|
||||
: []),
|
||||
{ topic: `page:${guideId}`, aggregateId: guideId, eventType: "page.updated", payload: { id: guideId, version } },
|
||||
{ topic: "admin", aggregateId: guideId, eventType: "admin.updated", payload: { id: guideId, version } },
|
||||
{ topic: "admin", aggregateId: guideId, eventType: "admin.updated", payload: { id: guideId, version, ...(scope ? { scope } : {}) } },
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -101,14 +102,14 @@ export async function createGuide(input: unknown) {
|
||||
});
|
||||
}
|
||||
|
||||
async function advanceGuideVersion(tx: Parameters<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0], guideId: string, expectedVersion: number, directoryChanged = false) {
|
||||
async function advanceGuideVersion(tx: Parameters<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0], guideId: string, expectedVersion: number, directoryChanged = false, scope?: AdminEditorScope) {
|
||||
const [guide] = await tx.update(guides).set({ version: expectedVersion + 1 }).where(and(eq(guides.id, guideId), eq(guides.version, expectedVersion), sql`${guides.trashedAt} is null`)).returning();
|
||||
if (!guide) {
|
||||
const [current] = await tx.select({ version: guides.version }).from(guides).where(eq(guides.id, guideId)).limit(1);
|
||||
if (current) throw new GuideVersionConflictError(current.version);
|
||||
throw new Error("ไม่พบ Guide");
|
||||
}
|
||||
await emitGuideEvents(tx, guide.id, guide.version, directoryChanged);
|
||||
await emitGuideEvents(tx, guide.id, guide.version, directoryChanged, scope);
|
||||
return guide;
|
||||
}
|
||||
|
||||
@@ -117,7 +118,7 @@ export async function saveWeapons(guideId: string, input: unknown) {
|
||||
const [guide] = await getDb().select().from(guides).where(eq(guides.id, guideId)).limit(1);
|
||||
if (!guide) throw new Error("ไม่พบ Guide");
|
||||
return getDb().transaction(async (tx) => {
|
||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
|
||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "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));
|
||||
for (const [sortOrder, group] of data.groups.entries()) {
|
||||
@@ -153,7 +154,7 @@ export async function updateGuideBasics(guideId: string, input: unknown) {
|
||||
: [];
|
||||
if (bleedingMedia.length !== bleedingMediaIds.length) throw new Error("ไม่พบภาพ Bleeding ที่อัปโหลดแล้ว");
|
||||
}
|
||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, true);
|
||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, true, "overview");
|
||||
await tx.update(guides).set({
|
||||
overview: data.overview,
|
||||
coverMediaId: data.coverMediaId,
|
||||
@@ -206,7 +207,7 @@ export async function saveArtifacts(guideId: string, input: unknown) {
|
||||
const [guide] = await getDb().select().from(guides).where(eq(guides.id, guideId)).limit(1);
|
||||
if (!guide) throw new Error("ไม่พบ Guide");
|
||||
return getDb().transaction(async (tx) => {
|
||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
|
||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "artifact");
|
||||
await tx.delete(artifactGroups).where(eq(artifactGroups.guideId, guideId));
|
||||
for (const [sortOrder, group] of data.groups.entries()) {
|
||||
const [created] = await tx.insert(artifactGroups).values({ guideId, name: group.name, sortOrder }).returning();
|
||||
@@ -220,7 +221,7 @@ export async function saveArtifacts(guideId: string, input: unknown) {
|
||||
export async function saveConstellations(guideId: string, input: unknown) {
|
||||
const data = constellationSectionSchema.parse(input);
|
||||
return getDb().transaction(async (tx) => {
|
||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
|
||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "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));
|
||||
for (const [sortOrder, group] of data.groups.entries()) {
|
||||
@@ -255,7 +256,7 @@ export async function saveTeams(guideId: string, input: unknown) {
|
||||
}
|
||||
}
|
||||
return getDb().transaction(async (tx) => {
|
||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
|
||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "team");
|
||||
await tx.delete(teamConditions).where(eq(teamConditions.guideId, guideId));
|
||||
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();
|
||||
@@ -271,7 +272,7 @@ export async function saveTeams(guideId: string, input: unknown) {
|
||||
export async function saveExtraSections(guideId: string, input: unknown) {
|
||||
const data = extraSectionSchema.parse(input);
|
||||
return getDb().transaction(async (tx) => {
|
||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
|
||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "extras:list");
|
||||
await tx.delete(guideSections).where(and(eq(guideSections.guideId, guideId), eq(guideSections.kind, "extra")));
|
||||
for (const [sortOrder, section] of data.sections.entries()) {
|
||||
const [createdSection] = await tx.insert(guideSections).values({ guideId, kind: "extra", slug: `extra-${sortOrder + 1}`, title: section.title, enabled: true, sortOrder: 4 + sortOrder }).returning();
|
||||
@@ -323,7 +324,7 @@ export async function createExtraSection(guideId: string, input: unknown) {
|
||||
})
|
||||
.returning();
|
||||
await tx.insert(extraSections).values({ sectionId: section.id });
|
||||
await emitGuideEvents(tx, guideId, updated.version);
|
||||
await emitGuideEvents(tx, guideId, updated.version, false, "extras:list");
|
||||
return { ...section, version: updated.version };
|
||||
});
|
||||
}
|
||||
@@ -346,7 +347,7 @@ export async function saveExtraSection(
|
||||
.limit(1);
|
||||
if (!target) throw new Error("ไม่พบ Extra section");
|
||||
|
||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
|
||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, `extra:${sectionId}`);
|
||||
await tx
|
||||
.update(guideSections)
|
||||
.set({ title: data.section.title })
|
||||
@@ -406,7 +407,7 @@ export async function reorderExtraSections(guideId: string, input: unknown) {
|
||||
throw new Error("รายการ Extra sections ไม่ตรงกับข้อมูลล่าสุด");
|
||||
}
|
||||
|
||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
|
||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "extras:list");
|
||||
for (const [index, sectionId] of data.sectionIds.entries()) {
|
||||
await tx
|
||||
.update(guideSections)
|
||||
|
||||
Reference in New Issue
Block a user