@remit/data-ports 0.0.52 → 0.0.54

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/data-ports",
3
- "version": "0.0.52",
3
+ "version": "0.0.54",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -59,6 +59,29 @@ export function calendarCollectionRepositoryConformance(
59
59
  assert.equal(all.length, 1);
60
60
  });
61
61
 
62
+ test("createExclusive refuses a segment the account already uses", async () => {
63
+ const accountConfigId = harness.makeId();
64
+
65
+ const first = await repo.createExclusive({
66
+ accountConfigId,
67
+ urlSegment: "work",
68
+ displayName: "Work",
69
+ });
70
+ const second = await repo.createExclusive({
71
+ accountConfigId,
72
+ urlSegment: "work",
73
+ displayName: "Work again",
74
+ });
75
+
76
+ assert.ok(first);
77
+ assert.equal(second, null);
78
+ assert.equal(
79
+ (await repo.get(accountConfigId, first.calendarId)).displayName,
80
+ "Work",
81
+ "the loser never overwrote the calendar the winner made",
82
+ );
83
+ });
84
+
62
85
  test("get throws a not-found error for a missing collection", async () => {
63
86
  await assert.rejects(
64
87
  repo.get(harness.makeId(), harness.makeId()),
@@ -1,5 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { after, before, describe, test } from "node:test";
3
+ import { CalendarEventStatus, CalendarTransparency } from "@remit/domain-enums";
3
4
  import type { ICalendarEventIndexRepository } from "../interfaces/calendar-event-index.js";
4
5
  import type { CalendarOccurrenceInput } from "../types.js";
5
6
  import type { RepositoryConformanceHarness } from "./harness.js";
@@ -8,11 +9,16 @@ const occurrence = (
8
9
  recurrenceId: string,
9
10
  startAt: string,
10
11
  endAt: string,
12
+ overrides: Partial<CalendarOccurrenceInput> = {},
11
13
  ): CalendarOccurrenceInput => ({
12
14
  recurrenceId,
13
15
  startAt,
14
16
  endAt,
15
17
  allDay: false,
18
+ summary: "Stand-up",
19
+ status: CalendarEventStatus.Confirmed,
20
+ transparency: CalendarTransparency.Opaque,
21
+ ...overrides,
16
22
  });
17
23
 
18
24
  export function calendarEventIndexRepositoryConformance(
@@ -139,6 +145,46 @@ export function calendarEventIndexRepositoryConformance(
139
145
  assert.equal(rows.length, 1);
140
146
  });
141
147
 
148
+ test("an occurrence keeps its own summary, status and transparency", async () => {
149
+ const calendarId = harness.makeId();
150
+ const calendarObjectId = harness.makeId();
151
+
152
+ await repo.replaceForObject(calendarId, calendarObjectId, [
153
+ occurrence(
154
+ "2026-08-26T07:00:00Z",
155
+ "2026-08-26T07:00:00Z",
156
+ "2026-08-26T08:00:00Z",
157
+ ),
158
+ occurrence(
159
+ "2026-09-02T07:00:00Z",
160
+ "2026-09-02T07:00:00Z",
161
+ "2026-09-02T08:00:00Z",
162
+ {
163
+ summary: "Stand-up (moved)",
164
+ status: CalendarEventStatus.Cancelled,
165
+ transparency: CalendarTransparency.Transparent,
166
+ },
167
+ ),
168
+ ]);
169
+
170
+ const rows = await repo.listForObject(calendarId, calendarObjectId);
171
+ assert.deepEqual(
172
+ rows.map((row) => [row.summary, row.status, row.transparency]),
173
+ [
174
+ [
175
+ "Stand-up",
176
+ CalendarEventStatus.Confirmed,
177
+ CalendarTransparency.Opaque,
178
+ ],
179
+ [
180
+ "Stand-up (moved)",
181
+ CalendarEventStatus.Cancelled,
182
+ CalendarTransparency.Transparent,
183
+ ],
184
+ ],
185
+ );
186
+ });
187
+
142
188
  test("deleteForObject removes every occurrence of one resource", async () => {
143
189
  const calendarId = harness.makeId();
144
190
  const calendarObjectId = harness.makeId();
@@ -107,6 +107,49 @@ export function calendarObjectRepositoryConformance(
107
107
  );
108
108
  });
109
109
 
110
+ test("find answers with null where get throws", async () => {
111
+ const calendarId = harness.makeId();
112
+ const object = await repo.put(objectInput(calendarId));
113
+
114
+ assert.equal(
115
+ (await repo.find(calendarId, object.calendarObjectId))?.icalData,
116
+ ICAL_DATA,
117
+ );
118
+ assert.equal(await repo.find(calendarId, harness.makeId()), null);
119
+ assert.equal(
120
+ await repo.find(harness.makeId(), object.calendarObjectId),
121
+ null,
122
+ "a resource is only found through the collection that holds it",
123
+ );
124
+ });
125
+
126
+ test("listIncompleteExpansions returns only the series whose index stops short", async () => {
127
+ const calendarId = harness.makeId();
128
+ await repo.put(objectInput(calendarId, { resourceName: "complete.ics" }));
129
+ const incomplete = await repo.put(
130
+ objectInput(calendarId, {
131
+ resourceName: "incomplete.ics",
132
+ expandedThrough: "2026-01-01T00:00:00Z",
133
+ }),
134
+ );
135
+
136
+ assert.deepEqual(
137
+ (
138
+ await repo.listIncompleteExpansions(
139
+ calendarId,
140
+ "2026-06-01T00:00:00Z",
141
+ )
142
+ ).map((object) => object.calendarObjectId),
143
+ [incomplete.calendarObjectId],
144
+ "a resource with no expandedThrough is complete and never appears",
145
+ );
146
+ assert.deepEqual(
147
+ await repo.listIncompleteExpansions(calendarId, "2025-06-01T00:00:00Z"),
148
+ [],
149
+ "and one whose index already reaches the window does not either",
150
+ );
151
+ });
152
+
110
153
  test("findByResourceName returns null for a name the collection does not hold", async () => {
111
154
  assert.equal(
112
155
  await repo.findByResourceName(harness.makeId(), "absent.ics"),
@@ -0,0 +1,293 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, before, describe, test } from "node:test";
3
+ import {
4
+ CalendarInviteMethod,
5
+ CalendarSuggestionSource,
6
+ CalendarSuggestionState,
7
+ ZoneCertainty,
8
+ } from "@remit/domain-enums";
9
+ import { deriveCalendarSuggestionId } from "../id.js";
10
+ import type { ICalendarSuggestionRepository } from "../interfaces/calendar-suggestion.js";
11
+ import type { PutCalendarSuggestionInput } from "../types.js";
12
+ import type { RepositoryConformanceHarness } from "./harness.js";
13
+
14
+ const suggestion = (
15
+ accountConfigId: string,
16
+ messageId: string,
17
+ bodyPartId: string,
18
+ icalUid: string,
19
+ overrides: Partial<PutCalendarSuggestionInput> = {},
20
+ ): PutCalendarSuggestionInput => ({
21
+ accountConfigId,
22
+ messageId,
23
+ bodyPartId,
24
+ icalUid,
25
+ sequence: 0,
26
+ method: CalendarInviteMethod.Request,
27
+ source: CalendarSuggestionSource.IcalendarPart,
28
+ summary: "Design review",
29
+ dtStart: "2026-09-01T10:00:00+02:00",
30
+ dtEnd: "2026-09-01T11:00:00+02:00",
31
+ allDay: false,
32
+ location: "",
33
+ organizer: "organizer@example.test",
34
+ zoneCertainty: ZoneCertainty.Explicit,
35
+ icalData: "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n",
36
+ ...overrides,
37
+ });
38
+
39
+ export function calendarSuggestionRepositoryConformance(
40
+ harness: RepositoryConformanceHarness<ICalendarSuggestionRepository>,
41
+ ): void {
42
+ describe("ICalendarSuggestionRepository conformance", () => {
43
+ let repo: ICalendarSuggestionRepository;
44
+
45
+ before(async () => {
46
+ repo = await harness.createRepository();
47
+ });
48
+
49
+ after(() => harness.teardown());
50
+
51
+ test("put writes a Pending suggestion under its derived id", async () => {
52
+ const accountConfigId = harness.makeId();
53
+ const messageId = harness.makeId();
54
+ const bodyPartId = harness.makeId();
55
+
56
+ const written = await repo.put(
57
+ suggestion(accountConfigId, messageId, bodyPartId, "uid-1"),
58
+ );
59
+
60
+ assert.equal(
61
+ written.suggestionId,
62
+ deriveCalendarSuggestionId(messageId, bodyPartId, "uid-1"),
63
+ );
64
+ assert.equal(written.state, CalendarSuggestionState.Pending);
65
+ assert.equal(written.acceptedCalendarObjectId, "");
66
+ });
67
+
68
+ test("put re-reading the same message converges on one row", async () => {
69
+ const accountConfigId = harness.makeId();
70
+ const messageId = harness.makeId();
71
+ const bodyPartId = harness.makeId();
72
+
73
+ await repo.put(
74
+ suggestion(accountConfigId, messageId, bodyPartId, "uid-1"),
75
+ );
76
+ await repo.put(
77
+ suggestion(accountConfigId, messageId, bodyPartId, "uid-1", {
78
+ summary: "Design review, moved",
79
+ }),
80
+ );
81
+
82
+ const rows = await repo.listByMessage(accountConfigId, messageId);
83
+ assert.equal(rows.length, 1);
84
+ assert.equal(rows[0]?.summary, "Design review, moved");
85
+ });
86
+
87
+ test("put leaves an answered suggestion answered", async () => {
88
+ // A re-sync must never walk a decision back to Pending: the user
89
+ // already added this event to a calendar, and a producer write is not
90
+ // allowed to un-decide that.
91
+ const accountConfigId = harness.makeId();
92
+ const messageId = harness.makeId();
93
+ const bodyPartId = harness.makeId();
94
+
95
+ const written = await repo.put(
96
+ suggestion(accountConfigId, messageId, bodyPartId, "uid-1"),
97
+ );
98
+ await repo.settle(accountConfigId, written.suggestionId, {
99
+ state: CalendarSuggestionState.Accepted,
100
+ acceptedCalendarObjectId: "cal-object-1",
101
+ });
102
+ await repo.put(
103
+ suggestion(accountConfigId, messageId, bodyPartId, "uid-1"),
104
+ );
105
+
106
+ const reread = await repo.get(accountConfigId, written.suggestionId);
107
+ assert.equal(reread.state, CalendarSuggestionState.Accepted);
108
+ assert.equal(reread.acceptedCalendarObjectId, "cal-object-1");
109
+ });
110
+
111
+ test("get rejects a suggestion that is not there", async () => {
112
+ const accountConfigId = harness.makeId();
113
+ await assert.rejects(
114
+ () => repo.get(accountConfigId, harness.makeId()),
115
+ (error: unknown) => harness.isNotFoundError(error),
116
+ );
117
+ });
118
+
119
+ test("get scopes to the account config", async () => {
120
+ const accountConfigId = harness.makeId();
121
+ const written = await repo.put(
122
+ suggestion(accountConfigId, harness.makeId(), harness.makeId(), "uid"),
123
+ );
124
+
125
+ await assert.rejects(
126
+ () => repo.get(harness.makeId(), written.suggestionId),
127
+ (error: unknown) => harness.isNotFoundError(error),
128
+ );
129
+ });
130
+
131
+ test("listByMessage returns every suggestion one message produced", async () => {
132
+ const accountConfigId = harness.makeId();
133
+ const messageId = harness.makeId();
134
+ const other = harness.makeId();
135
+
136
+ await repo.put(
137
+ suggestion(accountConfigId, messageId, harness.makeId(), "uid-a"),
138
+ );
139
+ await repo.put(
140
+ suggestion(accountConfigId, messageId, harness.makeId(), "uid-b"),
141
+ );
142
+ await repo.put(
143
+ suggestion(accountConfigId, other, harness.makeId(), "uid-c"),
144
+ );
145
+
146
+ const rows = await repo.listByMessage(accountConfigId, messageId);
147
+ assert.deepEqual(rows.map((row) => row.icalUid).sort(), [
148
+ "uid-a",
149
+ "uid-b",
150
+ ]);
151
+ });
152
+
153
+ test("listByState returns only the state asked for", async () => {
154
+ const accountConfigId = harness.makeId();
155
+ const pending = await repo.put(
156
+ suggestion(accountConfigId, harness.makeId(), harness.makeId(), "p"),
157
+ );
158
+ const declined = await repo.put(
159
+ suggestion(accountConfigId, harness.makeId(), harness.makeId(), "d"),
160
+ );
161
+ await repo.settle(accountConfigId, declined.suggestionId, {
162
+ state: CalendarSuggestionState.Declined,
163
+ acceptedCalendarObjectId: "",
164
+ });
165
+
166
+ const page = await repo.listByState(
167
+ accountConfigId,
168
+ CalendarSuggestionState.Pending,
169
+ );
170
+ assert.deepEqual(
171
+ page.items.map((row) => row.suggestionId),
172
+ [pending.suggestionId],
173
+ );
174
+ });
175
+
176
+ test("listByState scopes to the account config", async () => {
177
+ const accountConfigId = harness.makeId();
178
+ await repo.put(
179
+ suggestion(harness.makeId(), harness.makeId(), harness.makeId(), "x"),
180
+ );
181
+
182
+ const page = await repo.listByState(
183
+ accountConfigId,
184
+ CalendarSuggestionState.Pending,
185
+ );
186
+ assert.equal(page.items.length, 0);
187
+ });
188
+
189
+ test("listByState pages, and the token resumes where the page stopped", async () => {
190
+ const accountConfigId = harness.makeId();
191
+ for (const uid of ["a", "b", "c"]) {
192
+ await repo.put(
193
+ suggestion(accountConfigId, harness.makeId(), harness.makeId(), uid),
194
+ );
195
+ }
196
+
197
+ const first = await repo.listByState(
198
+ accountConfigId,
199
+ CalendarSuggestionState.Pending,
200
+ { limit: 2 },
201
+ );
202
+ assert.equal(first.items.length, 2);
203
+ assert.ok(first.continuationToken);
204
+
205
+ const second = await repo.listByState(
206
+ accountConfigId,
207
+ CalendarSuggestionState.Pending,
208
+ { limit: 2, continuationToken: first.continuationToken },
209
+ );
210
+ const seen = [...first.items, ...second.items].map(
211
+ (row) => row.suggestionId,
212
+ );
213
+ assert.equal(new Set(seen).size, 3);
214
+ });
215
+
216
+ test("settle records the accepted resource", async () => {
217
+ const accountConfigId = harness.makeId();
218
+ const written = await repo.put(
219
+ suggestion(accountConfigId, harness.makeId(), harness.makeId(), "uid"),
220
+ );
221
+
222
+ const settled = await repo.settle(accountConfigId, written.suggestionId, {
223
+ state: CalendarSuggestionState.Accepted,
224
+ acceptedCalendarObjectId: "cal-object-9",
225
+ });
226
+
227
+ assert.equal(settled.state, CalendarSuggestionState.Accepted);
228
+ assert.equal(settled.acceptedCalendarObjectId, "cal-object-9");
229
+ });
230
+
231
+ test("supersedeIfPending retires a card still waiting on the user", async () => {
232
+ const accountConfigId = harness.makeId();
233
+ const written = await repo.put(
234
+ suggestion(accountConfigId, harness.makeId(), harness.makeId(), "uid"),
235
+ );
236
+
237
+ const retired = await repo.supersedeIfPending(
238
+ accountConfigId,
239
+ written.suggestionId,
240
+ );
241
+
242
+ assert.equal(retired?.state, CalendarSuggestionState.Superseded);
243
+ });
244
+
245
+ test("supersedeIfPending leaves an answered card alone and says so", async () => {
246
+ // The producer reads the pending set and writes to it in two steps. A
247
+ // person accepting in between must keep their acceptance — and the
248
+ // link to the event it put in their calendar.
249
+ const accountConfigId = harness.makeId();
250
+ const written = await repo.put(
251
+ suggestion(accountConfigId, harness.makeId(), harness.makeId(), "uid"),
252
+ );
253
+ await repo.settle(accountConfigId, written.suggestionId, {
254
+ state: CalendarSuggestionState.Accepted,
255
+ acceptedCalendarObjectId: "cal-object-7",
256
+ });
257
+
258
+ const retired = await repo.supersedeIfPending(
259
+ accountConfigId,
260
+ written.suggestionId,
261
+ );
262
+
263
+ assert.equal(retired, null);
264
+ const reread = await repo.get(accountConfigId, written.suggestionId);
265
+ assert.equal(reread.state, CalendarSuggestionState.Accepted);
266
+ assert.equal(reread.acceptedCalendarObjectId, "cal-object-7");
267
+ });
268
+
269
+ test("supersedeIfPending scopes to the account config", async () => {
270
+ const accountConfigId = harness.makeId();
271
+ const written = await repo.put(
272
+ suggestion(accountConfigId, harness.makeId(), harness.makeId(), "uid"),
273
+ );
274
+
275
+ assert.equal(
276
+ await repo.supersedeIfPending(harness.makeId(), written.suggestionId),
277
+ null,
278
+ );
279
+ });
280
+
281
+ test("settle rejects a suggestion that is not there", async () => {
282
+ const accountConfigId = harness.makeId();
283
+ await assert.rejects(
284
+ () =>
285
+ repo.settle(accountConfigId, harness.makeId(), {
286
+ state: CalendarSuggestionState.Declined,
287
+ acceptedCalendarObjectId: "",
288
+ }),
289
+ (error: unknown) => harness.isNotFoundError(error),
290
+ );
291
+ });
292
+ });
293
+ }
@@ -1,6 +1,7 @@
1
1
  export { calendarCollectionRepositoryConformance } from "./calendar-collection.js";
2
2
  export { calendarEventIndexRepositoryConformance } from "./calendar-event-index.js";
3
3
  export { calendarObjectRepositoryConformance } from "./calendar-object.js";
4
+ export { calendarSuggestionRepositoryConformance } from "./calendar-suggestion.js";
4
5
  export type { RepositoryConformanceHarness } from "./harness.js";
5
6
  export { labelRepositoryConformance } from "./label.js";
6
7
  export { senderSignerStandingRepositoryConformance } from "./sender-signer-standing.js";
package/src/id.test.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  deriveBodyPartId,
7
7
  deriveCalendarId,
8
8
  deriveCalendarObjectId,
9
+ deriveCalendarSuggestionId,
9
10
  deriveCopyMessageId,
10
11
  deriveEnvelopeId,
11
12
  deriveMessageId,
@@ -161,6 +162,43 @@ describe("deriveCalendarObjectId", () => {
161
162
  });
162
163
  });
163
164
 
165
+ describe("deriveCalendarSuggestionId", () => {
166
+ const MESSAGE = "msg-1";
167
+ const PART = "part-1";
168
+ const UID = "invite@example.com";
169
+
170
+ it("converges a re-read of the same message on one row", () => {
171
+ assert.equal(
172
+ deriveCalendarSuggestionId(MESSAGE, PART, UID),
173
+ deriveCalendarSuggestionId(MESSAGE, PART, UID),
174
+ );
175
+ });
176
+
177
+ it("keeps two invitations in one message apart", () => {
178
+ assert.notEqual(
179
+ deriveCalendarSuggestionId(MESSAGE, PART, UID),
180
+ deriveCalendarSuggestionId(MESSAGE, "part-2", UID),
181
+ );
182
+ });
183
+
184
+ it("gives a revision its own row, so the superseded card survives", () => {
185
+ // A later message carrying the same UID is a second suggestion, never an
186
+ // overwrite of the first: the older message keeps its card and the user
187
+ // can see which revision replaced which.
188
+ assert.notEqual(
189
+ deriveCalendarSuggestionId(MESSAGE, PART, UID),
190
+ deriveCalendarSuggestionId("msg-2", PART, UID),
191
+ );
192
+ });
193
+
194
+ it("separates two events sent as one part", () => {
195
+ assert.notEqual(
196
+ deriveCalendarSuggestionId(MESSAGE, PART, UID),
197
+ deriveCalendarSuggestionId(MESSAGE, PART, "other@example.com"),
198
+ );
199
+ });
200
+ });
201
+
164
202
  describe("derived ids are stored primary keys: a changed value orphans every row already written and needs a migration, never a new expectation here", () => {
165
203
  const ACCOUNT = "account-golden";
166
204
  const MESSAGE = "message-golden";
@@ -268,6 +306,17 @@ describe("derived ids are stored primary keys: a changed value orphans every row
268
306
  );
269
307
  });
270
308
 
309
+ it("deriveCalendarSuggestionId pins the calendar_suggestion primary key", () => {
310
+ assert.equal(
311
+ deriveCalendarSuggestionId(
312
+ MESSAGE,
313
+ "bodypart-golden",
314
+ "golden-invite@example.com",
315
+ ),
316
+ "1pesdr3fl02uyoz5k1qrfik3e",
317
+ );
318
+ });
319
+
271
320
  it("quarantineMessageIdHash pins the stored Message-ID hash that correlates reports", () => {
272
321
  assert.equal(
273
322
  quarantineMessageIdHash("<golden@example.com>"),
package/src/id.ts CHANGED
@@ -64,6 +64,34 @@ export const deriveCalendarObjectId = (
64
64
  ): string =>
65
65
  base36uuidv5(`calendarobject:${calendarId}:${resourceName}`, REMIT_NAMESPACE);
66
66
 
67
+ /**
68
+ * Identity of a suggestion read out of a message (issue #1033). Derived from
69
+ * the message, the MIME part the bytes came from and the event's iCalendar
70
+ * UID, so re-reading the same message converges on the row it already wrote
71
+ * instead of stacking a second card on the message every pass.
72
+ *
73
+ * All three parts are needed. The message alone would merge two invitations
74
+ * sent in one mail; the part alone is not stable enough to key on across a
75
+ * re-parse; and the UID keeps two events sent as one part apart.
76
+ *
77
+ * The message is in the seed, so identity is per message, not per event: a
78
+ * later message carrying the same UID derives its own id, and the earlier
79
+ * suggestion survives beside it as `Superseded`. The user can see which
80
+ * revision they are being asked about, and the older message still has a card.
81
+ * The same holds for a plain resend — a redelivered copy is a new message and
82
+ * so a new card, which the SEQUENCE comparison then declines to supersede
83
+ * anything for.
84
+ */
85
+ export const deriveCalendarSuggestionId = (
86
+ messageId: string,
87
+ bodyPartId: string,
88
+ icalUid: string,
89
+ ): string =>
90
+ base36uuidv5(
91
+ `calendarsuggestion:${messageId}:${bodyPartId}:${icalUid}`,
92
+ REMIT_NAMESPACE,
93
+ );
94
+
67
95
  export const isValidMessageId = (messageId: string | undefined): boolean => {
68
96
  if (!messageId) return false;
69
97
  const trimmed = messageId.trim();
package/src/index.ts CHANGED
@@ -7,6 +7,10 @@ export type { IAddressRepository } from "./interfaces/address.js";
7
7
  export type { ICalendarCollectionRepository } from "./interfaces/calendar-collection.js";
8
8
  export type { ICalendarEventIndexRepository } from "./interfaces/calendar-event-index.js";
9
9
  export type { ICalendarObjectRepository } from "./interfaces/calendar-object.js";
10
+ export type {
11
+ ICalendarSuggestionRepository,
12
+ SettleCalendarSuggestionInput,
13
+ } from "./interfaces/calendar-suggestion.js";
10
14
  export type {
11
15
  CalendarUnitOfWorkRepositories,
12
16
  ICalendarUnitOfWork,
@@ -46,6 +50,11 @@ export type {
46
50
  IUnitOfWork,
47
51
  UnitOfWorkRepositories,
48
52
  } from "./interfaces/unit-of-work.js";
53
+ export {
54
+ FILTER_NO_ACTION,
55
+ isSenderMuted,
56
+ isSenderMuteFilter,
57
+ } from "./sender-mute.js";
49
58
  export type {
50
59
  AccountConfigDescription,
51
60
  AccountConfigItem,
@@ -68,6 +77,7 @@ export type {
68
77
  CalendarEventIndexItem,
69
78
  CalendarObjectItem,
70
79
  CalendarOccurrenceInput,
80
+ CalendarSuggestionItem,
71
81
  ConfigImportItem,
72
82
  ConfigImportUnresolvedRefItem,
73
83
  CreateAccountConfigInput,
@@ -113,6 +123,7 @@ export type {
113
123
  OutboxAttachmentItem,
114
124
  OutboxMessageItem,
115
125
  PutCalendarObjectInput,
126
+ PutCalendarSuggestionInput,
116
127
  PutMessageFlagPushInput,
117
128
  PutMessagePlacementMoveInput,
118
129
  QuarantineItem,
@@ -12,6 +12,18 @@ export interface ICalendarCollectionRepository {
12
12
  * request without a prior read.
13
13
  */
14
14
  create(input: CreateCalendarCollectionInput): Promise<CalendarCollectionItem>;
15
+ /**
16
+ * Writes the collection, or answers `null` when its `urlSegment` is already
17
+ * taken in this account config.
18
+ *
19
+ * The distinction from `create` is who asked. Provisioning wants the row
20
+ * whoever got there first wrote; a person naming a calendar wants to be told
21
+ * the name is taken, and handing them somebody else's calendar instead is a
22
+ * silent merge of two things they meant to keep apart.
23
+ */
24
+ createExclusive(
25
+ input: CreateCalendarCollectionInput,
26
+ ): Promise<CalendarCollectionItem | null>;
15
27
  get(
16
28
  accountConfigId: string,
17
29
  calendarId: string,
@@ -6,6 +6,16 @@ export interface ICalendarObjectRepository {
6
6
  calendarId: string,
7
7
  calendarObjectId: string,
8
8
  ): Promise<CalendarObjectItem>;
9
+ /**
10
+ * The resource under an id, or `null` when the collection does not hold it.
11
+ * `get` throws instead, which is right for a caller that already knows the
12
+ * resource exists; a request naming one is asking whether it does, and that
13
+ * is an answer rather than a fault.
14
+ */
15
+ find(
16
+ calendarId: string,
17
+ calendarObjectId: string,
18
+ ): Promise<CalendarObjectItem | null>;
9
19
  delete(calendarId: string, calendarObjectId: string): Promise<void>;
10
20
  findByResourceName(
11
21
  calendarId: string,
@@ -21,6 +31,17 @@ export interface ICalendarObjectRepository {
21
31
  icalUid: string,
22
32
  ): Promise<CalendarObjectItem | null>;
23
33
  listByCalendar(calendarId: string): Promise<CalendarObjectItem[]>;
34
+ /**
35
+ * The resources whose occurrence rows stop before `instant` — the series a
36
+ * range read has to expand for itself because the index does not reach that
37
+ * far. Narrow by construction: a resource that fits inside the expansion
38
+ * horizon carries no `expandedThrough` and never appears here, so a view of
39
+ * an ordinary calendar reads no iCalendar text at all.
40
+ */
41
+ listIncompleteExpansions(
42
+ calendarId: string,
43
+ instant: string,
44
+ ): Promise<CalendarObjectItem[]>;
24
45
  /**
25
46
  * Everything written to the collection after `syncSequence`, in change
26
47
  * order — the read behind a WebDAV-Sync report. Exclusive of the token
@@ -0,0 +1,73 @@
1
+ import type {
2
+ CalendarSuggestionItem,
3
+ PutCalendarSuggestionInput,
4
+ ResultList,
5
+ } from "../types.js";
6
+
7
+ /**
8
+ * The two fields that record what a person decided about a suggestion. They
9
+ * move together and only through {@link ICalendarSuggestionRepository.settle},
10
+ * so no producer write can walk an answered card back to `Pending`, and no
11
+ * state can name a calendar object that was never written.
12
+ *
13
+ * `acceptedCalendarObjectId` is `""` for every outcome but `Accepted` — the
14
+ * named absent state, never a missing attribute.
15
+ */
16
+ export interface SettleCalendarSuggestionInput {
17
+ state: CalendarSuggestionItem["state"];
18
+ acceptedCalendarObjectId: string;
19
+ }
20
+
21
+ export interface ICalendarSuggestionRepository {
22
+ /**
23
+ * Writes the suggestion under its derived `suggestionId`. An upsert: the
24
+ * producer re-reading the same message rewrites the row it already wrote.
25
+ *
26
+ * Never touches `state` or `acceptedCalendarObjectId` on a row that already
27
+ * exists, so a re-sync of a message whose invitation the user accepted
28
+ * leaves the acceptance intact. A row it creates starts `Pending`.
29
+ */
30
+ put(input: PutCalendarSuggestionInput): Promise<CalendarSuggestionItem>;
31
+ get(
32
+ accountConfigId: string,
33
+ suggestionId: string,
34
+ ): Promise<CalendarSuggestionItem>;
35
+ /** Every suggestion one message produced, superseded revisions included. */
36
+ listByMessage(
37
+ accountConfigId: string,
38
+ messageId: string,
39
+ ): Promise<CalendarSuggestionItem[]>;
40
+ /**
41
+ * The account's suggestions in one state, newest first. Also how the
42
+ * producer finds the revision a new message supersedes: only a `Pending`
43
+ * suggestion can be superseded, so the candidate set is this one.
44
+ */
45
+ listByState(
46
+ accountConfigId: string,
47
+ state: CalendarSuggestionItem["state"],
48
+ options?: { limit?: number; continuationToken?: string },
49
+ ): Promise<ResultList<CalendarSuggestionItem>>;
50
+ /**
51
+ * Records the outcome of a person's decision. Returns the settled row.
52
+ */
53
+ settle(
54
+ accountConfigId: string,
55
+ suggestionId: string,
56
+ input: SettleCalendarSuggestionInput,
57
+ ): Promise<CalendarSuggestionItem>;
58
+ /**
59
+ * Retires a card as `Superseded`, but only while it is still `Pending`.
60
+ * Returns the retired row, or `null` when the card had already been
61
+ * answered and nothing was written.
62
+ *
63
+ * A compare-and-set rather than a plain `settle` because the producer reads
64
+ * the pending set and writes to it in two steps, and a person can accept a
65
+ * card in between. An unconditional write there would overwrite their
66
+ * acceptance with `Superseded` and blank `acceptedCalendarObjectId`,
67
+ * severing the card from the event it put in their calendar.
68
+ */
69
+ supersedeIfPending(
70
+ accountConfigId: string,
71
+ suggestionId: string,
72
+ ): Promise<CalendarSuggestionItem | null>;
73
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ICalendarCollectionRepository } from "./calendar-collection.js";
2
2
  import type { ICalendarEventIndexRepository } from "./calendar-event-index.js";
3
3
  import type { ICalendarObjectRepository } from "./calendar-object.js";
4
+ import type { ICalendarSuggestionRepository } from "./calendar-suggestion.js";
4
5
 
5
6
  /**
6
7
  * The repositories one calendar write set touches, bound to a single unit of
@@ -10,6 +11,13 @@ export interface CalendarUnitOfWorkRepositories {
10
11
  calendarCollection: ICalendarCollectionRepository;
11
12
  calendarObject: ICalendarObjectRepository;
12
13
  calendarEventIndex: ICalendarEventIndexRepository;
14
+ /**
15
+ * Present so accepting a suggestion settles the card in the same unit that
16
+ * writes the resource (issue #1033). The two are one fact: a card still
17
+ * asking with the event already in the calendar, or a card marked accepted
18
+ * with no event behind it, is a state nothing later can repair.
19
+ */
20
+ calendarSuggestion: ICalendarSuggestionRepository;
13
21
  }
14
22
 
15
23
  /**
@@ -0,0 +1,76 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { isSenderMuted, isSenderMuteFilter } from "./sender-mute.js";
4
+ import type { FilterItem } from "./types.js";
5
+
6
+ const rule = (overrides: Partial<FilterItem> = {}): FilterItem =>
7
+ ({
8
+ scope: "Standing",
9
+ literalClauses: [{ field: "From", value: "ada@example.test" }],
10
+ actionLabelId: "None",
11
+ actionMailboxId: "None",
12
+ ...overrides,
13
+ }) as unknown as FilterItem;
14
+
15
+ describe("isSenderMuteFilter", () => {
16
+ it("recognises a standing rule that names a sender and does nothing", () => {
17
+ assert.equal(isSenderMuteFilter(rule()), true);
18
+ });
19
+
20
+ it("declines a rule that applies a label", () => {
21
+ assert.equal(isSenderMuteFilter(rule({ actionLabelId: "label-1" })), false);
22
+ });
23
+
24
+ it("declines a rule that moves the message", () => {
25
+ assert.equal(
26
+ isSenderMuteFilter(rule({ actionMailboxId: "mailbox-1" })),
27
+ false,
28
+ );
29
+ });
30
+
31
+ it("declines a rule that matches on something other than the sender", () => {
32
+ assert.equal(
33
+ isSenderMuteFilter(
34
+ rule({
35
+ literalClauses: [{ field: "Subject", value: "invitation" }],
36
+ } as Partial<FilterItem>),
37
+ ),
38
+ false,
39
+ );
40
+ });
41
+
42
+ it("declines a rule with no clauses, which would mute everybody", () => {
43
+ assert.equal(isSenderMuteFilter(rule({ literalClauses: [] })), false);
44
+ });
45
+
46
+ it("declines a temporary rule", () => {
47
+ assert.equal(isSenderMuteFilter(rule({ scope: "Temporary" })), false);
48
+ });
49
+ });
50
+
51
+ describe("isSenderMuted", () => {
52
+ it("matches an address whatever case either side was written in", () => {
53
+ assert.equal(isSenderMuted([rule()], "Ada@Example.TEST"), true);
54
+ });
55
+
56
+ it("matches the whole address, never a substring of one", () => {
57
+ // A rule about ada@example.test must not silence an address that merely
58
+ // contains it — the lookalike is exactly what a spoofer would send from.
59
+ assert.equal(
60
+ isSenderMuted([rule()], "not-ada@example.test.evil.test"),
61
+ false,
62
+ );
63
+ });
64
+
65
+ it("says no when nothing names the sender", () => {
66
+ assert.equal(isSenderMuted([rule()], "someone@example.test"), false);
67
+ });
68
+
69
+ it("says no for an empty sender rather than matching an empty clause", () => {
70
+ assert.equal(isSenderMuted([rule()], ""), false);
71
+ });
72
+
73
+ it("says no with no rules at all", () => {
74
+ assert.equal(isSenderMuted([], "ada@example.test"), false);
75
+ });
76
+ });
@@ -0,0 +1,59 @@
1
+ import { FilterClauseField, FilterScope } from "@remit/domain-enums";
2
+ import type { FilterItem } from "./types.js";
3
+
4
+ /**
5
+ * The `Filter` model's sentinel for "this rule takes no such action" — the
6
+ * value `actionLabelId` and `actionMailboxId` hold when a filter applies no
7
+ * label and moves nothing (RFC 034 Decision 3.1).
8
+ */
9
+ export const FILTER_NO_ACTION = "None";
10
+
11
+ /**
12
+ * A standing rule that names senders and does nothing else.
13
+ *
14
+ * That combination has exactly one meaning. The index-time filter pipeline
15
+ * skips a filter with no label and no move, so such a rule changes nothing
16
+ * about where mail lands or how it is tagged; the only thing it can express is
17
+ * the user saying "not from this sender". `dismiss{muteSender:true}` writes
18
+ * one, and the calendar-suggestion producer reads it.
19
+ *
20
+ * Structural, never name-matching: the rule is recognised by its shape, so a
21
+ * rename in the settings UI cannot quietly un-mute a sender.
22
+ */
23
+ export const isSenderMuteFilter = (
24
+ filter: Pick<
25
+ FilterItem,
26
+ "scope" | "literalClauses" | "actionLabelId" | "actionMailboxId"
27
+ >,
28
+ ): boolean =>
29
+ filter.scope === FilterScope.Standing &&
30
+ filter.actionLabelId === FILTER_NO_ACTION &&
31
+ filter.actionMailboxId === FILTER_NO_ACTION &&
32
+ filter.literalClauses.length > 0 &&
33
+ filter.literalClauses.every(
34
+ (clause) =>
35
+ clause.field === FilterClauseField.From && clause.value.trim() !== "",
36
+ );
37
+
38
+ const fold = (value: string): string => value.trim().toLowerCase();
39
+
40
+ /**
41
+ * Whether one of these rules mutes an address. Compared case-insensitively and
42
+ * whole: a mail address is one value, and a substring match would let a rule
43
+ * about `ada@example.com` silence `not-ada@example.com.evil.test`.
44
+ */
45
+ export const isSenderMuted = (
46
+ filters: readonly Pick<
47
+ FilterItem,
48
+ "scope" | "literalClauses" | "actionLabelId" | "actionMailboxId"
49
+ >[],
50
+ sender: string,
51
+ ): boolean => {
52
+ const wanted = fold(sender);
53
+ if (wanted === "") return false;
54
+ return filters.some(
55
+ (filter) =>
56
+ isSenderMuteFilter(filter) &&
57
+ filter.literalClauses.some((clause) => fold(clause.value) === wanted),
58
+ );
59
+ };
package/src/types.ts CHANGED
@@ -9,6 +9,7 @@ import type {
9
9
  CalendarCollectionSchema,
10
10
  CalendarEventIndexSchema,
11
11
  CalendarObjectSchema,
12
+ CalendarSuggestionSchema,
12
13
  ConfigImportSchema,
13
14
  ConfigImportUnresolvedRefSchema,
14
15
  EnvelopeAddressSchema,
@@ -607,6 +608,29 @@ export type CalendarOccurrenceInput = Omit<
607
608
  "calendarId" | "calendarObjectId" | "createdAt" | "updatedAt"
608
609
  >;
609
610
 
611
+ export type CalendarSuggestionItem = z.infer<typeof CalendarSuggestionSchema>;
612
+
613
+ /**
614
+ * A whole suggestion, as the producer read it out of a message. Every field is
615
+ * derived from the message — nothing here is client-supplied — and
616
+ * `suggestionId` is derived from messageId + bodyPartId + icalUid, so this is
617
+ * an upsert: re-reading the same message rewrites its own row rather than
618
+ * stacking a second card on the message.
619
+ *
620
+ * `state` and `acceptedCalendarObjectId` are excluded: a producer only ever
621
+ * writes a `Pending` suggestion, and the two fields that record what a person
622
+ * decided move exclusively through `settle`, so a re-sync can never walk an
623
+ * answered card back to Pending.
624
+ */
625
+ export type PutCalendarSuggestionInput = Omit<
626
+ CalendarSuggestionItem,
627
+ | "suggestionId"
628
+ | "state"
629
+ | "acceptedCalendarObjectId"
630
+ | "createdAt"
631
+ | "updatedAt"
632
+ >;
633
+
610
634
  export type SenderSignerStandingItem = z.infer<
611
635
  typeof SenderSignerStandingSchema
612
636
  >;