From c3a2aa88d6830a4b2b26783e45839b589a2d935d Mon Sep 17 00:00:00 2001 From: gunshiz Date: Tue, 15 Sep 2026 19:46:40 +0700 Subject: [PATCH] feat(editor) : notify peers of draft overwrite --- app/admin/actions.ts | 42 ++++++++++++++--------- components/admin/guide-overview-form.tsx | 4 +-- components/admin/guide-section-editor.tsx | 20 +++++------ hooks/use-autosave.ts | 26 +++++++++++--- lib/events/invalidation.test.ts | 23 +++++++++++++ lib/events/invalidation.ts | 20 ++++++++++- lib/guides/mutations.ts | 36 +++++++++++-------- 7 files changed, 121 insertions(+), 50 deletions(-) diff --git a/app/admin/actions.ts b/app/admin/actions.ts index 5e25a8d..7c63265 100644 --- a/app/admin/actions.ts +++ b/app/admin/actions.ts @@ -43,6 +43,13 @@ type GuideActionResult = | { status: "error"; message: string }; type CreateGuideActionResult = Exclude; +type SaveIntent = { overwrite?: boolean }; + +function overwriteDetails(admin: Awaited>, intent?: SaveIntent) { + return intent?.overwrite + ? { operation: "overwrite" as const, actorName: (admin.user.name?.trim() || admin.user.email).slice(0, 100) } + : undefined; +} async function runGuideAction(action: () => Promise<{ version?: number }>): Promise { try { @@ -69,29 +76,29 @@ export async function createStructuredGuide(input: unknown): Promise saveWeapons(guideId, input)); +export async function saveWeaponSection(guideId: string, input: unknown, intent?: SaveIntent) { + const admin = await requireAdmin(); + return runGuideAction(() => saveWeapons(guideId, input, overwriteDetails(admin, intent))); } -export async function saveGuideOverview(guideId: string, input: unknown) { - await requireAdmin(); - return runGuideAction(() => updateGuideBasics(guideId, input)); +export async function saveGuideOverview(guideId: string, input: unknown, intent?: SaveIntent) { + const admin = await requireAdmin(); + return runGuideAction(() => updateGuideBasics(guideId, input, overwriteDetails(admin, intent))); } -export async function saveArtifactSection(guideId: string, input: unknown) { - await requireAdmin(); - return runGuideAction(() => saveArtifacts(guideId, input)); +export async function saveArtifactSection(guideId: string, input: unknown, intent?: SaveIntent) { + const admin = await requireAdmin(); + return runGuideAction(() => saveArtifacts(guideId, input, overwriteDetails(admin, intent))); } -export async function saveConstellationSection(guideId: string, input: unknown) { - await requireAdmin(); - return runGuideAction(() => saveConstellations(guideId, input)); +export async function saveConstellationSection(guideId: string, input: unknown, intent?: SaveIntent) { + const admin = await requireAdmin(); + return runGuideAction(() => saveConstellations(guideId, input, overwriteDetails(admin, intent))); } -export async function saveTeamSection(guideId: string, input: unknown) { - await requireAdmin(); - return runGuideAction(() => saveTeams(guideId, input)); +export async function saveTeamSection(guideId: string, input: unknown, intent?: SaveIntent) { + const admin = await requireAdmin(); + return runGuideAction(() => saveTeams(guideId, input, overwriteDetails(admin, intent))); } export async function listTeamImportGuides(currentGuideId: string) { @@ -152,12 +159,13 @@ export async function saveGuideExtra( guideId: string, sectionId: string, input: unknown, + intent?: SaveIntent, ) { - await requireAdmin(); + const admin = await requireAdmin(); const parsedGuideId = z.string().uuid().parse(guideId); const parsedSectionId = z.string().uuid().parse(sectionId); const result = await runGuideAction(() => - saveExtraSection(parsedGuideId, parsedSectionId, input), + saveExtraSection(parsedGuideId, parsedSectionId, input, overwriteDetails(admin, intent)), ); if (result.status === "saved") updateTag(pageCacheTag(guideId)); return result; diff --git a/components/admin/guide-overview-form.tsx b/components/admin/guide-overview-form.tsx index 3f854dd..88dfa0a 100644 --- a/components/admin/guide-overview-form.tsx +++ b/components/admin/guide-overview-form.tsx @@ -208,8 +208,8 @@ export function GuideOverviewForm({ value, valid: Boolean(cover.id && overview.trim() && !invalidYoutubeLinks), initialVersion: guide.version, - persist: (next, expectedVersion) => - saveGuideOverview(guide.id, { ...next, expectedVersion }), + persist: (next, expectedVersion, intent) => + saveGuideOverview(guide.id, { ...next, expectedVersion }, intent), storageKey: adminDraftKey(guide.id, "overview"), localDraft, parseLocalDraft: (candidate) => { diff --git a/components/admin/guide-section-editor.tsx b/components/admin/guide-section-editor.tsx index a2add9e..a5a4fe8 100644 --- a/components/admin/guide-section-editor.tsx +++ b/components/admin/guide-section-editor.tsx @@ -1380,8 +1380,8 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) { value, valid: validation.success, initialVersion: guide.version, - persist: (next, expectedVersion) => - saveWeaponSection(guide.id, { ...next, expectedVersion }), + persist: (next, expectedVersion, intent) => + saveWeaponSection(guide.id, { ...next, expectedVersion }, intent), storageKey: adminDraftKey(guide.id, "weapon"), localDraft, parseLocalDraft: (candidate) => @@ -1885,8 +1885,8 @@ function ArtifactEditor({ value, valid: validation.success, initialVersion: guide.version, - persist: (next, expectedVersion) => - saveArtifactSection(guide.id, { ...next, expectedVersion }), + persist: (next, expectedVersion, intent) => + saveArtifactSection(guide.id, { ...next, expectedVersion }, intent), storageKey: adminDraftKey(guide.id, "artifact"), localDraft, parseLocalDraft: (candidate) => @@ -2588,8 +2588,8 @@ function ConstellationEditor({ value, valid: validation.success, initialVersion: guide.version, - persist: (next, expectedVersion) => - saveConstellationSection(guide.id, { ...next, expectedVersion }), + persist: (next, expectedVersion, intent) => + saveConstellationSection(guide.id, { ...next, expectedVersion }, intent), storageKey: adminDraftKey(guide.id, "constellations"), localDraft, parseLocalDraft: (candidate) => @@ -3034,8 +3034,8 @@ function TeamEditor({ value, valid: validation.success, initialVersion: guide.version, - persist: (next, expectedVersion) => - saveTeamSection(guide.id, { ...next, expectedVersion }), + persist: (next, expectedVersion, intent) => + saveTeamSection(guide.id, { ...next, expectedVersion }, intent), storageKey: adminDraftKey(guide.id, "team"), localDraft, parseLocalDraft: (candidate) => @@ -4477,11 +4477,11 @@ function ExtraEditor({ guide, extraSectionId, initial, characters, weapons, arti value, valid: validation.success, initialVersion: guide.version, - persist: (next, expectedVersion) => + persist: (next, expectedVersion, intent) => saveGuideExtra(guide.id, sectionId, { section: next.sections[0], expectedVersion, - }), + }, intent), storageKey: adminDraftKey(guide.id, `extra:${sectionId}`), localDraft, parseLocalDraft: (candidate) => diff --git a/hooks/use-autosave.ts b/hooks/use-autosave.ts index 579994f..6c4784f 100644 --- a/hooks/use-autosave.ts +++ b/hooks/use-autosave.ts @@ -16,6 +16,10 @@ interface SaveResult { currentVersion?: number; } +interface SaveIntent { + overwrite?: boolean; +} + export function useAutosave({ value, valid, @@ -32,7 +36,7 @@ export function useAutosave({ value: Value; valid: boolean; initialVersion: number; - persist: (value: Value, expectedVersion: number) => Promise; + persist: (value: Value, expectedVersion: number, intent?: SaveIntent) => Promise; delay?: number; storageKey?: string; localDraft?: Draft; @@ -52,6 +56,7 @@ export function useAutosave({ const [hydrated, setHydrated] = useState(!storageKey); const [manualRequest, setManualRequest] = useState(0); const [conflictVersion, setConflictVersion] = useState(null); + const [conflictActor, setConflictActor] = useState(null); const [overwriteRequest, setOverwriteRequest] = useState(null); const handledManualRequest = useRef(0); const storageWarningShown = useRef(false); @@ -125,14 +130,15 @@ export function useAutosave({ } }, [draftFingerprint, hydrated, localDraft, savedDraftFingerprint, storageKey, version]); - const save = useEffectEvent(async (snapshot: Value, snapshotFingerprint: string, snapshotDraftFingerprint: string, expectedVersion = version) => { + const save = useEffectEvent(async (snapshot: Value, snapshotFingerprint: string, snapshotDraftFingerprint: string, expectedVersion = version, overwrite = false) => { setSaving(true); setErrorFingerprint(null); try { - const result = await persist(snapshot, expectedVersion); + const result = await persist(snapshot, expectedVersion, overwrite ? { overwrite: true } : undefined); if (result.status === "conflict") { if (result.currentVersion) setVersion(result.currentVersion); setConflictVersion(result.currentVersion ?? expectedVersion); + setConflictActor(null); return; } if (result.status === "error") { @@ -144,6 +150,7 @@ export function useAutosave({ setSavedFingerprint(snapshotFingerprint); setSavedDraftFingerprint(snapshotDraftFingerprint); setConflictVersion(null); + setConflictActor(null); } catch (cause) { setErrorFingerprint(snapshotFingerprint); toast.error(cause instanceof Error ? cause.message : "บันทึกอัตโนมัติไม่สำเร็จ"); @@ -162,12 +169,18 @@ export function useAutosave({ return; } if (hydrated && valid && !saving && fingerprint === savedFingerprint) { + if (event.operation === "overwrite" && event.actorName) { + toast.warning(`${event.actorName} เขียนทับข้อมูลส่วนนี้ด้วย Local draft`, { + description: "ระบบโหลดข้อมูลล่าสุดให้แล้ว", + }); + } 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)); + setConflictActor(event.operation === "overwrite" ? event.actorName ?? null : null); }); useEffect(() => { @@ -187,7 +200,9 @@ export function useAutosave({ } toast.error("Autosave ถูกหยุดชั่วคราว", { id: toastId, - description: "มีผู้แก้ไขคนอื่นบันทึกข้อมูลส่วนนี้ กรุณาเลือกโหลดข้อมูลล่าสุดหรือเขียนทับด้วย Draft นี้", + description: conflictActor + ? `${conflictActor} เขียนทับข้อมูลส่วนนี้ แต่ Draft ของคุณยังถูกเก็บไว้` + : "มีผู้แก้ไขคนอื่นบันทึกข้อมูลส่วนนี้ กรุณาเลือกโหลดข้อมูลล่าสุดหรือเขียนทับด้วย Draft นี้", duration: Infinity, action: { label: "ดูตัวเลือก", @@ -197,7 +212,7 @@ export function useAutosave({ return () => { toast.dismiss(toastId); }; - }, [conflictVersion, guideId, scope]); + }, [conflictActor, conflictVersion, guideId, scope]); useEffect(() => { if (!hydrated || !valid || saving || conflictVersion !== null) return; @@ -218,6 +233,7 @@ export function useAutosave({ fingerprint, draftFingerprint, overwriteRequest, + true, ).finally(() => setOverwriteRequest(null)); }, 0); return () => window.clearTimeout(timer); diff --git a/lib/events/invalidation.test.ts b/lib/events/invalidation.test.ts index d347570..444bfc2 100644 --- a/lib/events/invalidation.test.ts +++ b/lib/events/invalidation.test.ts @@ -61,4 +61,27 @@ describe("invalidation event envelopes", () => { payload: { id, version: 6, scope: "unknown" }, })).toBeNull(); }); + + it("publishes validated overwrite details only on admin events", () => { + const id = "4afeea7b-6f24-43f3-b747-2db98335e01e"; + const overwrite = { operation: "overwrite", actorName: "Editor One" }; + expect(toInvalidationEvent({ + topic: "admin", + aggregateId: id, + eventType: "admin.updated", + payload: { id, version: 7, scope: "weapon", ...overwrite }, + })).toEqual({ type: "admin.updated", id, version: 7, scope: "weapon", ...overwrite }); + expect(parseInvalidationEvent(JSON.stringify({ + type: "admin.updated", + id, + version: 8, + operation: "overwrite", + }))).toBeNull(); + expect(toInvalidationEvent({ + topic: `page:${id}`, + aggregateId: id, + eventType: "page.updated", + payload: { id, version: 7, ...overwrite }, + })).toEqual({ type: "page.updated", id, version: 7 }); + }); }); diff --git a/lib/events/invalidation.ts b/lib/events/invalidation.ts index 771b4de..19b9e4e 100644 --- a/lib/events/invalidation.ts +++ b/lib/events/invalidation.ts @@ -20,6 +20,8 @@ export interface InvalidationEvent { id: string; version: number; scope?: AdminEditorScope; + operation?: "overwrite"; + actorName?: string; } export interface OutboxEventLike { @@ -40,6 +42,17 @@ function isAdminEditorScope(value: unknown): value is AdminEditorScope { ); } +function overwriteDetails(payload: Record) { + if (payload.operation === undefined && payload.actorName === undefined) return {}; + if ( + payload.operation !== "overwrite" + || typeof payload.actorName !== "string" + || !payload.actorName.trim() + || payload.actorName.length > 100 + ) return null; + return { operation: "overwrite" as const, actorName: payload.actorName }; +} + export function expectedTopic(event: InvalidationEvent): string { switch (event.type) { case "directory.updated": @@ -60,6 +73,8 @@ export function toInvalidationEvent( && record.payload.scope !== undefined && !isAdminEditorScope(record.payload.scope) ) return null; + const details = record.eventType === "admin.updated" ? overwriteDetails(record.payload) : {}; + if (details === null) return null; const id = record.payload.id; const version = record.payload.version; if ( @@ -78,6 +93,7 @@ export function toInvalidationEvent( ...(record.eventType === "admin.updated" && isAdminEditorScope(record.payload.scope) ? { scope: record.payload.scope } : {}), + ...details, }; return expectedTopic(event) === record.topic ? event : null; } @@ -89,6 +105,7 @@ export function parseInvalidationEvent(value: string): InvalidationEvent | null const id = parsed.id; const version = parsed.version; const scope = parsed.scope; + const details = type === "admin.updated" ? overwriteDetails(parsed) : {}; if ( typeof type !== "string" || !isInvalidationType(type) || @@ -102,7 +119,8 @@ export function parseInvalidationEvent(value: string): InvalidationEvent | null if (scope !== undefined && (type !== "admin.updated" || !isAdminEditorScope(scope))) { return null; } - return { type, id, version, ...(scope === undefined ? {} : { scope }) }; + if (details === null) return null; + return { type, id, version, ...(scope === undefined ? {} : { scope }), ...details }; } catch { return null; } diff --git a/lib/guides/mutations.ts b/lib/guides/mutations.ts index 45d9594..5d96a02 100644 --- a/lib/guides/mutations.ts +++ b/lib/guides/mutations.ts @@ -49,6 +49,11 @@ const DEFAULT_SECTIONS = [ { kind: "team", slug: "team", title: "Team Calc", sortOrder: 3 }, ] as const; +export interface GuideEventDetails { + operation: "overwrite"; + actorName: string; +} + export class GuideVersionConflictError extends Error { constructor(readonly currentVersion: number) { super("ข้อมูลถูกแก้ไขจากแท็บอื่น กด Save now เพื่อบันทึก Local draft ทับเวอร์ชันล่าสุด"); @@ -56,13 +61,13 @@ export class GuideVersionConflictError extends Error { } } -async function emitGuideEvents(tx: Parameters["transaction"]>[0]>[0], guideId: string, version: number, directoryChanged = false, scope?: AdminEditorScope) { +async function emitGuideEvents(tx: Parameters["transaction"]>[0]>[0], guideId: string, version: number, directoryChanged = false, scope?: AdminEditorScope, details?: GuideEventDetails) { 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, ...(scope ? { scope } : {}) } }, + { topic: "admin", aggregateId: guideId, eventType: "admin.updated", payload: { id: guideId, version, ...(scope ? { scope } : {}), ...details } }, ]); } @@ -102,23 +107,23 @@ export async function createGuide(input: unknown) { }); } -async function advanceGuideVersion(tx: Parameters["transaction"]>[0]>[0], guideId: string, expectedVersion: number, directoryChanged = false, scope?: AdminEditorScope) { +async function advanceGuideVersion(tx: Parameters["transaction"]>[0]>[0], guideId: string, expectedVersion: number, directoryChanged = false, scope?: AdminEditorScope, details?: GuideEventDetails) { 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, scope); + await emitGuideEvents(tx, guide.id, guide.version, directoryChanged, scope, details); return guide; } -export async function saveWeapons(guideId: string, input: unknown) { +export async function saveWeapons(guideId: string, input: unknown, details?: GuideEventDetails) { const data = weaponSectionSchema.parse(input); 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, false, "weapon"); + const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "weapon", details); 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()) { @@ -129,7 +134,7 @@ export async function saveWeapons(guideId: string, input: unknown) { }); } -export async function updateGuideBasics(guideId: string, input: unknown) { +export async function updateGuideBasics(guideId: string, input: unknown, details?: GuideEventDetails) { const data = updateGuideSchema.parse(input); const [current] = await getDb().select().from(guides).where(eq(guides.id, guideId)).limit(1); if (!current) throw new Error("ไม่พบ Guide"); @@ -154,7 +159,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, "overview"); + const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, true, "overview", details); await tx.update(guides).set({ overview: data.overview, coverMediaId: data.coverMediaId, @@ -202,12 +207,12 @@ export async function updateGuideBasics(guideId: string, input: unknown) { }); } -export async function saveArtifacts(guideId: string, input: unknown) { +export async function saveArtifacts(guideId: string, input: unknown, details?: GuideEventDetails) { const data = artifactSectionSchema.parse(input); 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, false, "artifact"); + const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "artifact", details); 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(); @@ -218,10 +223,10 @@ export async function saveArtifacts(guideId: string, input: unknown) { }); } -export async function saveConstellations(guideId: string, input: unknown) { +export async function saveConstellations(guideId: string, input: unknown, details?: GuideEventDetails) { const data = constellationSectionSchema.parse(input); return getDb().transaction(async (tx) => { - const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "constellations"); + const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "constellations", details); 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()) { @@ -232,7 +237,7 @@ export async function saveConstellations(guideId: string, input: unknown) { }); } -export async function saveTeams(guideId: string, input: unknown) { +export async function saveTeams(guideId: string, input: unknown, details?: GuideEventDetails) { const data = teamSectionSchema.parse(input); const [guide] = await getDb().select().from(guides).where(eq(guides.id, guideId)).limit(1); if (!guide) throw new Error("ไม่พบ Guide"); @@ -256,7 +261,7 @@ export async function saveTeams(guideId: string, input: unknown) { } } return getDb().transaction(async (tx) => { - const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "team"); + const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "team", details); 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(); @@ -333,6 +338,7 @@ export async function saveExtraSection( guideId: string, sectionId: string, input: unknown, + details?: GuideEventDetails, ) { const data = saveExtraSectionSchema.parse(input); return getDb().transaction(async (tx) => { @@ -347,7 +353,7 @@ export async function saveExtraSection( .limit(1); if (!target) throw new Error("ไม่พบ Extra section"); - const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, `extra:${sectionId}`); + const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, `extra:${sectionId}`, details); await tx .update(guideSections) .set({ title: data.section.title })