@remit/backend 0.0.36 → 0.0.38
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/filter.test.ts +2 -2
- package/src/handlers/filter.ts +3 -12
- 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/message.ts +66 -2
- package/src/service/filter.ts +1 -4
- package/src/service/organize.ts +1 -4
- 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
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
-
import type { UpdateFilterInput } from "@remit/
|
|
3
|
+
import type { UpdateFilterInput as UpdateFilterRequestBody } from "@remit/api-openapi-types";
|
|
4
4
|
import { FilterScope, FilterState } from "@remit/domain-enums";
|
|
5
5
|
import {
|
|
6
6
|
pickFilterUpdate,
|
|
@@ -35,7 +35,7 @@ describe("pickFilterUpdate", () => {
|
|
|
35
35
|
ruleChangedAt: 999,
|
|
36
36
|
filterId: "sneaky",
|
|
37
37
|
};
|
|
38
|
-
const patch = pickFilterUpdate(raw as Partial<
|
|
38
|
+
const patch = pickFilterUpdate(raw as Partial<UpdateFilterRequestBody>);
|
|
39
39
|
assert.deepEqual(patch, { name: "Receipts" });
|
|
40
40
|
});
|
|
41
41
|
});
|
package/src/handlers/filter.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
CreateFilterInput,
|
|
3
3
|
FilterResponse,
|
|
4
|
+
UpdateFilterInput as UpdateFilterRequestBody,
|
|
4
5
|
} from "@remit/api-openapi-types";
|
|
5
6
|
import type { FilterItem, UpdateFilterInput } from "@remit/data-ports";
|
|
6
7
|
import { BadRequestError } from "@remit/data-ports/errors";
|
|
@@ -93,19 +94,9 @@ export const deriveFilterTtl = (
|
|
|
93
94
|
* dropped. `scope`/`expiresAt` land here as the caller sent them; a patch that
|
|
94
95
|
* touches either still needs `resolveFilterScopeExpiry` to merge them against
|
|
95
96
|
* the stored row and derive `ttl`/`state` before this reaches the repo.
|
|
96
|
-
*
|
|
97
|
-
* Typed against the internal `@remit/data-ports` `UpdateFilterInput`, not the
|
|
98
|
-
* generated `@remit/api-openapi-types` one: that package publishes separately
|
|
99
|
-
* from this repo, so a PR that both widens the TypeSpec model and reads the
|
|
100
|
-
* new field in the same change would typecheck in-tree (fresh local codegen)
|
|
101
|
-
* but fail `check-consumer-typecheck`/`release:dry-run`, which resolve
|
|
102
|
-
* generated `@remit/*` packages off the registry, not the local `build/`
|
|
103
|
-
* output. The data-ports shape carries every field this reads regardless —
|
|
104
|
-
* it derives from the full `Filter` row, which has never gated `scope` or
|
|
105
|
-
* `expiresAt` behind an API-visibility distinction.
|
|
106
97
|
*/
|
|
107
98
|
export const pickFilterUpdate = (
|
|
108
|
-
body: Partial<
|
|
99
|
+
body: Partial<UpdateFilterRequestBody>,
|
|
109
100
|
): Partial<UpdateFilterInput> => {
|
|
110
101
|
const patch: Partial<UpdateFilterInput> = {};
|
|
111
102
|
if (Object.hasOwn(body, "name")) patch.name = body.name;
|
|
@@ -368,7 +359,7 @@ export const FilterDetailOperations: Record<
|
|
|
368
359
|
assertAccountOwnership(account, accountConfigId, "act");
|
|
369
360
|
|
|
370
361
|
const { filter } = client;
|
|
371
|
-
const patch = pickFilterUpdate(body as Partial<
|
|
362
|
+
const patch = pickFilterUpdate(body as Partial<UpdateFilterRequestBody>);
|
|
372
363
|
const touchesScopeOrExpiry =
|
|
373
364
|
Object.hasOwn(patch, "scope") || Object.hasOwn(patch, "expiresAt");
|
|
374
365
|
const resolvedPatch: Partial<UpdateFilterInput> = touchesScopeOrExpiry
|
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/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/service/filter.ts
CHANGED
|
@@ -28,10 +28,7 @@ const build = (): BuildFilterAnchor => {
|
|
|
28
28
|
const embedder = buildEmbeddingServiceFromEnv();
|
|
29
29
|
const store = buildVectorStoreFromEnv(embedder.dimensions);
|
|
30
30
|
return (accountConfigId, anchorMessageId) =>
|
|
31
|
-
buildMessageAnchor(
|
|
32
|
-
{ store, embedder },
|
|
33
|
-
{ accountConfigId, anchorMessageId },
|
|
34
|
-
);
|
|
31
|
+
buildMessageAnchor({ store }, { accountConfigId, anchorMessageId });
|
|
35
32
|
};
|
|
36
33
|
|
|
37
34
|
export const buildFilterAnchor: BuildFilterAnchor = (
|
package/src/service/organize.ts
CHANGED
|
@@ -341,10 +341,7 @@ const buildSemanticFromEnv = (): OrganizeSemanticDeps => {
|
|
|
341
341
|
const store = buildVectorStoreFromEnv(embedder.dimensions);
|
|
342
342
|
cachedSemantic = {
|
|
343
343
|
buildAnchor: (accountConfigId, anchorMessageId) =>
|
|
344
|
-
buildMessageAnchor(
|
|
345
|
-
{ store, embedder },
|
|
346
|
-
{ accountConfigId, anchorMessageId },
|
|
347
|
-
),
|
|
344
|
+
buildMessageAnchor({ store }, { accountConfigId, anchorMessageId }),
|
|
348
345
|
vectorStore: store,
|
|
349
346
|
};
|
|
350
347
|
return cachedSemantic;
|
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
|