feat(editor) : notify peers of draft overwrite
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user