@remit/backend 0.0.90 → 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 +2 -1
- package/src/handlers/calendar-event.ts +668 -0
- package/src/handlers/calendar-suggestion.test.ts +279 -0
- package/src/handlers/calendar-suggestion.ts +289 -0
- package/src/handlers/calendar.test.ts +982 -0
- package/src/handlers/calendar.ts +384 -0
- package/src/handlers/index.ts +19 -0
- package/src/service/compose-sqlite.ts +10 -0
- package/src/service/create-remit-client.ts +37 -0
- package/src/types.ts +57 -1
|
@@ -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
|
+
};
|