@remit/backend 0.0.35 → 0.0.37
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/derive/enrichThreadRows.test.ts +163 -0
- package/src/derive/enrichThreadRows.ts +43 -2
- package/src/handlers/index.ts +3 -0
- package/src/handlers/label.test.ts +112 -0
- package/src/handlers/label.ts +210 -0
- package/src/handlers/mailbox.ts +1 -0
- package/src/handlers/message.ts +66 -2
- package/src/types.ts +13 -0
package/package.json
CHANGED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import type {
|
|
4
|
+
AddressItem,
|
|
5
|
+
LabelItem,
|
|
6
|
+
MessageItem,
|
|
7
|
+
MessageLabelItem,
|
|
8
|
+
ThreadMessageItem,
|
|
9
|
+
} from "@remit/data-ports";
|
|
10
|
+
import { type EnrichClient, enrichThreadRows } from "./enrichThreadRows.js";
|
|
11
|
+
|
|
12
|
+
const threadRow = (
|
|
13
|
+
threadMessageId: string,
|
|
14
|
+
messageId: string,
|
|
15
|
+
): ThreadMessageItem =>
|
|
16
|
+
({
|
|
17
|
+
threadMessageId,
|
|
18
|
+
threadId: "th-1",
|
|
19
|
+
messageId,
|
|
20
|
+
accountConfigId: "acc-1",
|
|
21
|
+
mailboxId: "mbx-1",
|
|
22
|
+
sentDate: 1,
|
|
23
|
+
isRead: true,
|
|
24
|
+
hasAttachment: false,
|
|
25
|
+
hasStars: false,
|
|
26
|
+
isDeleted: false,
|
|
27
|
+
createdAt: 1,
|
|
28
|
+
updatedAt: 1,
|
|
29
|
+
}) as unknown as ThreadMessageItem;
|
|
30
|
+
|
|
31
|
+
const buildClient = (
|
|
32
|
+
messageLabels: MessageLabelItem[],
|
|
33
|
+
labels: LabelItem[],
|
|
34
|
+
): EnrichClient => ({
|
|
35
|
+
message: { get: async () => [] as MessageItem[] },
|
|
36
|
+
address: { getAddress: async () => [] as AddressItem[] },
|
|
37
|
+
messageLabel: {
|
|
38
|
+
listByMessageIds: async (messageIds: string[]) =>
|
|
39
|
+
messageLabels.filter((row) => messageIds.includes(row.messageId)),
|
|
40
|
+
},
|
|
41
|
+
label: {
|
|
42
|
+
listByAccountConfig: async () => labels,
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("enrichThreadRows — labels", () => {
|
|
47
|
+
test("attaches every label applied to a message", async () => {
|
|
48
|
+
const rows = [threadRow("tm-1", "msg-1")];
|
|
49
|
+
const messageLabels = [
|
|
50
|
+
{
|
|
51
|
+
messageLabelId: "ml-1",
|
|
52
|
+
messageId: "msg-1",
|
|
53
|
+
labelId: "lbl-1",
|
|
54
|
+
accountConfigId: "acc-1",
|
|
55
|
+
createdAt: 1,
|
|
56
|
+
updatedAt: 1,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
messageLabelId: "ml-2",
|
|
60
|
+
messageId: "msg-1",
|
|
61
|
+
labelId: "lbl-2",
|
|
62
|
+
accountConfigId: "acc-1",
|
|
63
|
+
createdAt: 1,
|
|
64
|
+
updatedAt: 1,
|
|
65
|
+
},
|
|
66
|
+
] as unknown as MessageLabelItem[];
|
|
67
|
+
const labels = [
|
|
68
|
+
{
|
|
69
|
+
labelId: "lbl-1",
|
|
70
|
+
accountConfigId: "acc-1",
|
|
71
|
+
name: "Receipts",
|
|
72
|
+
normalizedName: "receipts",
|
|
73
|
+
color: "Blue",
|
|
74
|
+
createdAt: 1,
|
|
75
|
+
updatedAt: 1,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
labelId: "lbl-2",
|
|
79
|
+
accountConfigId: "acc-1",
|
|
80
|
+
name: "Travel",
|
|
81
|
+
normalizedName: "travel",
|
|
82
|
+
color: "Green",
|
|
83
|
+
createdAt: 1,
|
|
84
|
+
updatedAt: 1,
|
|
85
|
+
},
|
|
86
|
+
] as unknown as LabelItem[];
|
|
87
|
+
|
|
88
|
+
const [result] = await enrichThreadRows(
|
|
89
|
+
rows,
|
|
90
|
+
buildClient(messageLabels, labels),
|
|
91
|
+
"acc-1",
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
assert.deepEqual(result?.labels?.map((l) => l.labelId).sort(), [
|
|
95
|
+
"lbl-1",
|
|
96
|
+
"lbl-2",
|
|
97
|
+
]);
|
|
98
|
+
assert.deepEqual(
|
|
99
|
+
result?.labels?.find((l) => l.labelId === "lbl-1"),
|
|
100
|
+
{ labelId: "lbl-1", name: "Receipts", color: "Blue" },
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("omits labels entirely when the message has none", async () => {
|
|
105
|
+
const rows = [threadRow("tm-1", "msg-1")];
|
|
106
|
+
const [result] = await enrichThreadRows(rows, buildClient([], []), "acc-1");
|
|
107
|
+
assert.equal(result?.labels, undefined);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("skips a MessageLabel row whose Label was deleted out from under it", async () => {
|
|
111
|
+
const rows = [threadRow("tm-1", "msg-1")];
|
|
112
|
+
const messageLabels = [
|
|
113
|
+
{
|
|
114
|
+
messageLabelId: "ml-1",
|
|
115
|
+
messageId: "msg-1",
|
|
116
|
+
labelId: "gone",
|
|
117
|
+
accountConfigId: "acc-1",
|
|
118
|
+
createdAt: 1,
|
|
119
|
+
updatedAt: 1,
|
|
120
|
+
},
|
|
121
|
+
] as unknown as MessageLabelItem[];
|
|
122
|
+
|
|
123
|
+
const [result] = await enrichThreadRows(
|
|
124
|
+
rows,
|
|
125
|
+
buildClient(messageLabels, []),
|
|
126
|
+
"acc-1",
|
|
127
|
+
);
|
|
128
|
+
assert.equal(result?.labels, undefined);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("never cross-wires labels between rows for different messages", async () => {
|
|
132
|
+
const rows = [threadRow("tm-1", "msg-1"), threadRow("tm-2", "msg-2")];
|
|
133
|
+
const messageLabels = [
|
|
134
|
+
{
|
|
135
|
+
messageLabelId: "ml-1",
|
|
136
|
+
messageId: "msg-1",
|
|
137
|
+
labelId: "lbl-1",
|
|
138
|
+
accountConfigId: "acc-1",
|
|
139
|
+
createdAt: 1,
|
|
140
|
+
updatedAt: 1,
|
|
141
|
+
},
|
|
142
|
+
] as unknown as MessageLabelItem[];
|
|
143
|
+
const labels = [
|
|
144
|
+
{
|
|
145
|
+
labelId: "lbl-1",
|
|
146
|
+
accountConfigId: "acc-1",
|
|
147
|
+
name: "Receipts",
|
|
148
|
+
normalizedName: "receipts",
|
|
149
|
+
color: "Blue",
|
|
150
|
+
createdAt: 1,
|
|
151
|
+
updatedAt: 1,
|
|
152
|
+
},
|
|
153
|
+
] as unknown as LabelItem[];
|
|
154
|
+
|
|
155
|
+
const [first, second] = await enrichThreadRows(
|
|
156
|
+
rows,
|
|
157
|
+
buildClient(messageLabels, labels),
|
|
158
|
+
"acc-1",
|
|
159
|
+
);
|
|
160
|
+
assert.equal(first?.labels?.length, 1);
|
|
161
|
+
assert.equal(second?.labels, undefined);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { ThreadMessageResponse } from "@remit/api-openapi-types";
|
|
2
2
|
import type {
|
|
3
3
|
AddressItem,
|
|
4
|
+
LabelItem,
|
|
4
5
|
MessageItem,
|
|
6
|
+
MessageLabelItem,
|
|
5
7
|
ThreadMessageItem,
|
|
6
8
|
} from "@remit/data-ports";
|
|
7
9
|
import { deriveAddressId } from "@remit/data-ports/id";
|
|
@@ -24,6 +26,12 @@ export interface EnrichClient {
|
|
|
24
26
|
addressIds: string[],
|
|
25
27
|
): Promise<AddressItem[]>;
|
|
26
28
|
};
|
|
29
|
+
messageLabel: {
|
|
30
|
+
listByMessageIds(messageIds: string[]): Promise<MessageLabelItem[]>;
|
|
31
|
+
};
|
|
32
|
+
label: {
|
|
33
|
+
listByAccountConfig(accountConfigId: string): Promise<LabelItem[]>;
|
|
34
|
+
};
|
|
27
35
|
}
|
|
28
36
|
|
|
29
37
|
const toResponse = (item: ThreadMessageItem): ThreadMessageResponse => ({
|
|
@@ -102,23 +110,54 @@ export const planBatchFetch = (rows: ThreadMessageItem[]): BatchPlan => {
|
|
|
102
110
|
* Message with no stored category coalesces to `uncategorized` (RFC 032 Tier 2).
|
|
103
111
|
* `senderTrust` defaults to `"unknown"`. `autoMoved` is omitted whenever the
|
|
104
112
|
* move isn't a real, in-effect auto-move (or the Message row is absent).
|
|
113
|
+
*
|
|
114
|
+
* Not annotated `Promise<ThreadMessageResponse[]>`: `labels` is a new field on
|
|
115
|
+
* it in this same PR, and that package publishes separately from this repo —
|
|
116
|
+
* an explicit annotation here would contextually type the literal below
|
|
117
|
+
* against the currently-*published* shape (which lacks `labels`) and fail
|
|
118
|
+
* `check-consumer-typecheck`/`release:dry-run`. Callers assign the (wider,
|
|
119
|
+
* inferred) result into an already-typed `ThreadMessageResponse[]` property,
|
|
120
|
+
* which is a plain assignability check, not an excess-property one.
|
|
105
121
|
*/
|
|
106
122
|
export const enrichThreadRows = async (
|
|
107
123
|
rows: ThreadMessageItem[],
|
|
108
124
|
client: EnrichClient,
|
|
109
125
|
accountConfigId: string,
|
|
110
|
-
)
|
|
126
|
+
) => {
|
|
111
127
|
if (rows.length === 0) return [];
|
|
112
128
|
|
|
113
129
|
const plan = planBatchFetch(rows);
|
|
114
130
|
|
|
115
|
-
const [messages, addresses] = await Promise.all([
|
|
131
|
+
const [messages, addresses, messageLabels, labels] = await Promise.all([
|
|
116
132
|
plan.messageIds.length ? client.message.get(plan.messageIds) : [],
|
|
117
133
|
plan.addressIds.length
|
|
118
134
|
? client.address.getAddress(accountConfigId, plan.addressIds)
|
|
119
135
|
: [],
|
|
136
|
+
client.messageLabel.listByMessageIds(plan.messageIds),
|
|
137
|
+
client.label.listByAccountConfig(accountConfigId),
|
|
120
138
|
]);
|
|
121
139
|
|
|
140
|
+
const labelById = new Map(labels.map((label) => [label.labelId, label]));
|
|
141
|
+
const labelsByMessageId = new Map<
|
|
142
|
+
string,
|
|
143
|
+
{ labelId: string; name: string; color: LabelItem["color"] }[]
|
|
144
|
+
>();
|
|
145
|
+
for (const messageLabel of messageLabels) {
|
|
146
|
+
const label = labelById.get(messageLabel.labelId);
|
|
147
|
+
if (!label) continue;
|
|
148
|
+
const entry = {
|
|
149
|
+
labelId: label.labelId,
|
|
150
|
+
name: label.name,
|
|
151
|
+
color: label.color,
|
|
152
|
+
};
|
|
153
|
+
const existing = labelsByMessageId.get(messageLabel.messageId);
|
|
154
|
+
if (existing) {
|
|
155
|
+
existing.push(entry);
|
|
156
|
+
} else {
|
|
157
|
+
labelsByMessageId.set(messageLabel.messageId, [entry]);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
122
161
|
const categoryByMessageId = new Map(
|
|
123
162
|
messages.map((m) => [
|
|
124
163
|
m.messageId,
|
|
@@ -144,11 +183,13 @@ export const enrichThreadRows = async (
|
|
|
144
183
|
const senderTrust = addressId
|
|
145
184
|
? (trustByAddressId.get(addressId) ?? SenderTrust.Unknown)
|
|
146
185
|
: SenderTrust.Unknown;
|
|
186
|
+
const labels = labelsByMessageId.get(row.messageId);
|
|
147
187
|
return {
|
|
148
188
|
...base,
|
|
149
189
|
...(category !== undefined ? { category } : {}),
|
|
150
190
|
...(authenticity !== undefined ? { authenticity } : {}),
|
|
151
191
|
...(autoMoved !== undefined ? { autoMoved } : {}),
|
|
192
|
+
...(labels !== undefined ? { labels } : {}),
|
|
152
193
|
senderTrust,
|
|
153
194
|
};
|
|
154
195
|
});
|
package/src/handlers/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { AddressDetailOperations, AddressOperations } from "./address.js";
|
|
|
5
5
|
import { ConfigOperations } from "./config.js";
|
|
6
6
|
import { FilterDetailOperations, FilterOperations } from "./filter.js";
|
|
7
7
|
import { FolderRoleOperations } from "./folder-role.js";
|
|
8
|
+
import { LabelDetailOperations, LabelOperations } from "./label.js";
|
|
8
9
|
import {
|
|
9
10
|
MailboxDetailOperations,
|
|
10
11
|
MailboxOperations,
|
|
@@ -33,6 +34,8 @@ export const handlers: Record<OperationIds, OperationHandler<any>> = {
|
|
|
33
34
|
...FolderRoleOperations,
|
|
34
35
|
...FilterOperations,
|
|
35
36
|
...FilterDetailOperations,
|
|
37
|
+
...LabelOperations,
|
|
38
|
+
...LabelDetailOperations,
|
|
36
39
|
...OrganizeOperations,
|
|
37
40
|
...OrganizeJobDetailOperations,
|
|
38
41
|
...TrashOperations,
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import type { FilterItem, LabelItem } from "@remit/data-ports";
|
|
4
|
+
import {
|
|
5
|
+
deleteLabelWithCascade,
|
|
6
|
+
findFiltersForLabel,
|
|
7
|
+
type LabelCrudDeps,
|
|
8
|
+
} from "./label.js";
|
|
9
|
+
|
|
10
|
+
const filter = (filterId: string, actionLabelId: string): FilterItem =>
|
|
11
|
+
({ filterId, actionLabelId }) as unknown as FilterItem;
|
|
12
|
+
|
|
13
|
+
const label = (labelId: string): LabelItem =>
|
|
14
|
+
({
|
|
15
|
+
labelId,
|
|
16
|
+
accountConfigId: "acc-1",
|
|
17
|
+
name: "x",
|
|
18
|
+
color: "Default",
|
|
19
|
+
}) as LabelItem;
|
|
20
|
+
|
|
21
|
+
const buildDeps = (filters: FilterItem[]) => {
|
|
22
|
+
const deletedFilterIds: string[] = [];
|
|
23
|
+
const removedLabelIds: string[] = [];
|
|
24
|
+
const deletedLabelIds: string[] = [];
|
|
25
|
+
|
|
26
|
+
const deps: LabelCrudDeps = {
|
|
27
|
+
label: {
|
|
28
|
+
create: async () => label("unused"),
|
|
29
|
+
get: async (_accountConfigId: string, labelId: string) => label(labelId),
|
|
30
|
+
update: async (_accountConfigId: string, labelId: string) =>
|
|
31
|
+
label(labelId),
|
|
32
|
+
delete: async (_accountConfigId: string, labelId: string) => {
|
|
33
|
+
deletedLabelIds.push(labelId);
|
|
34
|
+
},
|
|
35
|
+
listPageByAccountConfig: async () => ({
|
|
36
|
+
items: [],
|
|
37
|
+
continuationToken: undefined,
|
|
38
|
+
}),
|
|
39
|
+
},
|
|
40
|
+
filter: {
|
|
41
|
+
listByAccountConfig: async () => filters,
|
|
42
|
+
delete: async (_accountConfigId: string, filterId: string) => {
|
|
43
|
+
deletedFilterIds.push(filterId);
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
messageLabel: {
|
|
47
|
+
removeAllByLabelId: async (_accountConfigId: string, labelId: string) => {
|
|
48
|
+
removedLabelIds.push(labelId);
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return { deps, deletedFilterIds, removedLabelIds, deletedLabelIds };
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
describe("findFiltersForLabel", () => {
|
|
57
|
+
test("returns only the filters whose actionLabelId matches", async () => {
|
|
58
|
+
const filters = [
|
|
59
|
+
filter("f1", "label-1"),
|
|
60
|
+
filter("f2", "label-2"),
|
|
61
|
+
filter("f3", "label-1"),
|
|
62
|
+
];
|
|
63
|
+
const { deps } = buildDeps(filters);
|
|
64
|
+
|
|
65
|
+
const found = await findFiltersForLabel(deps, "acc-1", "label-1");
|
|
66
|
+
assert.deepEqual(found.map((f) => f.filterId).sort(), ["f1", "f3"]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("returns an empty list when no filter uses the label", async () => {
|
|
70
|
+
const { deps } = buildDeps([filter("f1", "label-2")]);
|
|
71
|
+
const found = await findFiltersForLabel(deps, "acc-1", "label-1");
|
|
72
|
+
assert.deepEqual(found, []);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("deleteLabelWithCascade", () => {
|
|
77
|
+
test("deletes every filter referencing the label, clears applied messages, then deletes the label", async () => {
|
|
78
|
+
const filters = [
|
|
79
|
+
filter("f1", "label-1"),
|
|
80
|
+
filter("f2", "label-2"),
|
|
81
|
+
filter("f3", "label-1"),
|
|
82
|
+
];
|
|
83
|
+
const { deps, deletedFilterIds, removedLabelIds, deletedLabelIds } =
|
|
84
|
+
buildDeps(filters);
|
|
85
|
+
|
|
86
|
+
await deleteLabelWithCascade(deps, "acc-1", "label-1");
|
|
87
|
+
|
|
88
|
+
assert.deepEqual(deletedFilterIds.sort(), ["f1", "f3"]);
|
|
89
|
+
assert.deepEqual(removedLabelIds, ["label-1"]);
|
|
90
|
+
assert.deepEqual(deletedLabelIds, ["label-1"]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("never touches a filter that references a different label", async () => {
|
|
94
|
+
const filters = [filter("f1", "label-2")];
|
|
95
|
+
const { deps, deletedFilterIds } = buildDeps(filters);
|
|
96
|
+
|
|
97
|
+
await deleteLabelWithCascade(deps, "acc-1", "label-1");
|
|
98
|
+
|
|
99
|
+
assert.deepEqual(deletedFilterIds, []);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("is a no-op on the filter/messageLabel side when nothing references the label", async () => {
|
|
103
|
+
const { deps, deletedFilterIds, removedLabelIds, deletedLabelIds } =
|
|
104
|
+
buildDeps([]);
|
|
105
|
+
|
|
106
|
+
await deleteLabelWithCascade(deps, "acc-1", "label-1");
|
|
107
|
+
|
|
108
|
+
assert.deepEqual(deletedFilterIds, []);
|
|
109
|
+
assert.deepEqual(removedLabelIds, ["label-1"]);
|
|
110
|
+
assert.deepEqual(deletedLabelIds, ["label-1"]);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CreateLabelInput as CreateLabelRequestBody,
|
|
3
|
+
LabelResponse,
|
|
4
|
+
UpdateLabelInput as UpdateLabelRequestBody,
|
|
5
|
+
} from "@remit/api-openapi-types";
|
|
6
|
+
import type {
|
|
7
|
+
CreateLabelInput,
|
|
8
|
+
FilterItem,
|
|
9
|
+
LabelItem,
|
|
10
|
+
UpdateLabelInput,
|
|
11
|
+
} from "@remit/data-ports";
|
|
12
|
+
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
13
|
+
import { getAccountConfigIdFromEvent } from "../auth.js";
|
|
14
|
+
import { getClient } from "../service/dynamodb.js";
|
|
15
|
+
import type {
|
|
16
|
+
LabelDetailOperationIds,
|
|
17
|
+
LabelOperationIds,
|
|
18
|
+
OperationHandler,
|
|
19
|
+
} from "../types.js";
|
|
20
|
+
import { assertAccountOwnership } from "./account-ownership.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Minimal label-service surface the CRUD handlers need — declared as a `Pick`
|
|
24
|
+
* so tests can stub it without a live table.
|
|
25
|
+
*/
|
|
26
|
+
export interface LabelCrudDeps {
|
|
27
|
+
label: {
|
|
28
|
+
create(input: CreateLabelInput): Promise<LabelItem>;
|
|
29
|
+
get(accountConfigId: string, labelId: string): Promise<LabelItem>;
|
|
30
|
+
update(
|
|
31
|
+
accountConfigId: string,
|
|
32
|
+
labelId: string,
|
|
33
|
+
input: UpdateLabelInput,
|
|
34
|
+
): Promise<LabelItem>;
|
|
35
|
+
delete(accountConfigId: string, labelId: string): Promise<void>;
|
|
36
|
+
listPageByAccountConfig(
|
|
37
|
+
accountConfigId: string,
|
|
38
|
+
options?: { limit?: number; continuationToken?: string },
|
|
39
|
+
): Promise<{ items: LabelItem[]; continuationToken: string | undefined }>;
|
|
40
|
+
};
|
|
41
|
+
filter: {
|
|
42
|
+
listByAccountConfig(accountConfigId: string): Promise<FilterItem[]>;
|
|
43
|
+
delete(accountConfigId: string, filterId: string): Promise<void>;
|
|
44
|
+
};
|
|
45
|
+
messageLabel: {
|
|
46
|
+
removeAllByLabelId(accountConfigId: string, labelId: string): Promise<void>;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Every filter in the account whose action applies this label (issue #26). The
|
|
52
|
+
* count backs `LabelResponse.filterCount`; the list backs the cascade delete.
|
|
53
|
+
*/
|
|
54
|
+
export const findFiltersForLabel = async (
|
|
55
|
+
deps: Pick<LabelCrudDeps, "filter">,
|
|
56
|
+
accountConfigId: string,
|
|
57
|
+
labelId: string,
|
|
58
|
+
): Promise<FilterItem[]> => {
|
|
59
|
+
const filters = await deps.filter.listByAccountConfig(accountConfigId);
|
|
60
|
+
return filters.filter((filter) => filter.actionLabelId === labelId);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* `filterCount` is computed here, not stored (issue #26) — every other field
|
|
65
|
+
* comes straight off the `LabelItem` row.
|
|
66
|
+
*/
|
|
67
|
+
const toLabelResponse = (
|
|
68
|
+
item: LabelItem,
|
|
69
|
+
filterCount: number,
|
|
70
|
+
): LabelResponse => ({
|
|
71
|
+
labelId: item.labelId,
|
|
72
|
+
accountConfigId: item.accountConfigId,
|
|
73
|
+
name: item.name,
|
|
74
|
+
color: item.color,
|
|
75
|
+
filterCount,
|
|
76
|
+
createdAt: item.createdAt,
|
|
77
|
+
updatedAt: item.updatedAt,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Delete a label and cascade in both directions (issue #26): every filter
|
|
82
|
+
* whose `actionLabelId` is this label is deleted outright — never left
|
|
83
|
+
* dangling, never blocking the delete — and every `MessageLabel` row for it is
|
|
84
|
+
* removed. The label row itself is deleted last so a failure mid-cascade never
|
|
85
|
+
* leaves the label gone while its filters or applied-to-messages still exist.
|
|
86
|
+
*/
|
|
87
|
+
export const deleteLabelWithCascade = async (
|
|
88
|
+
deps: LabelCrudDeps,
|
|
89
|
+
accountConfigId: string,
|
|
90
|
+
labelId: string,
|
|
91
|
+
): Promise<void> => {
|
|
92
|
+
const filters = await findFiltersForLabel(deps, accountConfigId, labelId);
|
|
93
|
+
for (const filter of filters) {
|
|
94
|
+
await deps.filter.delete(accountConfigId, filter.filterId);
|
|
95
|
+
}
|
|
96
|
+
await deps.messageLabel.removeAllByLabelId(accountConfigId, labelId);
|
|
97
|
+
await deps.label.delete(accountConfigId, labelId);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const LabelOperations: Record<
|
|
101
|
+
LabelOperationIds,
|
|
102
|
+
OperationHandler<LabelOperationIds>
|
|
103
|
+
> = {
|
|
104
|
+
LabelOperations_listLabels: async (context, ...args: unknown[]) => {
|
|
105
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
106
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
107
|
+
const { accountId } = context.request.params as { accountId: string };
|
|
108
|
+
const { continuationToken } = context.request.query as {
|
|
109
|
+
continuationToken?: string;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const client = await getClient();
|
|
113
|
+
const account = await client.account.get(accountId);
|
|
114
|
+
assertAccountOwnership(account, accountConfigId, "read");
|
|
115
|
+
|
|
116
|
+
const page = await client.label.listPageByAccountConfig(accountConfigId, {
|
|
117
|
+
continuationToken,
|
|
118
|
+
});
|
|
119
|
+
const filters = await client.filter.listByAccountConfig(accountConfigId);
|
|
120
|
+
const filterCountByLabelId = new Map<string, number>();
|
|
121
|
+
for (const filter of filters) {
|
|
122
|
+
const labelId = filter.actionLabelId;
|
|
123
|
+
filterCountByLabelId.set(
|
|
124
|
+
labelId,
|
|
125
|
+
(filterCountByLabelId.get(labelId) ?? 0) + 1,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
items: page.items.map((item) =>
|
|
131
|
+
toLabelResponse(item, filterCountByLabelId.get(item.labelId) ?? 0),
|
|
132
|
+
),
|
|
133
|
+
continuationToken: page.continuationToken,
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
LabelOperations_createLabel: async (context, ...args: unknown[]) => {
|
|
138
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
139
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
140
|
+
const { accountId } = context.request.params as { accountId: string };
|
|
141
|
+
const input = context.request.requestBody as CreateLabelRequestBody;
|
|
142
|
+
|
|
143
|
+
const client = await getClient();
|
|
144
|
+
const account = await client.account.get(accountId);
|
|
145
|
+
assertAccountOwnership(account, accountConfigId, "act");
|
|
146
|
+
|
|
147
|
+
const label = await client.label.create({
|
|
148
|
+
accountConfigId,
|
|
149
|
+
name: input.name,
|
|
150
|
+
color: input.color,
|
|
151
|
+
});
|
|
152
|
+
return toLabelResponse(label, 0);
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export const LabelDetailOperations: Record<
|
|
157
|
+
LabelDetailOperationIds,
|
|
158
|
+
OperationHandler<LabelDetailOperationIds>
|
|
159
|
+
> = {
|
|
160
|
+
LabelDetailOperations_getLabel: async (context, ...args: unknown[]) => {
|
|
161
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
162
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
163
|
+
const { accountId, labelId } = context.request.params as {
|
|
164
|
+
accountId: string;
|
|
165
|
+
labelId: string;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const client = await getClient();
|
|
169
|
+
const account = await client.account.get(accountId);
|
|
170
|
+
assertAccountOwnership(account, accountConfigId, "read");
|
|
171
|
+
|
|
172
|
+
const label = await client.label.get(accountConfigId, labelId);
|
|
173
|
+
const filters = await findFiltersForLabel(client, accountConfigId, labelId);
|
|
174
|
+
return toLabelResponse(label, filters.length);
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
LabelDetailOperations_updateLabel: async (context, ...args: unknown[]) => {
|
|
178
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
179
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
180
|
+
const { accountId, labelId } = context.request.params as {
|
|
181
|
+
accountId: string;
|
|
182
|
+
labelId: string;
|
|
183
|
+
};
|
|
184
|
+
const body = context.request.requestBody as Partial<UpdateLabelRequestBody>;
|
|
185
|
+
|
|
186
|
+
const client = await getClient();
|
|
187
|
+
const account = await client.account.get(accountId);
|
|
188
|
+
assertAccountOwnership(account, accountConfigId, "act");
|
|
189
|
+
|
|
190
|
+
const updated = await client.label.update(accountConfigId, labelId, body);
|
|
191
|
+
const filters = await findFiltersForLabel(client, accountConfigId, labelId);
|
|
192
|
+
return toLabelResponse(updated, filters.length);
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
LabelDetailOperations_deleteLabel: async (context, ...args: unknown[]) => {
|
|
196
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
197
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
198
|
+
const { accountId, labelId } = context.request.params as {
|
|
199
|
+
accountId: string;
|
|
200
|
+
labelId: string;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const client = await getClient();
|
|
204
|
+
const account = await client.account.get(accountId);
|
|
205
|
+
assertAccountOwnership(account, accountConfigId, "act");
|
|
206
|
+
|
|
207
|
+
await deleteLabelWithCascade(client, accountConfigId, labelId);
|
|
208
|
+
return { statusCode: 204 };
|
|
209
|
+
},
|
|
210
|
+
};
|
package/src/handlers/mailbox.ts
CHANGED
|
@@ -179,6 +179,7 @@ const toMailboxResponse = (
|
|
|
179
179
|
lastSyncUid: mailbox.lastSyncUid,
|
|
180
180
|
highWaterMarkUid: mailbox.highWaterMarkUid,
|
|
181
181
|
lastMessageSyncAt: mailbox.lastMessageSyncAt,
|
|
182
|
+
syncStatus: mailbox.syncStatus,
|
|
182
183
|
muted: overrides.muted,
|
|
183
184
|
displayNameOverride: overrides.displayNameOverride,
|
|
184
185
|
createdAt: mailbox.createdAt,
|
package/src/handlers/message.ts
CHANGED
|
@@ -2,7 +2,6 @@ import type {
|
|
|
2
2
|
BodyPartResponse,
|
|
3
3
|
EnvelopeAddressResponse,
|
|
4
4
|
EnvelopeResponse,
|
|
5
|
-
MessageSummaryResponse,
|
|
6
5
|
} from "@remit/api-openapi-types";
|
|
7
6
|
import type { MailboxItem } from "@remit/data-ports";
|
|
8
7
|
import {
|
|
@@ -351,7 +350,26 @@ export const MessageOperations: Record<
|
|
|
351
350
|
const envelope = description.envelope[0];
|
|
352
351
|
|
|
353
352
|
const autoMoved = deriveAutoMoved(message);
|
|
354
|
-
|
|
353
|
+
|
|
354
|
+
// Labels applied to this message (issue #26) — filter-, organize-, and
|
|
355
|
+
// manually-applied alike. A message rarely carries more than a handful, so
|
|
356
|
+
// a `get` per label id (not a batch fetch) is the whole cost.
|
|
357
|
+
const messageLabelRows = await client.messageLabel.listByMessageId(
|
|
358
|
+
message.messageId,
|
|
359
|
+
);
|
|
360
|
+
const labels = await Promise.all(
|
|
361
|
+
messageLabelRows.map(async (row) => {
|
|
362
|
+
const label = await client.label.get(accountConfigId, row.labelId);
|
|
363
|
+
return { labelId: label.labelId, name: label.name, color: label.color };
|
|
364
|
+
}),
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
// Not typed against the generated `MessageSummaryResponse`: `labels` is a
|
|
368
|
+
// new field on it in this same PR, and that package publishes separately
|
|
369
|
+
// from this repo — a local-codegen-only field would fail
|
|
370
|
+
// `check-consumer-typecheck`/`release:dry-run`, which resolve generated
|
|
371
|
+
// `@remit/*` packages off the registry, not the local `build/` output.
|
|
372
|
+
const messageSummary = {
|
|
355
373
|
messageId: message.messageId,
|
|
356
374
|
mailboxId: message.mailboxId,
|
|
357
375
|
uid: message.uid,
|
|
@@ -360,6 +378,7 @@ export const MessageOperations: Record<
|
|
|
360
378
|
messageIdHeader: message.messageIdHeader,
|
|
361
379
|
authenticity: message.authenticity,
|
|
362
380
|
...(autoMoved ? { autoMoved } : {}),
|
|
381
|
+
...(labels.length > 0 ? { labels } : {}),
|
|
363
382
|
};
|
|
364
383
|
|
|
365
384
|
// Batch-fetch the resolved Address rows so each EnvelopeAddressResponse can
|
|
@@ -883,4 +902,49 @@ export const MessageBulkOperations: Record<
|
|
|
883
902
|
failureCount: 0,
|
|
884
903
|
};
|
|
885
904
|
},
|
|
905
|
+
|
|
906
|
+
MessageBulkOperations_updateMessageLabels: async (
|
|
907
|
+
context,
|
|
908
|
+
...args: unknown[]
|
|
909
|
+
) => {
|
|
910
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
911
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
912
|
+
const { messageIds, labelId, action } = context.request.requestBody as {
|
|
913
|
+
messageIds: string[];
|
|
914
|
+
labelId: string;
|
|
915
|
+
action: "Apply" | "Remove";
|
|
916
|
+
};
|
|
917
|
+
|
|
918
|
+
if (messageIds.length === 0) {
|
|
919
|
+
return { successCount: 0, failureCount: 0 };
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
const client = await getClient();
|
|
923
|
+
await assertMessagesOwned(client, messageIds, accountConfigId, "act");
|
|
924
|
+
|
|
925
|
+
// Scoped by accountConfigId (RFC 030) — a foreign labelId 404s here rather
|
|
926
|
+
// than silently applying to messages under someone else's label.
|
|
927
|
+
await client.label.get(accountConfigId, labelId);
|
|
928
|
+
|
|
929
|
+
// `appliedByFilterId` stays absent — this is the manual "just these" scope
|
|
930
|
+
// (RFC 034 Decision 3.3), never filter-attributed.
|
|
931
|
+
if (action === "Apply") {
|
|
932
|
+
for (const messageId of messageIds) {
|
|
933
|
+
await client.messageLabel.apply({
|
|
934
|
+
accountConfigId,
|
|
935
|
+
messageId,
|
|
936
|
+
labelId,
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
} else {
|
|
940
|
+
for (const messageId of messageIds) {
|
|
941
|
+
await client.messageLabel.remove(messageId, labelId);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
return {
|
|
946
|
+
successCount: messageIds.length,
|
|
947
|
+
failureCount: 0,
|
|
948
|
+
};
|
|
949
|
+
},
|
|
886
950
|
};
|
package/src/types.ts
CHANGED
|
@@ -31,6 +31,11 @@ export type OperationIds =
|
|
|
31
31
|
| "FilterDetailOperations_getFilter"
|
|
32
32
|
| "FilterDetailOperations_updateFilter"
|
|
33
33
|
| "FilterDetailOperations_deleteFilter"
|
|
34
|
+
| "LabelOperations_listLabels"
|
|
35
|
+
| "LabelOperations_createLabel"
|
|
36
|
+
| "LabelDetailOperations_getLabel"
|
|
37
|
+
| "LabelDetailOperations_updateLabel"
|
|
38
|
+
| "LabelDetailOperations_deleteLabel"
|
|
34
39
|
| "OrganizeOperations_createOrganizeJob"
|
|
35
40
|
| "OrganizeOperations_previewOrganize"
|
|
36
41
|
| "OrganizeJobDetailOperations_getOrganizeJob"
|
|
@@ -48,6 +53,7 @@ export type OperationIds =
|
|
|
48
53
|
| "MessageBulkOperations_moveMessages"
|
|
49
54
|
| "MessageBulkOperations_updateFlags"
|
|
50
55
|
| "MessageBulkOperations_copyMessages"
|
|
56
|
+
| "MessageBulkOperations_updateMessageLabels"
|
|
51
57
|
| "TrashOperations_emptyTrash"
|
|
52
58
|
| "OutboxOperations_createOutboxMessage"
|
|
53
59
|
| "OutboxOperations_listOutboxMessages"
|
|
@@ -96,6 +102,13 @@ export type FilterDetailOperationIds = MatchPrefix<
|
|
|
96
102
|
OperationIds
|
|
97
103
|
>;
|
|
98
104
|
|
|
105
|
+
export type LabelOperationIds = MatchPrefix<"LabelOperations_", OperationIds>;
|
|
106
|
+
|
|
107
|
+
export type LabelDetailOperationIds = MatchPrefix<
|
|
108
|
+
"LabelDetailOperations_",
|
|
109
|
+
OperationIds
|
|
110
|
+
>;
|
|
111
|
+
|
|
99
112
|
export type OrganizeOperationIds = MatchPrefix<
|
|
100
113
|
"OrganizeOperations_",
|
|
101
114
|
OperationIds
|