feat(editor) : notify peers of draft overwrite
CI / Verify (push) Successful in 1m20s
CI / Build immutable images and deploy (push) Successful in 1m51s

This commit is contained in:
2026-09-15 19:46:40 +07:00 Unverified
parent fb152c12fe
commit c3a2aa88d6
7 changed files with 121 additions and 50 deletions
+25 -17
View File
@@ -43,6 +43,13 @@ type GuideActionResult =
| { status: "error"; message: string };
type CreateGuideActionResult = Exclude<GuideActionResult, { status: "conflict" }>;
type SaveIntent = { overwrite?: boolean };
function overwriteDetails(admin: Awaited<ReturnType<typeof requireAdmin>>, 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<GuideActionResult> {
try {
@@ -69,29 +76,29 @@ export async function createStructuredGuide(input: unknown): Promise<CreateGuide
}
}
export async function saveWeaponSection(guideId: string, input: unknown) {
await requireAdmin();
return runGuideAction(() => 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;
+2 -2
View File
@@ -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) => {
+10 -10
View File
@@ -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) =>
+21 -5
View File
@@ -16,6 +16,10 @@ interface SaveResult {
currentVersion?: number;
}
interface SaveIntent {
overwrite?: boolean;
}
export function useAutosave<Value, Draft = Value>({
value,
valid,
@@ -32,7 +36,7 @@ export function useAutosave<Value, Draft = Value>({
value: Value;
valid: boolean;
initialVersion: number;
persist: (value: Value, expectedVersion: number) => Promise<SaveResult>;
persist: (value: Value, expectedVersion: number, intent?: SaveIntent) => Promise<SaveResult>;
delay?: number;
storageKey?: string;
localDraft?: Draft;
@@ -52,6 +56,7 @@ export function useAutosave<Value, Draft = Value>({
const [hydrated, setHydrated] = useState(!storageKey);
const [manualRequest, setManualRequest] = useState(0);
const [conflictVersion, setConflictVersion] = useState<number | null>(null);
const [conflictActor, setConflictActor] = useState<string | null>(null);
const [overwriteRequest, setOverwriteRequest] = useState<number | null>(null);
const handledManualRequest = useRef(0);
const storageWarningShown = useRef(false);
@@ -125,14 +130,15 @@ export function useAutosave<Value, Draft = Value>({
}
}, [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<Value, Draft = Value>({
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<Value, Draft = Value>({
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<Value, Draft = Value>({
}
toast.error("Autosave ถูกหยุดชั่วคราว", {
id: toastId,
description: "มีผู้แก้ไขคนอื่นบันทึกข้อมูลส่วนนี้ กรุณาเลือกโหลดข้อมูลล่าสุดหรือเขียนทับด้วย Draft นี้",
description: conflictActor
? `${conflictActor} เขียนทับข้อมูลส่วนนี้ แต่ Draft ของคุณยังถูกเก็บไว้`
: "มีผู้แก้ไขคนอื่นบันทึกข้อมูลส่วนนี้ กรุณาเลือกโหลดข้อมูลล่าสุดหรือเขียนทับด้วย Draft นี้",
duration: Infinity,
action: {
label: "ดูตัวเลือก",
@@ -197,7 +212,7 @@ export function useAutosave<Value, Draft = Value>({
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<Value, Draft = Value>({
fingerprint,
draftFingerprint,
overwriteRequest,
true,
).finally(() => setOverwriteRequest(null));
}, 0);
return () => window.clearTimeout(timer);
+23
View File
@@ -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 });
});
});
+19 -1
View File
@@ -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<string, unknown>) {
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;
}
+21 -15
View File
@@ -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<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0], guideId: string, version: number, directoryChanged = false, scope?: AdminEditorScope) {
async function emitGuideEvents(tx: Parameters<Parameters<ReturnType<typeof getDb>["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<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0], guideId: string, expectedVersion: number, directoryChanged = false, scope?: AdminEditorScope) {
async function advanceGuideVersion(tx: Parameters<Parameters<ReturnType<typeof getDb>["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 })