@remit/backend 0.0.91 → 0.0.92

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/backend",
3
- "version": "0.0.91",
3
+ "version": "0.0.92",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -0,0 +1,279 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import type {
4
+ CalendarSuggestionItem,
5
+ CreateFilterInput,
6
+ FilterItem,
7
+ ICalendarSuggestionRepository,
8
+ MessageData,
9
+ ResultList,
10
+ SettleCalendarSuggestionInput,
11
+ } from "@remit/data-ports";
12
+ import {
13
+ CalendarInviteMethod,
14
+ CalendarSuggestionSource,
15
+ CalendarSuggestionState,
16
+ } from "@remit/domain-enums";
17
+ import {
18
+ assertSettleable,
19
+ type MuteSenderDeps,
20
+ muteSender,
21
+ settleSuggestion,
22
+ toCalendarSuggestionResponse,
23
+ } from "./calendar-suggestion.js";
24
+
25
+ const ACCOUNT_CONFIG_ID = "cfg-1";
26
+
27
+ const suggestion = (
28
+ overrides: Partial<CalendarSuggestionItem> = {},
29
+ ): CalendarSuggestionItem => ({
30
+ suggestionId: "sug-1",
31
+ accountConfigId: ACCOUNT_CONFIG_ID,
32
+ messageId: "msg-1",
33
+ bodyPartId: "part-1",
34
+ icalUid: "invite@example.test",
35
+ sequence: 0,
36
+ method: CalendarInviteMethod.Request,
37
+ source: CalendarSuggestionSource.IcalendarPart,
38
+ state: CalendarSuggestionState.Pending,
39
+ summary: "Quarterly review",
40
+ dtStart: "2026-09-01T10:00:00+02:00",
41
+ dtEnd: "2026-09-01T11:00:00+02:00",
42
+ allDay: false,
43
+ location: "Room 4",
44
+ organizer: "organizer@example.test",
45
+ zoneCertainty: "Explicit",
46
+ icalData: "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n",
47
+ acceptedCalendarObjectId: "",
48
+ createdAt: 1,
49
+ updatedAt: 1,
50
+ ...overrides,
51
+ });
52
+
53
+ const repoOf = (
54
+ initial: CalendarSuggestionItem,
55
+ ): {
56
+ repo: ICalendarSuggestionRepository;
57
+ settles: SettleCalendarSuggestionInput[];
58
+ } => {
59
+ let row = initial;
60
+ const settles: SettleCalendarSuggestionInput[] = [];
61
+ const repo = {
62
+ get: async () => row,
63
+ settle: async (
64
+ _accountConfigId: string,
65
+ _suggestionId: string,
66
+ input: SettleCalendarSuggestionInput,
67
+ ) => {
68
+ settles.push(input);
69
+ row = { ...row, ...input };
70
+ return row;
71
+ },
72
+ } as unknown as ICalendarSuggestionRepository;
73
+ return { repo, settles };
74
+ };
75
+
76
+ const muteDepsOf = (
77
+ from: string | null,
78
+ existing: FilterItem[] = [],
79
+ ): { deps: MuteSenderDeps; created: CreateFilterInput[] } => {
80
+ const created: CreateFilterInput[] = [];
81
+ const rules = [...existing];
82
+ const deps: MuteSenderDeps = {
83
+ envelope: {
84
+ getMessageData: async () =>
85
+ ({
86
+ envelopeAddress: from
87
+ ? [{ addressRole: "from", normalizedEmail: from }]
88
+ : [{ addressRole: "to", normalizedEmail: "user@example.test" }],
89
+ }) as unknown as MessageData,
90
+ },
91
+ filter: {
92
+ listByAccountAndState: async () => rules,
93
+ create: async (input: CreateFilterInput) => {
94
+ created.push(input);
95
+ const row = {
96
+ ...input,
97
+ actionLabelId: input.actionLabelId ?? "None",
98
+ actionMailboxId: input.actionMailboxId ?? "None",
99
+ } as unknown as FilterItem;
100
+ rules.push(row);
101
+ return row;
102
+ },
103
+ },
104
+ };
105
+ return { deps, created };
106
+ };
107
+
108
+ const muteRule = (sender: string): FilterItem =>
109
+ ({
110
+ filterId: `mute-${sender}`,
111
+ accountConfigId: ACCOUNT_CONFIG_ID,
112
+ name: `Muted invitations from ${sender}`,
113
+ scope: "Standing",
114
+ state: "Active",
115
+ matchOperator: "And",
116
+ literalClauses: [{ field: "From", value: sender }],
117
+ actionLabelId: "None",
118
+ actionMailboxId: "None",
119
+ }) as unknown as FilterItem;
120
+
121
+ describe("toCalendarSuggestionResponse", () => {
122
+ test("keeps the raw invitation bytes on the server", async () => {
123
+ const response = toCalendarSuggestionResponse(suggestion());
124
+
125
+ assert.equal("icalData" in response, false);
126
+ assert.equal(response.summary, "Quarterly review");
127
+ assert.equal(response.organizer, "organizer@example.test");
128
+ });
129
+ });
130
+
131
+ describe("assertSettleable", () => {
132
+ test("lets a pending card be answered", () => {
133
+ assertSettleable(suggestion(), CalendarSuggestionState.Accepted);
134
+ });
135
+
136
+ test("refuses to accept an event a revision already retired", () => {
137
+ assert.throws(
138
+ () =>
139
+ assertSettleable(
140
+ suggestion({ state: CalendarSuggestionState.Superseded }),
141
+ CalendarSuggestionState.Accepted,
142
+ ),
143
+ /already superseded/,
144
+ );
145
+ });
146
+
147
+ test("refuses to decline an event that is already in the calendar", () => {
148
+ // A resource exists. Declining would say no to a meeting the user's
149
+ // calendar still shows, which is a lie the API must not tell.
150
+ assert.throws(
151
+ () =>
152
+ assertSettleable(
153
+ suggestion({ state: CalendarSuggestionState.Accepted }),
154
+ CalendarSuggestionState.Declined,
155
+ ),
156
+ /already accepted/,
157
+ );
158
+ });
159
+
160
+ test("lets a repeat of the same answer through", () => {
161
+ assertSettleable(
162
+ suggestion({ state: CalendarSuggestionState.Declined }),
163
+ CalendarSuggestionState.Declined,
164
+ );
165
+ });
166
+ });
167
+
168
+ describe("settleSuggestion", () => {
169
+ test("records a decline", async () => {
170
+ const { repo, settles } = repoOf(suggestion());
171
+
172
+ const settled = await settleSuggestion(
173
+ repo,
174
+ ACCOUNT_CONFIG_ID,
175
+ "sug-1",
176
+ CalendarSuggestionState.Declined,
177
+ );
178
+
179
+ assert.equal(settled.state, CalendarSuggestionState.Declined);
180
+ assert.deepEqual(settles, [
181
+ {
182
+ state: CalendarSuggestionState.Declined,
183
+ acceptedCalendarObjectId: "",
184
+ },
185
+ ]);
186
+ });
187
+
188
+ test("writes nothing on a repeated decline", async () => {
189
+ const { repo, settles } = repoOf(
190
+ suggestion({ state: CalendarSuggestionState.Declined }),
191
+ );
192
+
193
+ const settled = await settleSuggestion(
194
+ repo,
195
+ ACCOUNT_CONFIG_ID,
196
+ "sug-1",
197
+ CalendarSuggestionState.Declined,
198
+ );
199
+
200
+ assert.equal(settled.state, CalendarSuggestionState.Declined);
201
+ assert.deepEqual(settles, []);
202
+ });
203
+
204
+ test("never names a calendar object on a decision that wrote none", async () => {
205
+ // Dismiss and decline write no resource, so the field that points at one
206
+ // stays the empty sentinel rather than carrying a stale id.
207
+ const { repo, settles } = repoOf(suggestion());
208
+
209
+ await settleSuggestion(
210
+ repo,
211
+ ACCOUNT_CONFIG_ID,
212
+ "sug-1",
213
+ CalendarSuggestionState.Dismissed,
214
+ );
215
+
216
+ assert.deepEqual(
217
+ settles.map((settle) => settle.acceptedCalendarObjectId),
218
+ [""],
219
+ );
220
+ });
221
+ });
222
+
223
+ describe("muteSender", () => {
224
+ test("writes a standing rule on the message's sender", async () => {
225
+ const { deps, created } = muteDepsOf("organizer@example.test");
226
+
227
+ await muteSender(deps, ACCOUNT_CONFIG_ID, "msg-1");
228
+
229
+ assert.equal(created.length, 1);
230
+ assert.equal(created[0]?.accountConfigId, ACCOUNT_CONFIG_ID);
231
+ assert.equal(created[0]?.scope, "Standing");
232
+ assert.deepEqual(created[0]?.literalClauses, [
233
+ { field: "From", value: "organizer@example.test" },
234
+ ]);
235
+ assert.match(created[0]?.name ?? "", /organizer@example\.test/);
236
+ });
237
+
238
+ test("writes one rule however often the dismiss is retried", async () => {
239
+ // A retried dismiss is the same instruction repeated. A second identical
240
+ // rule would only be a second row for the user to find and delete twice.
241
+ const { deps, created } = muteDepsOf("organizer@example.test");
242
+
243
+ await muteSender(deps, ACCOUNT_CONFIG_ID, "msg-1");
244
+ await muteSender(deps, ACCOUNT_CONFIG_ID, "msg-1");
245
+ await muteSender(deps, ACCOUNT_CONFIG_ID, "msg-2");
246
+
247
+ assert.equal(created.length, 1);
248
+ });
249
+
250
+ test("adds nothing when the sender is already muted from another card", async () => {
251
+ const { deps, created } = muteDepsOf("organizer@example.test", [
252
+ muteRule("Organizer@Example.test"),
253
+ ]);
254
+
255
+ await muteSender(deps, ACCOUNT_CONFIG_ID, "msg-1");
256
+
257
+ assert.deepEqual(created, []);
258
+ });
259
+
260
+ test("still writes a rule when the existing one names a different sender", async () => {
261
+ const { deps, created } = muteDepsOf("organizer@example.test", [
262
+ muteRule("someone-else@example.test"),
263
+ ]);
264
+
265
+ await muteSender(deps, ACCOUNT_CONFIG_ID, "msg-1");
266
+
267
+ assert.equal(created.length, 1);
268
+ });
269
+
270
+ test("refuses to mute a message that names no sender", async () => {
271
+ const { deps, created } = muteDepsOf(null);
272
+
273
+ await assert.rejects(
274
+ () => muteSender(deps, ACCOUNT_CONFIG_ID, "msg-1"),
275
+ /nobody to mute/,
276
+ );
277
+ assert.deepEqual(created, []);
278
+ });
279
+ });
@@ -0,0 +1,289 @@
1
+ import type { CalendarSuggestionResponse } from "@remit/api-openapi-types";
2
+ import { acceptCalendarSuggestion } from "@remit/calendar-service";
3
+ import {
4
+ type CalendarSuggestionItem,
5
+ type ICalendarSuggestionRepository,
6
+ type IEnvelopeRepository,
7
+ type IFilterRepository,
8
+ isSenderMuted,
9
+ } from "@remit/data-ports";
10
+ import { BadRequestError } from "@remit/data-ports/errors";
11
+ import {
12
+ CalendarSuggestionState,
13
+ FilterClauseField,
14
+ FilterMatchOperator,
15
+ FilterScope,
16
+ FilterState,
17
+ } from "@remit/domain-enums";
18
+ import type { APIGatewayProxyEvent } from "aws-lambda";
19
+ import { getAccountConfigIdFromEvent } from "../auth.js";
20
+ import { getClient, type RemitClient } from "../service/data-client.js";
21
+ import type {
22
+ CalendarSuggestionActionOperationIds,
23
+ CalendarSuggestionOperationIds,
24
+ MessageCalendarSuggestionOperationIds,
25
+ OperationHandler,
26
+ } from "../types.js";
27
+
28
+ /**
29
+ * The raw invitation bytes stay on the server. A client renders the projected
30
+ * fields; the bytes exist so accepting can write them into a calendar
31
+ * unchanged, and shipping them would invite a second, divergent renderer.
32
+ */
33
+ export const toCalendarSuggestionResponse = (
34
+ item: CalendarSuggestionItem,
35
+ ): CalendarSuggestionResponse => {
36
+ const { icalData: _icalData, ...response } = item;
37
+ return response;
38
+ };
39
+
40
+ /**
41
+ * The states a person can move a pending card into. `Superseded` is the
42
+ * producer's alone — a revision retires a card, a person never does — and the
43
+ * rest are terminal.
44
+ */
45
+ export const assertSettleable = (
46
+ suggestion: CalendarSuggestionItem,
47
+ target: CalendarSuggestionItem["state"],
48
+ ): void => {
49
+ if (suggestion.state === CalendarSuggestionState.Pending) return;
50
+ if (suggestion.state === target) return;
51
+ throw new BadRequestError(
52
+ `This suggestion was already ${suggestion.state.toLowerCase()}, so it can't be ${target.toLowerCase()}.`,
53
+ );
54
+ };
55
+
56
+ /**
57
+ * The mail address the card's event should name as the accepting attendee: the
58
+ * account the invitation arrived on. Resolved from the message rather than
59
+ * asked of the client, so an ATTENDEE line can never name an address the user
60
+ * does not own.
61
+ */
62
+ const accountEmailForMessage = async (
63
+ client: RemitClient,
64
+ messageId: string,
65
+ ): Promise<string> => {
66
+ const message = await client.message.get(messageId);
67
+ const accountId = await client.mailbox.resolveAccountId(message.mailboxId);
68
+ if (!accountId) {
69
+ throw new BadRequestError(
70
+ "The folder this invitation arrived in is gone, so there is no address to accept as.",
71
+ );
72
+ }
73
+ const account = await client.account.get(accountId);
74
+ return account.email;
75
+ };
76
+
77
+ /** What muting a sender needs, so it can be driven without a live table. */
78
+ export interface MuteSenderDeps {
79
+ envelope: Pick<IEnvelopeRepository, "getMessageData">;
80
+ filter: Pick<IFilterRepository, "create" | "listByAccountAndState">;
81
+ }
82
+
83
+ /**
84
+ * The standing rule `dismiss{muteSender:true}` writes: the user saying they do
85
+ * not want this sender's invitations offered. It goes through the existing
86
+ * `Filter` entity rather than a second rules table, so it is visible and
87
+ * editable beside every other rule the user has, and matches on the message's
88
+ * From address rather than on the invitation's ORGANIZER — a `PUBLISH` names
89
+ * no organizer at all, and the sender is who the user is refusing.
90
+ *
91
+ * At most one rule per sender. Dismissing the same card twice, or muting a
92
+ * sender already muted from another card, is the same instruction repeated —
93
+ * a second identical rule would only be a second row for the settings page to
94
+ * show and the user to delete twice.
95
+ */
96
+ export const muteSender = async (
97
+ deps: MuteSenderDeps,
98
+ accountConfigId: string,
99
+ messageId: string,
100
+ ): Promise<void> => {
101
+ const data = await deps.envelope.getMessageData(messageId);
102
+ const from = data.envelopeAddress.find(
103
+ (address) => address.addressRole === "from",
104
+ );
105
+ if (!from) {
106
+ throw new BadRequestError(
107
+ "This message names no sender, so there is nobody to mute.",
108
+ );
109
+ }
110
+ const active = await deps.filter.listByAccountAndState(
111
+ accountConfigId,
112
+ FilterState.Active,
113
+ );
114
+ if (isSenderMuted(active, from.normalizedEmail)) return;
115
+ await deps.filter.create({
116
+ accountConfigId,
117
+ name: `Muted invitations from ${from.normalizedEmail}`,
118
+ scope: FilterScope.Standing,
119
+ matchOperator: FilterMatchOperator.And,
120
+ literalClauses: [
121
+ { field: FilterClauseField.From, value: from.normalizedEmail },
122
+ ],
123
+ });
124
+ };
125
+
126
+ /**
127
+ * Moves a card to a decision. Idempotent by design: a client that retries a
128
+ * decline gets the same answer rather than a 400, and only a card in a
129
+ * different terminal state is refused.
130
+ */
131
+ export const settleSuggestion = async (
132
+ repo: ICalendarSuggestionRepository,
133
+ accountConfigId: string,
134
+ suggestionId: string,
135
+ state: CalendarSuggestionItem["state"],
136
+ ): Promise<CalendarSuggestionItem> => {
137
+ const suggestion = await repo.get(accountConfigId, suggestionId);
138
+ assertSettleable(suggestion, state);
139
+ if (suggestion.state === state) return suggestion;
140
+ return repo.settle(accountConfigId, suggestionId, {
141
+ state,
142
+ acceptedCalendarObjectId: "",
143
+ });
144
+ };
145
+
146
+ export const CalendarSuggestionOperations: Record<
147
+ CalendarSuggestionOperationIds,
148
+ OperationHandler<CalendarSuggestionOperationIds>
149
+ > = {
150
+ CalendarSuggestionOperations_listCalendarSuggestions: async (
151
+ context,
152
+ ...args: unknown[]
153
+ ) => {
154
+ const event = args[0] as APIGatewayProxyEvent;
155
+ const accountConfigId = getAccountConfigIdFromEvent(event);
156
+ const { state, continuationToken } = context.request.query as {
157
+ state: CalendarSuggestionItem["state"];
158
+ continuationToken?: string;
159
+ };
160
+
161
+ const client = await getClient();
162
+ const page = await client.calendarSuggestion.listByState(
163
+ accountConfigId,
164
+ state,
165
+ { continuationToken },
166
+ );
167
+
168
+ return {
169
+ items: page.items.map(toCalendarSuggestionResponse),
170
+ continuationToken: page.continuationToken,
171
+ };
172
+ },
173
+ };
174
+
175
+ export const MessageCalendarSuggestionOperations: Record<
176
+ MessageCalendarSuggestionOperationIds,
177
+ OperationHandler<MessageCalendarSuggestionOperationIds>
178
+ > = {
179
+ MessageCalendarSuggestionOperations_listMessageCalendarSuggestions: async (
180
+ context,
181
+ ...args: unknown[]
182
+ ) => {
183
+ const event = args[0] as APIGatewayProxyEvent;
184
+ const accountConfigId = getAccountConfigIdFromEvent(event);
185
+ const { messageId } = context.request.params as { messageId: string };
186
+
187
+ const client = await getClient();
188
+ const items = await client.calendarSuggestion.listByMessage(
189
+ accountConfigId,
190
+ messageId,
191
+ );
192
+
193
+ return {
194
+ items: items.map(toCalendarSuggestionResponse),
195
+ continuationToken: undefined,
196
+ };
197
+ },
198
+ };
199
+
200
+ export const CalendarSuggestionActionOperations: Record<
201
+ CalendarSuggestionActionOperationIds,
202
+ OperationHandler<CalendarSuggestionActionOperationIds>
203
+ > = {
204
+ CalendarSuggestionActionOperations_acceptCalendarSuggestion: async (
205
+ context,
206
+ ...args: unknown[]
207
+ ) => {
208
+ const event = args[0] as APIGatewayProxyEvent;
209
+ const accountConfigId = getAccountConfigIdFromEvent(event);
210
+ const { suggestionId } = context.request.params as {
211
+ suggestionId: string;
212
+ };
213
+ const { calendarId } = context.request.requestBody as {
214
+ calendarId: string;
215
+ };
216
+
217
+ const client = await getClient();
218
+ const suggestion = await client.calendarSuggestion.get(
219
+ accountConfigId,
220
+ suggestionId,
221
+ );
222
+ assertSettleable(suggestion, CalendarSuggestionState.Accepted);
223
+
224
+ // Reads the collection through the caller's own account config, so a
225
+ // calendarId naming somebody else's collection is a 404 before anything
226
+ // is written into it.
227
+ await client.calendarCollection.get(accountConfigId, calendarId);
228
+
229
+ const accepted = await acceptCalendarSuggestion(client.calendarUnitOfWork, {
230
+ accountConfigId,
231
+ calendarId,
232
+ suggestion,
233
+ attendee: await accountEmailForMessage(client, suggestion.messageId),
234
+ });
235
+ if (!accepted.ok) {
236
+ throw new BadRequestError(accepted.error.message);
237
+ }
238
+
239
+ return toCalendarSuggestionResponse(accepted.value.suggestion);
240
+ },
241
+
242
+ CalendarSuggestionActionOperations_declineCalendarSuggestion: async (
243
+ context,
244
+ ...args: unknown[]
245
+ ) => {
246
+ const event = args[0] as APIGatewayProxyEvent;
247
+ const accountConfigId = getAccountConfigIdFromEvent(event);
248
+ const { suggestionId } = context.request.params as {
249
+ suggestionId: string;
250
+ };
251
+
252
+ const client = await getClient();
253
+ const declined = await settleSuggestion(
254
+ client.calendarSuggestion,
255
+ accountConfigId,
256
+ suggestionId,
257
+ CalendarSuggestionState.Declined,
258
+ );
259
+
260
+ return toCalendarSuggestionResponse(declined);
261
+ },
262
+
263
+ CalendarSuggestionActionOperations_dismissCalendarSuggestion: async (
264
+ context,
265
+ ...args: unknown[]
266
+ ) => {
267
+ const event = args[0] as APIGatewayProxyEvent;
268
+ const accountConfigId = getAccountConfigIdFromEvent(event);
269
+ const { suggestionId } = context.request.params as {
270
+ suggestionId: string;
271
+ };
272
+ const body = (context.request.requestBody ?? {}) as {
273
+ muteSender?: boolean;
274
+ };
275
+
276
+ const client = await getClient();
277
+ const dismissed = await settleSuggestion(
278
+ client.calendarSuggestion,
279
+ accountConfigId,
280
+ suggestionId,
281
+ CalendarSuggestionState.Dismissed,
282
+ );
283
+ if (body.muteSender) {
284
+ await muteSender(client, accountConfigId, dismissed.messageId);
285
+ }
286
+
287
+ return toCalendarSuggestionResponse(dismissed);
288
+ },
289
+ };
@@ -5,24 +5,32 @@ import type {
5
5
  CalendarEventIndexItem,
6
6
  CalendarObjectItem,
7
7
  CalendarOccurrenceInput,
8
+ CalendarSuggestionItem,
9
+ CalendarUnitOfWorkRepositories,
8
10
  CreateCalendarCollectionInput,
9
11
  ICalendarCollectionRepository,
10
12
  ICalendarEventIndexRepository,
11
13
  ICalendarObjectRepository,
14
+ ICalendarSuggestionRepository,
12
15
  ICalendarUnitOfWork,
13
16
  PutCalendarObjectInput,
17
+ PutCalendarSuggestionInput,
18
+ ResultList,
19
+ SettleCalendarSuggestionInput,
14
20
  UpdateCalendarCollectionInput,
15
21
  } from "@remit/data-ports";
16
22
  import { NotFoundError } from "@remit/data-ports/errors";
17
23
  import {
18
24
  deriveCalendarId,
19
25
  deriveCalendarObjectId,
26
+ deriveCalendarSuggestionId,
20
27
  normalizeCalendarUrlSegment,
21
28
  } from "@remit/data-ports/id";
22
29
  import {
23
30
  CalendarColor,
24
31
  CalendarComponentSet,
25
32
  CalendarSource,
33
+ CalendarSuggestionState,
26
34
  RecurrenceScope,
27
35
  } from "@remit/domain-enums";
28
36
  import {
@@ -58,6 +66,107 @@ class CalendarState {
58
66
  readonly collections = new Map<string, CalendarCollectionItem>();
59
67
  readonly objects = new Map<string, CalendarObjectItem>();
60
68
  readonly occurrences = new Map<string, CalendarEventIndexItem[]>();
69
+ readonly suggestions = new Map<string, CalendarSuggestionItem>();
70
+ }
71
+
72
+ /**
73
+ * Suggestions are bound to the same unit of work so accepting a card and
74
+ * writing its resource commit together (issue #1033). Nothing in this file
75
+ * exercises them; they are here so the store answers the whole port.
76
+ */
77
+ class MemorySuggestions implements ICalendarSuggestionRepository {
78
+ constructor(private state: CalendarState) {}
79
+
80
+ async put(
81
+ input: PutCalendarSuggestionInput,
82
+ ): Promise<CalendarSuggestionItem> {
83
+ const suggestionId = deriveCalendarSuggestionId(
84
+ input.messageId,
85
+ input.bodyPartId,
86
+ input.icalUid,
87
+ );
88
+ const existing = this.state.suggestions.get(suggestionId);
89
+ const now = Date.now();
90
+ const suggestion: CalendarSuggestionItem = {
91
+ ...input,
92
+ suggestionId,
93
+ state: existing?.state ?? CalendarSuggestionState.Pending,
94
+ acceptedCalendarObjectId: existing?.acceptedCalendarObjectId ?? "",
95
+ createdAt: existing?.createdAt ?? now,
96
+ updatedAt: now,
97
+ };
98
+ this.state.suggestions.set(suggestionId, suggestion);
99
+ return suggestion;
100
+ }
101
+
102
+ async get(
103
+ accountConfigId: string,
104
+ suggestionId: string,
105
+ ): Promise<CalendarSuggestionItem> {
106
+ const suggestion = this.state.suggestions.get(suggestionId);
107
+ if (!suggestion || suggestion.accountConfigId !== accountConfigId) {
108
+ throw new NotFoundError(`Calendar suggestion not found: ${suggestionId}`);
109
+ }
110
+ return suggestion;
111
+ }
112
+
113
+ async listByMessage(
114
+ accountConfigId: string,
115
+ messageId: string,
116
+ ): Promise<CalendarSuggestionItem[]> {
117
+ return [...this.state.suggestions.values()].filter(
118
+ (suggestion) =>
119
+ suggestion.accountConfigId === accountConfigId &&
120
+ suggestion.messageId === messageId,
121
+ );
122
+ }
123
+
124
+ async listByState(
125
+ accountConfigId: string,
126
+ state: CalendarSuggestionItem["state"],
127
+ ): Promise<ResultList<CalendarSuggestionItem>> {
128
+ return {
129
+ items: [...this.state.suggestions.values()].filter(
130
+ (suggestion) =>
131
+ suggestion.accountConfigId === accountConfigId &&
132
+ suggestion.state === state,
133
+ ),
134
+ continuationToken: undefined,
135
+ };
136
+ }
137
+
138
+ async settle(
139
+ accountConfigId: string,
140
+ suggestionId: string,
141
+ input: SettleCalendarSuggestionInput,
142
+ ): Promise<CalendarSuggestionItem> {
143
+ const suggestion = await this.get(accountConfigId, suggestionId);
144
+ const settled = { ...suggestion, ...input, updatedAt: Date.now() };
145
+ this.state.suggestions.set(suggestionId, settled);
146
+ return settled;
147
+ }
148
+
149
+ async supersedeIfPending(
150
+ accountConfigId: string,
151
+ suggestionId: string,
152
+ ): Promise<CalendarSuggestionItem | null> {
153
+ const suggestion = this.state.suggestions.get(suggestionId);
154
+ if (
155
+ !suggestion ||
156
+ suggestion.accountConfigId !== accountConfigId ||
157
+ suggestion.state !== CalendarSuggestionState.Pending
158
+ ) {
159
+ return null;
160
+ }
161
+ const retired = {
162
+ ...suggestion,
163
+ state: CalendarSuggestionState.Superseded,
164
+ acceptedCalendarObjectId: "",
165
+ updatedAt: Date.now(),
166
+ };
167
+ this.state.suggestions.set(suggestionId, retired);
168
+ return retired;
169
+ }
61
170
  }
62
171
 
63
172
  class MemoryCollections implements ICalendarCollectionRepository {
@@ -307,6 +416,7 @@ class InMemoryCalendarStore implements ICalendarUnitOfWork {
307
416
  readonly calendarCollection = new MemoryCollections(this.state);
308
417
  readonly calendarObject = new MemoryObjects(this.state);
309
418
  readonly calendarEventIndex = new MemoryOccurrences(this.state);
419
+ readonly calendarSuggestion = new MemorySuggestions(this.state);
310
420
 
311
421
  get collections(): Map<string, CalendarCollectionItem> {
312
422
  return this.state.collections;
@@ -323,11 +433,7 @@ class InMemoryCalendarStore implements ICalendarUnitOfWork {
323
433
  // No isolation to model: the tests that care about atomicity run against
324
434
  // sqlite, where the transaction is real.
325
435
  transaction<T>(
326
- fn: (repos: {
327
- calendarCollection: ICalendarCollectionRepository;
328
- calendarObject: ICalendarObjectRepository;
329
- calendarEventIndex: ICalendarEventIndexRepository;
330
- }) => Promise<T>,
436
+ fn: (repos: CalendarUnitOfWorkRepositories) => Promise<T>,
331
437
  ): Promise<T> {
332
438
  return fn(this);
333
439
  }
@@ -8,6 +8,11 @@ import {
8
8
  CalendarEventOperations,
9
9
  CalendarFreeBusyOperations,
10
10
  } from "./calendar-event.js";
11
+ import {
12
+ CalendarSuggestionActionOperations,
13
+ CalendarSuggestionOperations,
14
+ MessageCalendarSuggestionOperations,
15
+ } from "./calendar-suggestion.js";
11
16
  import { ConfigOperations } from "./config.js";
12
17
  import { FilterDetailOperations, FilterOperations } from "./filter.js";
13
18
  import { FolderRoleOperations } from "./folder-role.js";
@@ -66,4 +71,7 @@ export const handlers: Record<OperationIds, OperationHandler<any>> = {
66
71
  ...AddressOperations,
67
72
  ...AddressDetailOperations,
68
73
  ...SemanticSearchOperations,
74
+ ...CalendarSuggestionOperations,
75
+ ...MessageCalendarSuggestionOperations,
76
+ ...CalendarSuggestionActionOperations,
69
77
  };
@@ -7,6 +7,7 @@ import {
7
7
  CalendarCollectionRepo,
8
8
  CalendarEventIndexRepo,
9
9
  CalendarObjectRepo,
10
+ CalendarSuggestionRepo,
10
11
  ConfigImportRepo,
11
12
  createSqliteDatabase,
12
13
  DrizzleCalendarUnitOfWork,
@@ -85,6 +86,7 @@ export const buildSqliteClient = async (): Promise<RemitClient> => {
85
86
  calendarCollection: new CalendarCollectionRepo(genericDb),
86
87
  calendarObject: new CalendarObjectRepo(genericDb),
87
88
  calendarEventIndex: new CalendarEventIndexRepo(genericDb),
89
+ calendarSuggestion: new CalendarSuggestionRepo(genericDb),
88
90
  calendarUnitOfWork: new DrizzleCalendarUnitOfWork(genericDb),
89
91
  unitOfWork: new DrizzleUnitOfWork(messageDataDb),
90
92
  writeSet: (run) => runInTransaction(genericDb, () => run()),
@@ -7,6 +7,7 @@ import type {
7
7
  ICalendarCollectionRepository,
8
8
  ICalendarEventIndexRepository,
9
9
  ICalendarObjectRepository,
10
+ ICalendarSuggestionRepository,
10
11
  ICalendarUnitOfWork,
11
12
  IConfigImportRepository,
12
13
  IEnvelopeRepository,
@@ -119,15 +120,17 @@ export interface RemitClient {
119
120
  // body-sync, which is its only writer.
120
121
  senderSignerStanding: ISenderSignerStandingRepository;
121
122
 
122
- // The calendar store (issue #15). Every write goes through
123
- // `calendarUnitOfWork`: the object, its occurrence rows and the collection's
124
- // sequence bump are one fact, and the three repositories beside it are the
125
- // read side. A backend that cannot supply them cannot serve the calendar at
126
- // all, so they are part of the client rather than something each handler
127
- // checks for.
123
+ // The calendar store (issue #15) and the cards a message offers into it
124
+ // (issue #1033). Every write goes through `calendarUnitOfWork`: the object,
125
+ // its occurrence rows, the collection's sequence bump and when the write
126
+ // came from accepting a suggestion that suggestion's own state are one
127
+ // fact, and the repositories beside it are the read side. A backend that
128
+ // cannot supply them cannot serve the calendar at all, so they are part of
129
+ // the client rather than something each handler checks for.
128
130
  calendarCollection: ICalendarCollectionRepository;
129
131
  calendarObject: ICalendarObjectRepository;
130
132
  calendarEventIndex: ICalendarEventIndexRepository;
133
+ calendarSuggestion: ICalendarSuggestionRepository;
131
134
  calendarUnitOfWork: ICalendarUnitOfWork;
132
135
 
133
136
  // Atomic write set for a message save. Present on the relational backend (real
@@ -222,6 +225,7 @@ export interface RemitClientRepositories {
222
225
  calendarCollection: ICalendarCollectionRepository;
223
226
  calendarObject: ICalendarObjectRepository;
224
227
  calendarEventIndex: ICalendarEventIndexRepository;
228
+ calendarSuggestion: ICalendarSuggestionRepository;
225
229
  calendarUnitOfWork: ICalendarUnitOfWork;
226
230
  unitOfWork?: IUnitOfWork;
227
231
  writeSet?: <T>(run: () => Promise<T>) => Promise<T>;
@@ -425,6 +429,15 @@ export const createRemitClient = (deps: RemitClientDeps): RemitClient => {
425
429
  filterConfig,
426
430
  undefined,
427
431
  { flagQueueService },
432
+ // The read-path backfill materializes a body the sync path never got to,
433
+ // so it is a message's first sight as much as the sync pass is — and an
434
+ // invitation the user opens before background sync must still get its
435
+ // card (issue #1033, the same argument as the filter wiring above).
436
+ {
437
+ calendarSuggestionService: repositories.calendarSuggestion,
438
+ calendarUnitOfWork: repositories.calendarUnitOfWork,
439
+ filterService: repositories.filter,
440
+ },
428
441
  );
429
442
 
430
443
  return {
@@ -453,6 +466,7 @@ export const createRemitClient = (deps: RemitClientDeps): RemitClient => {
453
466
  calendarCollection: repositories.calendarCollection,
454
467
  calendarObject: repositories.calendarObject,
455
468
  calendarEventIndex: repositories.calendarEventIndex,
469
+ calendarSuggestion: repositories.calendarSuggestion,
456
470
  calendarUnitOfWork: repositories.calendarUnitOfWork,
457
471
  unitOfWork: repositories.unitOfWork,
458
472
  writeSet: repositories.writeSet,
package/src/types.ts CHANGED
@@ -79,7 +79,12 @@ export type OperationIds =
79
79
  | "OutboxDetailOperations_mintOutboxAttachment"
80
80
  | "OutboxAttachmentOperations_completeOutboxAttachment"
81
81
  | "AddressOperations_searchAddresses"
82
- | "AddressDetailOperations_updateAddress";
82
+ | "AddressDetailOperations_updateAddress"
83
+ | "CalendarSuggestionOperations_listCalendarSuggestions"
84
+ | "MessageCalendarSuggestionOperations_listMessageCalendarSuggestions"
85
+ | "CalendarSuggestionActionOperations_acceptCalendarSuggestion"
86
+ | "CalendarSuggestionActionOperations_declineCalendarSuggestion"
87
+ | "CalendarSuggestionActionOperations_dismissCalendarSuggestion";
83
88
 
84
89
  export type MeOperationIds = MatchPrefix<"MeOperations_", OperationIds>;
85
90
 
@@ -219,6 +224,21 @@ export type MicrosoftOAuthOperationIds = MatchPrefix<
219
224
  OperationIds
220
225
  >;
221
226
 
227
+ export type CalendarSuggestionOperationIds = MatchPrefix<
228
+ "CalendarSuggestionOperations_",
229
+ OperationIds
230
+ >;
231
+
232
+ export type MessageCalendarSuggestionOperationIds = MatchPrefix<
233
+ "MessageCalendarSuggestionOperations_",
234
+ OperationIds
235
+ >;
236
+
237
+ export type CalendarSuggestionActionOperationIds = MatchPrefix<
238
+ "CalendarSuggestionActionOperations_",
239
+ OperationIds
240
+ >;
241
+
222
242
  // biome-ignore lint/suspicious/noExplicitAny: handler responses vary by operation
223
243
  type HandlerResponse = Record<string, any>;
224
244
  export type OperationHandler<_T extends OperationIds = OperationIds> = (