From e01080eb98aeea37bc38485da59d9ab52455ab2a Mon Sep 17 00:00:00 2001 From: Lucky Reddy Date: Wed, 7 Oct 2026 14:21:05 -0500 Subject: [PATCH 1/3] fix: remove a saved draft once its email is sent Sending a saved draft left it in Drafts after the email went out. The send request did not say which draft it came from, and nothing removed drafts, so the person saw an email that looked unsent and could send it twice. The email.send proposal now carries an optional draftId, and the action service removes that draft only after the send succeeds. A declined, expired, failed or uncertain send keeps the draft. Edit details on the review reopens the same draft instead of creating a copy. --- apps/mobile/src/details.tsx | 6 +++++- apps/server/src/actions.ts | 3 +++ packages/domain/src/index.ts | 7 ++++++- tests/actions.test.ts | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 46 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/details.tsx b/apps/mobile/src/details.tsx index 8751ff921..1922f0918 100644 --- a/apps/mobile/src/details.tsx +++ b/apps/mobile/src/details.tsx @@ -255,6 +255,7 @@ function EmailEditor({ draft }: { draft?: Partial & { id?: string } const action = await api.request("/api/actions", { kind: "email.send", data: parsed.data, + draftId: draft?.id, }); await refresh(); open({ type: "review", action }); @@ -571,7 +572,10 @@ function ReviewDetail({ initial }: { initial: ActionProposal }) { try { let next: Detail; if (action.kind === "email.send") - next = { type: "email", draft: emailDraftSchema.parse(action.data) }; + next = { + type: "email", + draft: { ...emailDraftSchema.parse(action.data), id: action.draftId }, + }; else { const draft = eventDraftSchema.parse(action.data); if (action.kind === "calendar.update") { diff --git a/apps/server/src/actions.ts b/apps/server/src/actions.ts index e430a3d10..bbf088981 100644 --- a/apps/server/src/actions.ts +++ b/apps/server/src/actions.ts @@ -66,6 +66,7 @@ export class ActionService { const proposal: ActionProposal = { id, taskId, + draftId: input.kind === "email.send" ? input.draftId : undefined, title, kind: input.kind, data: input.data, @@ -203,6 +204,8 @@ export class ActionService { } await this.db.put(owner, "actions", finished); await this.record(owner, finished, finished.result ?? finished.error ?? finished.status); + if (finished.status === "succeeded" && finished.draftId) + await this.db.remove(owner, "drafts", finished.draftId); return finished; } private async record(owner: string, action: ActionProposal, detail: string) { diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 764ab9c8a..8c9c50d4f 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -126,7 +126,11 @@ export const eventDraftSchema = z } }); export const proposalSchema = z.discriminatedUnion("kind", [ - z.object({ kind: z.literal("email.send"), data: emailDraftSchema }), + z.object({ + kind: z.literal("email.send"), + data: emailDraftSchema, + draftId: z.string().uuid().optional(), + }), z.object({ kind: z.literal("calendar.create"), data: eventDraftSchema }), z.object({ kind: z.literal("calendar.update"), @@ -144,6 +148,7 @@ export interface ActionProposal { target?: CalendarEvent; targetVersion?: string; taskId?: string; + draftId?: string; account?: string; connectionId?: string; id: string; diff --git a/tests/actions.test.ts b/tests/actions.test.ts index e875b0586..6e9c9a513 100644 --- a/tests/actions.test.ts +++ b/tests/actions.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; import { after, before, test } from "node:test"; import { ActionService } from "../apps/server/src/actions.ts"; import { createStore, type Store } from "../apps/server/src/db.ts"; @@ -119,6 +120,37 @@ test("uncertain writes retain uncertainty and cannot be retried", async () => { await service.decide("uncertain-user", proposal.id, proposal.hash, "approve"); assert.equal(calls, 1); }); +async function sendDraft( + owner: string, + decision: "approve" | "deny", + execute: () => Promise, +) { + const service = new ActionService(db, { execute, connected: async () => true }); + const draft = await db.put(owner, "drafts", { ...email.data, id: randomUUID() }); + const proposal = await service.propose(owner, { ...email, draftId: draft.id }); + const result = await service.decide(owner, proposal.id, proposal.hash, decision); + return { status: result.status, draft: await db.get(owner, "drafts", draft.id) }; +} +test("sending a saved draft removes it from drafts", async () => { + const sent = await sendDraft("draft-sent-user", "approve", async () => "sent"); + assert.equal(sent.status, "succeeded"); + assert.equal(sent.draft, null); +}); +test("a draft stays when its email is declined, fails or has an unknown outcome", async () => { + const denied = await sendDraft("draft-denied-user", "deny", async () => "sent"); + assert.equal(denied.status, "denied"); + assert.ok(denied.draft); + const failed = await sendDraft("draft-failed-user", "approve", async () => { + throw new Error("Provider rejected the message"); + }); + assert.equal(failed.status, "failed"); + assert.ok(failed.draft); + const uncertain = await sendDraft("draft-uncertain-user", "approve", async () => { + throw Object.assign(new Error("Provider response lost"), { outcomeUnknown: true }); + }); + assert.equal(uncertain.status, "outcome_unknown"); + assert.ok(uncertain.draft); +}); test("another service instance sees persisted proposals", async () => { const options = { execute: async () => "created", connected: async () => true }; const first = new ActionService(db, options); From a816931f27f34809956428f5458b9d82782bda65 Mon Sep 17 00:00:00 2001 From: Lucky Reddy Date: Fri, 9 Oct 2026 11:21:12 -0500 Subject: [PATCH 2/3] fix: keep drafts safe while their review is pending A draft now records its pending review. Starting a new review or saving the draft again cancels the older review, so one draft has at most one pending send and older text cannot be sent over a rewrite. A send removes the draft only if it still points to that review, and a failed cleanup is logged instead of reported as a failed send. propose rejects a draft that does not exist for the owner, and Edit details opens the editor only when its own tap declined the review. --- apps/mobile/src/details.tsx | 7 +++- apps/server/src/actions.ts | 41 +++++++++++++++++- apps/server/src/app.ts | 15 ++++--- apps/server/src/db.ts | 11 +++++ packages/domain/src/index.ts | 2 +- tests/actions.test.ts | 81 ++++++++++++++++++++++++++++++++++++ tests/api.test.ts | 20 +++++++++ 7 files changed, 165 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/details.tsx b/apps/mobile/src/details.tsx index 1922f0918..65ea9e9bc 100644 --- a/apps/mobile/src/details.tsx +++ b/apps/mobile/src/details.tsx @@ -585,11 +585,16 @@ function ReviewDetail({ initial }: { initial: ActionProposal }) { next = { type: "event", event: { ...draft, id: eventId } }; } else next = { type: "event", draft }; } - await api.request(`/api/actions/${action.id}/decide`, { + const result = await api.request(`/api/actions/${action.id}/decide`, { decision: "deny", hash: action.hash, }); await refresh(); + // A review already replaced or sent elsewhere shows its outcome instead of an editor. + if (result.status !== "denied") { + setLocal(result); + return; + } open(next); } catch (e) { setError(e instanceof Error ? e.message : String(e)); diff --git a/apps/server/src/actions.ts b/apps/server/src/actions.ts index bbf088981..1c667fbdb 100644 --- a/apps/server/src/actions.ts +++ b/apps/server/src/actions.ts @@ -7,6 +7,7 @@ import { } from "../../../packages/domain/src/index.ts"; import type { Store } from "./db.ts"; import { AppError } from "./errors.ts"; +import { backgroundFailure } from "./log.ts"; interface Options { execute: ( @@ -51,6 +52,12 @@ export class ActionService { if (existing) return existing; } const parsed = proposalSchema.parse(raw); + if ( + parsed.kind === "email.send" && + parsed.draftId && + !(await this.db.get(owner, "drafts", parsed.draftId)) + ) + throw new AppError("Draft not found", 404); const connection = await this.options.connection?.(owner); if (this.options.connection && !connection) throw new AppError("Connect Google before preparing an action", 409); @@ -97,9 +104,32 @@ export class ActionService { if (!existing) throw new AppError("Prepared action could not be loaded", 409); return existing; } + if (saved.draftId) { + await this.db.compareAndSwap(owner, "drafts", saved.draftId, {}, { actionId: saved.id }); + await this.cancelDraftReviews(owner, saved.draftId, saved.id); + } await this.record(owner, saved, "Ready for your review"); return saved; } + /** Leaves at most one pending send per draft, so one draft cannot be sent twice. */ + async cancelDraftReviews(owner: string, draftId: string, keepId?: string) { + for (const action of await this.db.listByStatus( + owner, + "actions", + "awaiting_review", + )) { + if (action.draftId !== draftId || action.id === keepId) continue; + const cancelled = await this.db.compareAndSwap( + owner, + "actions", + action.id, + { status: "awaiting_review" }, + { status: "cancelled" }, + ); + if (cancelled) + await this.record(owner, cancelled, "Replaced by a newer version of the draft"); + } + } async decide( owner: string, id: string, @@ -204,8 +234,15 @@ export class ActionService { } await this.db.put(owner, "actions", finished); await this.record(owner, finished, finished.result ?? finished.error ?? finished.status); - if (finished.status === "succeeded" && finished.draftId) - await this.db.remove(owner, "drafts", finished.draftId); + if (finished.status === "succeeded" && finished.draftId) { + // The email has already gone out, so a cleanup failure must not read as a failed send. + // A draft saved again after this review no longer links to it and is kept. + try { + await this.db.removeIf(owner, "drafts", finished.draftId, { actionId: finished.id }); + } catch (error) { + backgroundFailure("sent draft cleanup", error); + } + } return finished; } private async record(owner: string, action: ActionProposal, detail: string) { diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 258d7a22b..4443d5a8a 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -196,14 +196,13 @@ export async function createApp( ? await db.get<{ createdAt: string }>(c.get("owner"), "drafts", body.id) : null; if (body.id && !existing) throw new AppError("Draft not found", 404); - return c.json( - await db.put(c.get("owner"), "drafts", { - ...body, - id: body.id ?? randomUUID(), - createdAt: existing?.createdAt ?? new Date().toISOString(), - }), - 201, - ); + const saved = await db.put(c.get("owner"), "drafts", { + ...body, + id: body.id ?? randomUUID(), + createdAt: existing?.createdAt ?? new Date().toISOString(), + }); + if (existing) await actions.cancelDraftReviews(c.get("owner"), saved.id); + return c.json(saved, 201); }); app.get("/api/main-thread", async (c) => { const owner = c.get("owner"); diff --git a/apps/server/src/db.ts b/apps/server/src/db.ts index 4f057b6c9..63b624b6b 100644 --- a/apps/server/src/db.ts +++ b/apps/server/src/db.ts @@ -66,6 +66,17 @@ export class Store { id, ]); } + async removeIf( + owner: string, + kind: string, + id: string, + expected: Record, + ): Promise { + await this.db.query( + "DELETE FROM records WHERE owner=$1 AND kind=$2 AND id=$3 AND data @> $4::jsonb", + [owner, kind, id, JSON.stringify(expected)], + ); + } async compareAndSwap( owner: string, kind: string, diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 8c9c50d4f..1eb21d843 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -129,7 +129,7 @@ export const proposalSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("email.send"), data: emailDraftSchema, - draftId: z.string().uuid().optional(), + draftId: z.uuid().optional(), }), z.object({ kind: z.literal("calendar.create"), data: eventDraftSchema }), z.object({ diff --git a/tests/actions.test.ts b/tests/actions.test.ts index 6e9c9a513..aeb702b98 100644 --- a/tests/actions.test.ts +++ b/tests/actions.test.ts @@ -151,6 +151,87 @@ test("a draft stays when its email is declined, fails or has an unknown outcome" assert.equal(uncertain.status, "outcome_unknown"); assert.ok(uncertain.draft); }); +test("an expired review keeps its draft", async () => { + let now = Date.now(); + const service = new ActionService(db, { + execute: async () => "sent", + connected: async () => true, + now: () => now, + }); + const draft = await db.put("draft-expired-user", "drafts", { ...email.data, id: randomUUID() }); + const proposal = await service.propose("draft-expired-user", { ...email, draftId: draft.id }); + now += 31 * 60 * 1000; + await assert.rejects( + service.decide("draft-expired-user", proposal.id, proposal.hash, "approve"), + /expired/i, + ); + assert.ok(await db.get("draft-expired-user", "drafts", draft.id)); +}); +test("a draft rewritten after its review started is kept when that review is approved", async () => { + const service = new ActionService(db, { + execute: async () => "sent", + connected: async () => true, + }); + const id = randomUUID(); + await db.put("draft-rewrite-user", "drafts", { ...email.data, id }); + const proposal = await service.propose("draft-rewrite-user", { ...email, draftId: id }); + await db.put("draft-rewrite-user", "drafts", { ...email.data, id, body: "Rewritten" }); + await service.decide("draft-rewrite-user", proposal.id, proposal.hash, "approve"); + const kept = await db.get<{ body: string }>("draft-rewrite-user", "drafts", id); + assert.equal(kept?.body, "Rewritten"); +}); +test("a newer review of a draft replaces the older one, so the email is sent once", async () => { + let sends = 0; + const service = new ActionService(db, { + execute: async () => { + sends++; + return "sent"; + }, + connected: async () => true, + }); + const draft = await db.put("draft-twice-user", "drafts", { ...email.data, id: randomUUID() }); + const first = await service.propose("draft-twice-user", { ...email, draftId: draft.id }); + const second = await service.propose("draft-twice-user", { ...email, draftId: draft.id }); + const stale = await service.decide("draft-twice-user", first.id, first.hash, "approve"); + assert.equal(stale.status, "cancelled"); + const sent = await service.decide("draft-twice-user", second.id, second.hash, "approve"); + assert.equal(sent.status, "succeeded"); + assert.equal(sends, 1); + assert.equal(await db.get("draft-twice-user", "drafts", draft.id), null); +}); +test("a failed draft cleanup still reports the email as sent", async (t) => { + const store = await createStore(); + t.after(() => store.close()); + const service = new ActionService(store, { + execute: async () => "sent", + connected: async () => true, + }); + const draft = await store.put("cleanup-user", "drafts", { ...email.data, id: randomUUID() }); + const proposal = await service.propose("cleanup-user", { ...email, draftId: draft.id }); + const unavailable = async () => { + throw new Error("Database briefly unavailable"); + }; + Object.assign(store, { remove: unavailable, removeIf: unavailable }); + const logged = t.mock.method(console, "error", () => {}); + const result = await service.decide("cleanup-user", proposal.id, proposal.hash, "approve"); + assert.equal(result.status, "succeeded"); + assert.equal(logged.mock.callCount(), 1); +}); +test("a review only links a draft that exists for the same owner", async () => { + const service = new ActionService(db, { + execute: async () => "sent", + connected: async () => true, + }); + const draft = await db.put("draft-owner", "drafts", { ...email.data, id: randomUUID() }); + await assert.rejects( + service.propose("draft-other-owner", { ...email, draftId: draft.id }), + /Draft not found/, + ); + await assert.rejects( + service.propose("draft-owner", { ...email, draftId: randomUUID() }), + /Draft not found/, + ); +}); test("another service instance sees persisted proposals", async () => { const options = { execute: async () => "created", connected: async () => true }; const first = new ActionService(db, options); diff --git a/tests/api.test.ts b/tests/api.test.ts index 6ea94208b..4e1278373 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -212,6 +212,26 @@ test("sample workspace serves a real PDF and filling creates a new version", asy forged.searchParams.set("owner", "another-user"); assert.equal((await app.request(forged.toString())).status, 403); }); +test("saving a draft again cancels its pending review, so the older text is never sent", async () => { + const post = (path: string, body: object) => + app.request(path, { method: "POST", headers: headers(), body: JSON.stringify(body) }); + const message = { to: ["sample@example.com"], subject: "Trip", body: "First version" }; + const draft: { id: string } = await (await post("/api/drafts", message)).json(); + const review: ActionProposal = await ( + await post("/api/actions", { kind: "email.send", data: message, draftId: draft.id }) + ).json(); + assert.equal(review.status, "awaiting_review"); + const resaved = await post("/api/drafts", { ...message, id: draft.id, body: "Second version" }); + assert.equal(resaved.status, 201); + const decided: ActionProposal = await ( + await post(`/api/actions/${review.id}/decide`, { hash: review.hash, decision: "approve" }) + ).json(); + assert.equal(decided.status, "cancelled"); + const drafts: { id: string; body: string }[] = await ( + await app.request("/api/drafts", { headers: headers() }) + ).json(); + assert.equal(drafts.find((d) => d.id === draft.id)?.body, "Second version"); +}); test("reviewed sample email persists a receipt, then revocation blocks another proposal", async () => { const propose = () => app.request("/api/actions", { From 092c0045375eb57c77517b83f45df44d6150bcbe Mon Sep 17 00:00:00 2001 From: Lucky Reddy Date: Thu, 29 Oct 2026 11:01:38 -0500 Subject: [PATCH 3/3] fix: link a draft's review only while the draft is unchanged A save, or a second review, that landed while a review was still being prepared could be replaced by the older review: propose linked the review to whatever version of the draft existed when preparation finished, so approving it sent the older text and deleted the newer draft, and two reviews started together cancelled each other. Every save now gives the draft a new revision, and linking a review bumps it too. propose reads the revision before preparing and links the review only if the draft still has it; otherwise the new review is cancelled and the request returns 409. Drafts saved before this change get a revision the first time they are reviewed. --- apps/server/src/actions.ts | 74 +++++++++++++++++++++++++++---------- apps/server/src/app.ts | 1 + tests/actions.test.ts | 75 ++++++++++++++++++++++++++++++++++++++ tests/api.test.ts | 12 ++++++ 4 files changed, 143 insertions(+), 19 deletions(-) diff --git a/apps/server/src/actions.ts b/apps/server/src/actions.ts index 1c667fbdb..55bfd27f4 100644 --- a/apps/server/src/actions.ts +++ b/apps/server/src/actions.ts @@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto"; import { type ActionProposal, type CalendarEvent, + emailDraftSchema, type ProposalInput, proposalSchema, } from "../../../packages/domain/src/index.ts"; @@ -29,6 +30,12 @@ interface Options { connection?: (owner: string) => Promise<{ id: string; account: string } | null>; now?: () => number; } +const draftChanged = + "This draft changed while its review was being prepared. Open it again to review the latest version."; +const draftFields = Object.keys(emailDraftSchema.shape); +function sameDraft(a: Record, b: Record) { + return draftFields.every((key) => JSON.stringify(a[key]) === JSON.stringify(b[key])); +} export class ActionService { private readonly now: () => number; constructor( @@ -52,12 +59,10 @@ export class ActionService { if (existing) return existing; } const parsed = proposalSchema.parse(raw); - if ( - parsed.kind === "email.send" && - parsed.draftId && - !(await this.db.get(owner, "drafts", parsed.draftId)) - ) - throw new AppError("Draft not found", 404); + const draft = + parsed.kind === "email.send" && parsed.draftId + ? { id: parsed.draftId, revision: await this.draftRevision(owner, parsed.draftId) } + : undefined; const connection = await this.options.connection?.(owner); if (this.options.connection && !connection) throw new AppError("Connect Google before preparing an action", 409); @@ -73,7 +78,7 @@ export class ActionService { const proposal: ActionProposal = { id, taskId, - draftId: input.kind === "email.send" ? input.draftId : undefined, + draftId: draft?.id, title, kind: input.kind, data: input.data, @@ -104,9 +109,22 @@ export class ActionService { if (!existing) throw new AppError("Prepared action could not be loaded", 409); return existing; } - if (saved.draftId) { - await this.db.compareAndSwap(owner, "drafts", saved.draftId, {}, { actionId: saved.id }); - await this.cancelDraftReviews(owner, saved.draftId, saved.id); + if (draft) { + // A save, or another review, while this one was being prepared gave the draft a new + // revision. Linking now would let this older review replace, and then delete, the newer + // draft, or leave two reviews that cancel each other. + const linked = await this.db.compareAndSwap( + owner, + "drafts", + draft.id, + { revision: draft.revision }, + { actionId: saved.id, revision: randomUUID() }, + ); + if (!linked) { + await this.cancelReview(owner, saved.id, "The draft changed before this review was ready"); + throw new AppError(draftChanged, 409); + } + await this.cancelDraftReviews(owner, draft.id, saved.id); } await this.record(owner, saved, "Ready for your review"); return saved; @@ -119,17 +137,35 @@ export class ActionService { "awaiting_review", )) { if (action.draftId !== draftId || action.id === keepId) continue; - const cancelled = await this.db.compareAndSwap( - owner, - "actions", - action.id, - { status: "awaiting_review" }, - { status: "cancelled" }, - ); - if (cancelled) - await this.record(owner, cancelled, "Replaced by a newer version of the draft"); + await this.cancelReview(owner, action.id, "Replaced by a newer version of the draft"); } } + private async cancelReview(owner: string, id: string, detail: string) { + const cancelled = await this.db.compareAndSwap( + owner, + "actions", + id, + { status: "awaiting_review" }, + { status: "cancelled" }, + ); + if (cancelled) await this.record(owner, cancelled, detail); + } + /** Drafts saved before revisions existed get one here, unless the draft changes meanwhile. */ + private async draftRevision(owner: string, draftId: string): Promise { + const draft = await this.db.get>(owner, "drafts", draftId); + if (!draft) throw new AppError("Draft not found", 404); + if (typeof draft.revision === "string") return draft.revision; + const revision = randomUUID(); + const stamped = await this.db.compareAndSwap>( + owner, + "drafts", + draftId, + {}, + { revision }, + ); + if (!stamped || !sameDraft(stamped, draft)) throw new AppError(draftChanged, 409); + return revision; + } async decide( owner: string, id: string, diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 4443d5a8a..8e3ac3d32 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -200,6 +200,7 @@ export async function createApp( ...body, id: body.id ?? randomUUID(), createdAt: existing?.createdAt ?? new Date().toISOString(), + revision: randomUUID(), }); if (existing) await actions.cancelDraftReviews(c.get("owner"), saved.id); return c.json(saved, 201); diff --git a/tests/actions.test.ts b/tests/actions.test.ts index aeb702b98..f1766a1b0 100644 --- a/tests/actions.test.ts +++ b/tests/actions.test.ts @@ -199,6 +199,81 @@ test("a newer review of a draft replaces the older one, so the email is sent onc assert.equal(sends, 1); assert.equal(await db.get("draft-twice-user", "drafts", draft.id), null); }); +test("saving a draft while its review is being prepared keeps the newer draft", async () => { + const owner = "draft-race-user"; + const paused = deferred(); + const resume = deferred(); + let sends = 0; + const service = new ActionService(db, { + execute: async () => { + sends++; + return "sent"; + }, + connected: async () => true, + connection: async () => { + paused.resolve(); + await resume.promise; + return { id: "race-connection", account: "me@example.com" }; + }, + }); + const id = randomUUID(); + await db.put(owner, "drafts", { ...email.data, id, revision: randomUUID() }); + const review = service.propose(owner, { ...email, draftId: id }); + await paused.promise; + // What POST /api/drafts does when a draft is saved again. + await db.put(owner, "drafts", { ...email.data, id, body: "Rewritten", revision: randomUUID() }); + await service.cancelDraftReviews(owner, id); + resume.resolve(); + await assert.rejects(review, /draft changed/); + const [action] = (await db.list(owner, "actions")).filter( + (a) => a.draftId === id, + ); + assert.equal(action?.status, "cancelled"); + const kept = await db.get<{ body: string; actionId?: string }>(owner, "drafts", id); + assert.equal(kept?.body, "Rewritten"); + assert.equal(kept?.actionId, undefined); + assert.equal(sends, 0); +}); +test("two reviews of one draft started together leave exactly one to approve", async () => { + const owner = "draft-double-user"; + const gates = [deferred(), deferred()]; + const arrived = [deferred(), deferred()]; + let calls = 0; + const service = new ActionService(db, { + execute: async () => "sent", + connected: async () => true, + connection: async () => { + const call = calls++; + arrived[call]?.resolve(); + await gates[call]?.promise; + return { id: "double-connection", account: "me@example.com" }; + }, + }); + const id = randomUUID(); + await db.put(owner, "drafts", { ...email.data, id, revision: randomUUID() }); + const reviews = [1, 2].map(() => + service.propose(owner, { ...email, draftId: id }).then( + (action) => action, + (error: Error) => error, + ), + ); + await Promise.all(arrived.map((a) => a.promise)); + for (const gate of gates) gate.resolve(); + const results = await Promise.all(reviews); + const opened = results.filter((r): r is ActionProposal => !(r instanceof Error)); + const refused = results.filter((r) => r instanceof Error); + assert.equal(opened.length, 1); + assert.match(String(refused[0]), /draft changed/); + const pending = (await db.list(owner, "actions")).filter( + (a) => a.draftId === id && a.status === "awaiting_review", + ); + assert.deepEqual( + pending.map((a) => a.id), + [opened[0]?.id], + ); + const draft = await db.get<{ actionId?: string }>(owner, "drafts", id); + assert.equal(draft?.actionId, opened[0]?.id); +}); test("a failed draft cleanup still reports the email as sent", async (t) => { const store = await createStore(); t.after(() => store.close()); diff --git a/tests/api.test.ts b/tests/api.test.ts index 4e1278373..28f420e03 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -232,6 +232,18 @@ test("saving a draft again cancels its pending review, so the older text is neve ).json(); assert.equal(drafts.find((d) => d.id === draft.id)?.body, "Second version"); }); +test("every save gives a draft a new revision", async () => { + const post = (path: string, body: object) => + app.request(path, { method: "POST", headers: headers(), body: JSON.stringify(body) }); + const message = { to: ["sample@example.com"], subject: "Plans", body: "First version" }; + const first: { id: string; revision: string } = await (await post("/api/drafts", message)).json(); + const second: { id: string; revision: string } = await ( + await post("/api/drafts", { ...message, id: first.id, body: "Second version" }) + ).json(); + assert.equal(second.id, first.id); + assert.ok(first.revision); + assert.notEqual(second.revision, first.revision); +}); test("reviewed sample email persists a receipt, then revocation blocks another proposal", async () => { const propose = () => app.request("/api/actions", {