Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions apps/mobile/src/details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ function EmailEditor({ draft }: { draft?: Partial<EmailDraft> & { id?: string }
const action = await api.request<ActionProposal>("/api/actions", {
kind: "email.send",
data: parsed.data,
draftId: draft?.id,
});
await refresh();
open({ type: "review", action });
Expand Down Expand Up @@ -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") {
Expand All @@ -581,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<ActionProposal>(`/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));
Expand Down
76 changes: 76 additions & 0 deletions apps/server/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import { createHash, randomUUID } from "node:crypto";
import {
type ActionProposal,
type CalendarEvent,
emailDraftSchema,
type ProposalInput,
proposalSchema,
} 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: (
Expand All @@ -28,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<string, unknown>, b: Record<string, unknown>) {
return draftFields.every((key) => JSON.stringify(a[key]) === JSON.stringify(b[key]));
}
export class ActionService {
private readonly now: () => number;
constructor(
Expand All @@ -51,6 +59,10 @@ export class ActionService {
if (existing) return existing;
}
const parsed = proposalSchema.parse(raw);
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);
Expand All @@ -66,6 +78,7 @@ export class ActionService {
const proposal: ActionProposal = {
id,
taskId,
draftId: draft?.id,
title,
kind: input.kind,
data: input.data,
Expand Down Expand Up @@ -96,9 +109,63 @@ export class ActionService {
if (!existing) throw new AppError("Prepared action could not be loaded", 409);
return existing;
}
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;
}
/** 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<ActionProposal>(
owner,
"actions",
"awaiting_review",
)) {
if (action.draftId !== draftId || action.id === keepId) continue;
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<ActionProposal>(
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<string> {
const draft = await this.db.get<Record<string, unknown>>(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<Record<string, unknown>>(
owner,
"drafts",
draftId,
{},
{ revision },
);
if (!stamped || !sameDraft(stamped, draft)) throw new AppError(draftChanged, 409);
return revision;
}
async decide(
owner: string,
id: string,
Expand Down Expand Up @@ -203,6 +270,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) {
// 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) {
Expand Down
16 changes: 8 additions & 8 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,14 +196,14 @@ 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(),
revision: randomUUID(),
});
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");
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,17 @@ export class Store {
id,
]);
}
async removeIf(
owner: string,
kind: string,
id: string,
expected: Record<string, unknown>,
): Promise<void> {
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<T>(
owner: string,
kind: string,
Expand Down
7 changes: 6 additions & 1 deletion packages/domain/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.uuid().optional(),
}),
z.object({ kind: z.literal("calendar.create"), data: eventDraftSchema }),
z.object({
kind: z.literal("calendar.update"),
Expand All @@ -144,6 +148,7 @@ export interface ActionProposal {
target?: CalendarEvent;
targetVersion?: string;
taskId?: string;
draftId?: string;
account?: string;
connectionId?: string;
id: string;
Expand Down
Loading
Loading