@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 +1 -1
- package/src/handlers/calendar-suggestion.test.ts +279 -0
- package/src/handlers/calendar-suggestion.ts +289 -0
- package/src/handlers/calendar.test.ts +111 -5
- package/src/handlers/index.ts +8 -0
- package/src/service/compose-sqlite.ts +2 -0
- package/src/service/create-remit-client.ts +20 -6
- package/src/types.ts +21 -1
package/package.json
CHANGED
|
@@ -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
|
}
|
package/src/handlers/index.ts
CHANGED
|
@@ -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)
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
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> = (
|