@remit/backend 0.0.136 → 0.0.138

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/backend",
3
- "version": "0.0.136",
3
+ "version": "0.0.138",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -13,6 +13,7 @@ import {
13
13
  type CalendarInstance,
14
14
  type CalendarWindow,
15
15
  deleteCalendarObject,
16
+ followingUidOf,
16
17
  listBusySpans,
17
18
  listCalendarInstances,
18
19
  listObjectInstances,
@@ -445,7 +446,10 @@ export const updateCalendarEventFor = async (
445
446
  {
446
447
  scope: request.scope,
447
448
  recurrenceId: request.recurrenceId,
448
- followingUid: `${deps.newId()}@reader.remit`,
449
+ followingUid: followingUidOf(
450
+ object.icalUid,
451
+ `${deps.newId()}@reader.remit`,
452
+ ),
449
453
  },
450
454
  patch,
451
455
  );
@@ -1,20 +1,27 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { after, before, describe, test } from "node:test";
3
+ import {
4
+ acceptCalendarSuggestion,
5
+ reopenCalendarSuggestion,
6
+ } from "@remit/calendar-service";
3
7
  import type {
4
8
  CalendarSuggestionItem,
5
9
  CreateFilterInput,
6
10
  FilterItem,
7
11
  ICalendarSuggestionRepository,
12
+ ICalendarUnitOfWork,
8
13
  MessageData,
9
14
  PutCalendarSuggestionInput,
10
15
  SettleCalendarSuggestionInput,
11
16
  } from "@remit/data-ports";
12
17
  import {
18
+ CalendarAnswerOvertaken,
13
19
  CalendarInviteAnswer,
14
20
  CalendarInviteMethod,
15
21
  CalendarSuggestionSource,
16
22
  CalendarSuggestionState,
17
23
  FilterState,
24
+ RecurrenceScope,
18
25
  } from "@remit/domain-enums";
19
26
  import type { APIGatewayProxyEvent } from "aws-lambda";
20
27
  import type { Context } from "openapi-backend";
@@ -24,11 +31,17 @@ import {
24
31
  type RemitClient,
25
32
  setClient,
26
33
  } from "../service/data-client.js";
34
+ import { calendarDepsOf } from "./calendar.js";
35
+ import {
36
+ calendarEventDepsOf,
37
+ updateCalendarEventFor,
38
+ } from "./calendar-event.js";
27
39
  import { createCalendarSqliteClient } from "./calendar-sqlite-fixture.js";
28
40
  import {
29
41
  assertSettleable,
30
42
  CalendarSuggestionActionOperations,
31
43
  CalendarSuggestionOperations,
44
+ MessageCalendarSuggestionOperations,
32
45
  type MuteSenderDeps,
33
46
  muteSender,
34
47
  settleSuggestion,
@@ -58,6 +71,7 @@ const suggestion = (
58
71
  zoneCertainty: "Explicit",
59
72
  icalData: "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n",
60
73
  acceptedCalendarObjectId: "",
74
+ supersededByMessageId: "",
61
75
  createdAt: 1,
62
76
  updatedAt: 1,
63
77
  ...overrides,
@@ -133,7 +147,10 @@ const muteRule = (sender: string): FilterItem =>
133
147
 
134
148
  describe("toCalendarSuggestionResponse", () => {
135
149
  test("keeps the raw invitation bytes on the server", async () => {
136
- const response = toCalendarSuggestionResponse(suggestion());
150
+ const response = toCalendarSuggestionResponse(
151
+ suggestion(),
152
+ CalendarAnswerOvertaken.None,
153
+ );
137
154
 
138
155
  assert.equal("icalData" in response, false);
139
156
  assert.equal(response.summary, "Quarterly review");
@@ -308,12 +325,40 @@ const acceptSuggestion =
308
325
  CalendarSuggestionActionOperations.CalendarSuggestionActionOperations_acceptCalendarSuggestion as Handler;
309
326
  const dismissSuggestion =
310
327
  CalendarSuggestionActionOperations.CalendarSuggestionActionOperations_dismissCalendarSuggestion as Handler;
328
+ const listMessageSuggestions =
329
+ MessageCalendarSuggestionOperations.MessageCalendarSuggestionOperations_listMessageCalendarSuggestions as Handler;
330
+ const reopenSuggestion =
331
+ CalendarSuggestionActionOperations.CalendarSuggestionActionOperations_reopenCalendarSuggestion as Handler;
311
332
 
312
333
  interface Card {
313
334
  suggestionId: string;
314
335
  state: string;
336
+ supersededByMessageId: string;
337
+ supersededByThreadId: string;
315
338
  }
316
339
 
340
+ const fileInThread = async (
341
+ accountConfigId: string,
342
+ messageId: string,
343
+ threadId: string,
344
+ ): Promise<void> => {
345
+ await client.threadMessage.create({
346
+ accountConfigId,
347
+ threadId,
348
+ messageId,
349
+ mailboxId: "mbx-inbox",
350
+ uid: 1,
351
+ referenceOrder: 0,
352
+ internalDate: 1,
353
+ sentDate: 1,
354
+ subject: "Invitation: Quarterly review",
355
+ isRead: false,
356
+ isDeleted: false,
357
+ hasAttachment: false,
358
+ hasStars: false,
359
+ });
360
+ };
361
+
317
362
  let client: RemitClient;
318
363
  let cleanup: () => void;
319
364
  let mintedSubs = 0;
@@ -473,6 +518,74 @@ describe("GET /calendar-suggestions", () => {
473
518
  });
474
519
  });
475
520
 
521
+ describe("GET /messages/{messageId}/calendar-suggestions", () => {
522
+ test("names the conversation of the revision that retired a card", async () => {
523
+ const { accountConfigId, event } = anAccount();
524
+ const older = await putSuggestion(accountConfigId, "msg-revision-0");
525
+ await client.calendarSuggestion.supersedeIfPending(
526
+ accountConfigId,
527
+ older.suggestionId,
528
+ "msg-revision-1",
529
+ );
530
+ await fileInThread(accountConfigId, "msg-revision-1", "thread-revision-1");
531
+
532
+ const listed = (await listMessageSuggestions(
533
+ contextOf({ params: { messageId: "msg-revision-0" } }),
534
+ event,
535
+ )) as unknown as { items: Card[] };
536
+
537
+ assert.deepEqual(
538
+ listed.items.map((card) => [
539
+ card.state,
540
+ card.supersededByMessageId,
541
+ card.supersededByThreadId,
542
+ ]),
543
+ [
544
+ [
545
+ CalendarSuggestionState.Superseded,
546
+ "msg-revision-1",
547
+ "thread-revision-1",
548
+ ],
549
+ ],
550
+ );
551
+ });
552
+
553
+ test("names no conversation when the newer message sits in none", async () => {
554
+ const { accountConfigId, event } = anAccount();
555
+ const older = await putSuggestion(accountConfigId, "msg-orphan-0");
556
+ await client.calendarSuggestion.supersedeIfPending(
557
+ accountConfigId,
558
+ older.suggestionId,
559
+ "msg-orphan-1",
560
+ );
561
+
562
+ const listed = (await listMessageSuggestions(
563
+ contextOf({ params: { messageId: "msg-orphan-0" } }),
564
+ event,
565
+ )) as unknown as { items: Card[] };
566
+
567
+ assert.equal(listed.items[0]?.supersededByThreadId, "");
568
+ });
569
+
570
+ test("names no conversation for a card nothing retired", async () => {
571
+ const { accountConfigId, event } = anAccount();
572
+ await putSuggestion(accountConfigId, "msg-current");
573
+
574
+ const listed = (await listMessageSuggestions(
575
+ contextOf({ params: { messageId: "msg-current" } }),
576
+ event,
577
+ )) as unknown as { items: Card[] };
578
+
579
+ assert.deepEqual(
580
+ listed.items.map((card) => [
581
+ card.supersededByMessageId,
582
+ card.supersededByThreadId,
583
+ ]),
584
+ [["", ""]],
585
+ );
586
+ });
587
+ });
588
+
476
589
  describe("POST /calendar-suggestions/{suggestionId}/accept", () => {
477
590
  test("answers not-found for a calendar on another account, before writing anything", async () => {
478
591
  const stranger = anAccount();
@@ -668,3 +781,382 @@ describe("POST /calendar-suggestions/{suggestionId}/accept with corrections", ()
668
781
  );
669
782
  });
670
783
  });
784
+
785
+ describe("POST /calendar-suggestions/{suggestionId}/reopen", () => {
786
+ const answeredInto = async (
787
+ accountConfigId: string,
788
+ messageId: string,
789
+ answer: (typeof CalendarInviteAnswer)[keyof typeof CalendarInviteAnswer],
790
+ ): Promise<{ calendarId: string; card: CalendarSuggestionItem }> => {
791
+ const calendar = await client.calendarCollection.create({
792
+ accountConfigId,
793
+ urlSegment: "default",
794
+ displayName: "Calendar",
795
+ });
796
+ const accepted = await acceptCalendarSuggestion(client.calendarUnitOfWork, {
797
+ accountConfigId,
798
+ calendarId: calendar.calendarId,
799
+ suggestion: await putSuggestion(accountConfigId, messageId),
800
+ attendee: "user@example.test",
801
+ answer,
802
+ });
803
+ assert.ok(accepted.ok);
804
+ return { calendarId: calendar.calendarId, card: accepted.value.suggestion };
805
+ };
806
+
807
+ for (const answer of [
808
+ CalendarInviteAnswer.Accepted,
809
+ CalendarInviteAnswer.Tentative,
810
+ ]) {
811
+ test(`takes a ${answer} answer back and removes the event it wrote`, async () => {
812
+ const { accountConfigId, event } = anAccount();
813
+ const { calendarId, card } = await answeredInto(
814
+ accountConfigId,
815
+ `msg-reopen-${answer}`,
816
+ answer,
817
+ );
818
+ assert.equal(
819
+ (await client.calendarObject.listByCalendar(calendarId)).length,
820
+ 1,
821
+ );
822
+
823
+ const reopened = (await reopenSuggestion(
824
+ contextOf({ params: { suggestionId: card.suggestionId } }),
825
+ event,
826
+ )) as unknown as CalendarSuggestionItem;
827
+
828
+ assert.equal(reopened.state, CalendarSuggestionState.Pending);
829
+ assert.equal(reopened.acceptedCalendarObjectId, "");
830
+ assert.deepEqual(
831
+ await client.calendarObject.listByCalendar(calendarId),
832
+ [],
833
+ );
834
+ const stored = await client.calendarSuggestion.get(
835
+ accountConfigId,
836
+ card.suggestionId,
837
+ );
838
+ assert.equal(stored.state, CalendarSuggestionState.Pending);
839
+ });
840
+ }
841
+
842
+ for (const state of [
843
+ CalendarSuggestionState.Declined,
844
+ CalendarSuggestionState.Dismissed,
845
+ ]) {
846
+ test(`returns a ${state} card to Pending`, async () => {
847
+ const { accountConfigId, event } = anAccount();
848
+ const card = await putSuggestion(accountConfigId, `msg-reopen-${state}`);
849
+ await client.calendarSuggestion.settle(
850
+ accountConfigId,
851
+ card.suggestionId,
852
+ { state, acceptedCalendarObjectId: "" },
853
+ );
854
+
855
+ const reopened = (await reopenSuggestion(
856
+ contextOf({ params: { suggestionId: card.suggestionId } }),
857
+ event,
858
+ )) as unknown as Card;
859
+
860
+ assert.equal(reopened.state, CalendarSuggestionState.Pending);
861
+ });
862
+ }
863
+
864
+ test("refuses a superseded card and leaves it as it is", async () => {
865
+ const { accountConfigId, event } = anAccount();
866
+ const card = await putSuggestion(accountConfigId, "msg-reopen-superseded");
867
+ await client.calendarSuggestion.supersedeIfPending(
868
+ accountConfigId,
869
+ card.suggestionId,
870
+ "msg-reopen-newer",
871
+ );
872
+
873
+ await assert.rejects(
874
+ () =>
875
+ reopenSuggestion(
876
+ contextOf({ params: { suggestionId: card.suggestionId } }),
877
+ event,
878
+ ),
879
+ (error: unknown) => (error as { statusCode?: number }).statusCode === 400,
880
+ );
881
+
882
+ const untouched = await client.calendarSuggestion.get(
883
+ accountConfigId,
884
+ card.suggestionId,
885
+ );
886
+ assert.equal(untouched.state, CalendarSuggestionState.Superseded);
887
+ });
888
+
889
+ test("refuses a cancellation, whose answer it cannot undo", async () => {
890
+ const { accountConfigId, event } = anAccount();
891
+ const card = await putSuggestion(
892
+ accountConfigId,
893
+ "msg-reopen-cancel",
894
+ CalendarInviteMethod.Cancel,
895
+ );
896
+ await client.calendarSuggestion.settle(accountConfigId, card.suggestionId, {
897
+ state: CalendarSuggestionState.Dismissed,
898
+ acceptedCalendarObjectId: "",
899
+ });
900
+
901
+ await assert.rejects(
902
+ () =>
903
+ reopenSuggestion(
904
+ contextOf({ params: { suggestionId: card.suggestionId } }),
905
+ event,
906
+ ),
907
+ (error: unknown) => (error as { statusCode?: number }).statusCode === 400,
908
+ );
909
+ });
910
+
911
+ test("answers not-found for a card on another account", async () => {
912
+ const stranger = anAccount();
913
+ const { card } = await answeredInto(
914
+ stranger.accountConfigId,
915
+ "msg-reopen-stranger",
916
+ CalendarInviteAnswer.Accepted,
917
+ );
918
+ const { event } = anAccount();
919
+
920
+ await assert.rejects(
921
+ () =>
922
+ reopenSuggestion(
923
+ contextOf({ params: { suggestionId: card.suggestionId } }),
924
+ event,
925
+ ),
926
+ (error: unknown) => (error as { statusCode?: number }).statusCode === 404,
927
+ );
928
+ const untouched = await client.calendarSuggestion.get(
929
+ stranger.accountConfigId,
930
+ card.suggestionId,
931
+ );
932
+ assert.equal(untouched.state, CalendarSuggestionState.Accepted);
933
+ });
934
+ });
935
+
936
+ describe("POST /calendar-suggestions/{suggestionId}/reopen, against what else holds the event", () => {
937
+ const recurring = (sequence: number): string =>
938
+ INVITATION.replace(
939
+ "SUMMARY:Quarterly review",
940
+ `SEQUENCE:${sequence}\r\nRRULE:FREQ=WEEKLY;COUNT=4\r\nSUMMARY:Quarterly review`,
941
+ );
942
+
943
+ const putInvite = (
944
+ accountConfigId: string,
945
+ messageId: string,
946
+ sequence: number,
947
+ ): Promise<CalendarSuggestionItem> =>
948
+ client.calendarSuggestion.put({
949
+ accountConfigId,
950
+ messageId,
951
+ bodyPartId: "part-1",
952
+ icalUid: "invite@example.test",
953
+ sequence,
954
+ method: CalendarInviteMethod.Request,
955
+ source: CalendarSuggestionSource.IcalendarPart,
956
+ summary: "Quarterly review",
957
+ dtStart: "2026-09-01T10:00:00+02:00",
958
+ dtEnd: "2026-09-01T11:00:00+02:00",
959
+ allDay: false,
960
+ location: "Room 4",
961
+ organizer: "organizer@example.test",
962
+ zoneCertainty: "Explicit",
963
+ icalData: recurring(sequence),
964
+ } as PutCalendarSuggestionInput);
965
+
966
+ const acceptInto = async (
967
+ accountConfigId: string,
968
+ calendarId: string,
969
+ suggestion: CalendarSuggestionItem,
970
+ ): Promise<CalendarSuggestionItem> => {
971
+ const accepted = await acceptCalendarSuggestion(client.calendarUnitOfWork, {
972
+ accountConfigId,
973
+ calendarId,
974
+ suggestion,
975
+ attendee: "user@example.test",
976
+ answer: CalendarInviteAnswer.Accepted,
977
+ });
978
+ assert.ok(accepted.ok);
979
+ return accepted.value.suggestion;
980
+ };
981
+
982
+ const calendarOf = (accountConfigId: string) =>
983
+ client.calendarCollection.create({
984
+ accountConfigId,
985
+ urlSegment: "default",
986
+ displayName: "Calendar",
987
+ });
988
+
989
+ test("refuses a request's answer once its cancellation was answered into the event, and says so on the card", async () => {
990
+ const { accountConfigId, event } = anAccount();
991
+ const calendar = await calendarOf(accountConfigId);
992
+ const first = await acceptInto(
993
+ accountConfigId,
994
+ calendar.calendarId,
995
+ await putInvite(accountConfigId, "msg-overtaken-0", 0),
996
+ );
997
+ await acceptInto(
998
+ accountConfigId,
999
+ calendar.calendarId,
1000
+ await putSuggestion(
1001
+ accountConfigId,
1002
+ "msg-overtaken-cancel",
1003
+ CalendarInviteMethod.Cancel,
1004
+ ),
1005
+ );
1006
+
1007
+ await assert.rejects(
1008
+ () =>
1009
+ reopenSuggestion(
1010
+ contextOf({ params: { suggestionId: first.suggestionId } }),
1011
+ event,
1012
+ ),
1013
+ (error: unknown) => (error as { statusCode?: number }).statusCode === 400,
1014
+ );
1015
+
1016
+ assert.equal(
1017
+ (await client.calendarObject.listByCalendar(calendar.calendarId)).length,
1018
+ 1,
1019
+ );
1020
+ const listed = (await listMessageSuggestions(
1021
+ contextOf({ params: { messageId: "msg-overtaken-0" } }),
1022
+ event,
1023
+ )) as unknown as { items: { answerOvertakenBy: string }[] };
1024
+ assert.deepEqual(
1025
+ listed.items.map((item) => item.answerOvertakenBy),
1026
+ [CalendarAnswerOvertaken.Cancellation],
1027
+ );
1028
+ });
1029
+
1030
+ test("takes back both halves of a this-and-following split, and a re-add writes one event", async () => {
1031
+ const { accountConfigId, event } = anAccount();
1032
+ const calendar = await calendarOf(accountConfigId);
1033
+ const card = await acceptInto(
1034
+ accountConfigId,
1035
+ calendar.calendarId,
1036
+ await putInvite(accountConfigId, "msg-split", 0),
1037
+ );
1038
+ const split = await updateCalendarEventFor(
1039
+ calendarEventDepsOf(calendarDepsOf(client)),
1040
+ accountConfigId,
1041
+ {
1042
+ calendarId: calendar.calendarId,
1043
+ calendarObjectId: card.acceptedCalendarObjectId,
1044
+ scope: RecurrenceScope.Following,
1045
+ recurrenceId: "2026-09-15T08:00:00Z",
1046
+ ifMatch: undefined,
1047
+ },
1048
+ { summary: "Quarterly review (moved)" },
1049
+ );
1050
+ assert.ok(split.ok, JSON.stringify(split));
1051
+ assert.equal(
1052
+ (await client.calendarObject.listByCalendar(calendar.calendarId)).length,
1053
+ 2,
1054
+ );
1055
+
1056
+ const reopened = (await reopenSuggestion(
1057
+ contextOf({ params: { suggestionId: card.suggestionId } }),
1058
+ event,
1059
+ )) as unknown as CalendarSuggestionItem;
1060
+
1061
+ assert.equal(reopened.state, CalendarSuggestionState.Pending);
1062
+ assert.deepEqual(
1063
+ await client.calendarObject.listByCalendar(calendar.calendarId),
1064
+ [],
1065
+ );
1066
+ await acceptInto(accountConfigId, calendar.calendarId, {
1067
+ ...card,
1068
+ ...reopened,
1069
+ });
1070
+ assert.equal(
1071
+ (await client.calendarObject.listByCalendar(calendar.calendarId)).length,
1072
+ 1,
1073
+ );
1074
+ });
1075
+
1076
+ test("keeps the sender rule a mute wrote", async () => {
1077
+ const { accountConfigId, event } = anAccount();
1078
+ const card = await putSuggestion(accountConfigId, "msg-mute-reopen");
1079
+ await seedMessageFrom("msg-mute-reopen", "organizer@example.test");
1080
+ await dismissSuggestion(
1081
+ contextOf({
1082
+ params: { suggestionId: card.suggestionId },
1083
+ requestBody: { muteSender: true },
1084
+ }),
1085
+ event,
1086
+ );
1087
+
1088
+ const reopened = (await reopenSuggestion(
1089
+ contextOf({ params: { suggestionId: card.suggestionId } }),
1090
+ event,
1091
+ )) as unknown as Card;
1092
+
1093
+ assert.equal(reopened.state, CalendarSuggestionState.Pending);
1094
+ const rules = await client.filter.listByAccountAndState(
1095
+ accountConfigId,
1096
+ FilterState.Active,
1097
+ );
1098
+ assert.deepEqual(
1099
+ rules.map((rule) => rule.literalClauses),
1100
+ [[{ field: "From", value: "organizer@example.test" }]],
1101
+ );
1102
+ });
1103
+
1104
+ test("a delete that fails part-way leaves the event and the answer as they were", async () => {
1105
+ const { accountConfigId } = anAccount();
1106
+ const calendar = await calendarOf(accountConfigId);
1107
+ const card = await acceptInto(
1108
+ accountConfigId,
1109
+ calendar.calendarId,
1110
+ await putInvite(accountConfigId, "msg-rollback", 0),
1111
+ );
1112
+ const failing: ICalendarUnitOfWork = {
1113
+ transaction: (fn) =>
1114
+ client.calendarUnitOfWork.transaction((repos) =>
1115
+ fn({
1116
+ ...repos,
1117
+ calendarCollection: Object.assign(
1118
+ Object.create(repos.calendarCollection),
1119
+ {
1120
+ bumpSyncSequence: async () => {
1121
+ throw new Error("disk full");
1122
+ },
1123
+ },
1124
+ ),
1125
+ }),
1126
+ ),
1127
+ };
1128
+
1129
+ await assert.rejects(
1130
+ () =>
1131
+ reopenCalendarSuggestion(failing, {
1132
+ accountConfigId,
1133
+ suggestion: card,
1134
+ }),
1135
+ /disk full/,
1136
+ );
1137
+
1138
+ const object = await client.calendarObject.find(
1139
+ calendar.calendarId,
1140
+ card.acceptedCalendarObjectId,
1141
+ );
1142
+ assert.ok(object, "the event is still on the calendar");
1143
+ assert.ok(
1144
+ (
1145
+ await client.calendarEventIndex.listForObject(
1146
+ calendar.calendarId,
1147
+ card.acceptedCalendarObjectId,
1148
+ )
1149
+ ).length > 0,
1150
+ "and so are its occurrences",
1151
+ );
1152
+ const untouched = await client.calendarSuggestion.get(
1153
+ accountConfigId,
1154
+ card.suggestionId,
1155
+ );
1156
+ assert.equal(untouched.state, CalendarSuggestionState.Accepted);
1157
+ assert.equal(
1158
+ untouched.acceptedCalendarObjectId,
1159
+ card.acceptedCalendarObjectId,
1160
+ );
1161
+ });
1162
+ });
@@ -5,13 +5,16 @@ import type {
5
5
  } from "@remit/api-openapi-types";
6
6
  import {
7
7
  acceptCalendarSuggestion,
8
+ answersOvertakenBy,
8
9
  correctCalendarSuggestion,
10
+ reopenCalendarSuggestion,
9
11
  } from "@remit/calendar-service";
10
12
  import {
11
13
  type CalendarSuggestionItem,
12
14
  type ICalendarSuggestionRepository,
13
15
  type IEnvelopeRepository,
14
16
  type IFilterRepository,
17
+ type IThreadMessageRepository,
15
18
  isSenderMuted,
16
19
  } from "@remit/data-ports";
17
20
  import { BadRequestError } from "@remit/data-ports/errors";
@@ -43,8 +46,51 @@ import { pickEventUpdate } from "./calendar-event.js";
43
46
  */
44
47
  export const toCalendarSuggestionResponse = (
45
48
  item: CalendarSuggestionItem,
49
+ answerOvertakenBy: CalendarSuggestionResponse["answerOvertakenBy"],
46
50
  ): CalendarSuggestionResponse => {
47
51
  const { icalData: _icalData, ...response } = item;
52
+ return { ...response, supersededByThreadId: "", answerOvertakenBy };
53
+ };
54
+
55
+ export const toCalendarSuggestionResponses = async (
56
+ deps: {
57
+ threadMessage: Pick<IThreadMessageRepository, "findByMessageId">;
58
+ calendarSuggestion: Pick<
59
+ ICalendarSuggestionRepository,
60
+ "listByAcceptedCalendarObjects"
61
+ >;
62
+ },
63
+ accountConfigId: string,
64
+ items: CalendarSuggestionItem[],
65
+ ): Promise<CalendarSuggestionResponse[]> =>
66
+ Promise.all(
67
+ (
68
+ await answersOvertakenBy(deps.calendarSuggestion, accountConfigId, items)
69
+ ).map(async ({ suggestion, answerOvertakenBy }) => {
70
+ const response = toCalendarSuggestionResponse(
71
+ suggestion,
72
+ answerOvertakenBy,
73
+ );
74
+ if (suggestion.supersededByMessageId === "") return response;
75
+ const newer = await deps.threadMessage.findByMessageId(
76
+ accountConfigId,
77
+ suggestion.supersededByMessageId,
78
+ );
79
+ return { ...response, supersededByThreadId: newer?.threadId ?? "" };
80
+ }),
81
+ );
82
+
83
+ const respondOne = async (
84
+ client: RemitClient,
85
+ accountConfigId: string,
86
+ item: CalendarSuggestionItem,
87
+ ): Promise<CalendarSuggestionResponse> => {
88
+ const [response] = await toCalendarSuggestionResponses(
89
+ client,
90
+ accountConfigId,
91
+ [item],
92
+ );
93
+ if (!response) throw new Error("unreachable: one item in, one response out");
48
94
  return response;
49
95
  };
50
96
 
@@ -177,7 +223,11 @@ export const CalendarSuggestionOperations: Record<
177
223
  );
178
224
 
179
225
  return {
180
- items: page.items.map(toCalendarSuggestionResponse),
226
+ items: await toCalendarSuggestionResponses(
227
+ client,
228
+ accountConfigId,
229
+ page.items,
230
+ ),
181
231
  continuationToken: page.continuationToken,
182
232
  };
183
233
  },
@@ -202,7 +252,11 @@ export const MessageCalendarSuggestionOperations: Record<
202
252
  );
203
253
 
204
254
  return {
205
- items: items.map(toCalendarSuggestionResponse),
255
+ items: await toCalendarSuggestionResponses(
256
+ client,
257
+ accountConfigId,
258
+ items,
259
+ ),
206
260
  continuationToken: undefined,
207
261
  };
208
262
  },
@@ -286,7 +340,7 @@ export const CalendarSuggestionActionOperations: Record<
286
340
  throw new BadRequestError(accepted.error.message);
287
341
  }
288
342
 
289
- return toCalendarSuggestionResponse(accepted.value.suggestion);
343
+ return respondOne(client, accountConfigId, accepted.value.suggestion);
290
344
  },
291
345
 
292
346
  CalendarSuggestionActionOperations_declineCalendarSuggestion: async (
@@ -307,7 +361,7 @@ export const CalendarSuggestionActionOperations: Record<
307
361
  CalendarSuggestionState.Declined,
308
362
  );
309
363
 
310
- return toCalendarSuggestionResponse(declined);
364
+ return respondOne(client, accountConfigId, declined);
311
365
  },
312
366
 
313
367
  CalendarSuggestionActionOperations_dismissCalendarSuggestion: async (
@@ -334,6 +388,32 @@ export const CalendarSuggestionActionOperations: Record<
334
388
  await muteSender(client, accountConfigId, dismissed.messageId);
335
389
  }
336
390
 
337
- return toCalendarSuggestionResponse(dismissed);
391
+ return respondOne(client, accountConfigId, dismissed);
392
+ },
393
+
394
+ CalendarSuggestionActionOperations_reopenCalendarSuggestion: async (
395
+ context,
396
+ ...args: unknown[]
397
+ ) => {
398
+ const event = args[0] as APIGatewayProxyEvent;
399
+ const accountConfigId = getAccountConfigIdFromEvent(event);
400
+ const { suggestionId } = context.request.params as {
401
+ suggestionId: string;
402
+ };
403
+
404
+ const client = await getClient();
405
+ const suggestion = await client.calendarSuggestion.get(
406
+ accountConfigId,
407
+ suggestionId,
408
+ );
409
+ const reopened = await reopenCalendarSuggestion(client.calendarUnitOfWork, {
410
+ accountConfigId,
411
+ suggestion,
412
+ });
413
+ if (!reopened.ok) {
414
+ throw new BadRequestError(reopened.error.message);
415
+ }
416
+
417
+ return respondOne(client, accountConfigId, reopened.value);
338
418
  },
339
419
  };
package/src/types.ts CHANGED
@@ -88,7 +88,8 @@ export type OperationIds =
88
88
  | "MessageCalendarSuggestionOperations_listMessageCalendarSuggestions"
89
89
  | "CalendarSuggestionActionOperations_acceptCalendarSuggestion"
90
90
  | "CalendarSuggestionActionOperations_declineCalendarSuggestion"
91
- | "CalendarSuggestionActionOperations_dismissCalendarSuggestion";
91
+ | "CalendarSuggestionActionOperations_dismissCalendarSuggestion"
92
+ | "CalendarSuggestionActionOperations_reopenCalendarSuggestion";
92
93
 
93
94
  export type MeOperationIds = MatchPrefix<"MeOperations_", OperationIds>;
94
95