@remit/backend 0.0.132 → 0.0.133
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/handlers/filter.test.ts +202 -1
- package/src/handlers/filter.ts +128 -12
- package/src/service/organize.test.ts +1 -0
package/package.json
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
FilterItem,
|
|
11
11
|
} from "@remit/data-ports";
|
|
12
12
|
import {
|
|
13
|
+
FilterDisabledReason,
|
|
13
14
|
FilterMatchOperator,
|
|
14
15
|
FilterScope,
|
|
15
16
|
FilterState,
|
|
@@ -21,6 +22,7 @@ import {
|
|
|
21
22
|
pickFilterUpdate,
|
|
22
23
|
rejectAnchorMutation,
|
|
23
24
|
resolveFilterScopeExpiry,
|
|
25
|
+
resolveFilterUpdate,
|
|
24
26
|
} from "./filter.js";
|
|
25
27
|
|
|
26
28
|
describe("pickFilterUpdate", () => {
|
|
@@ -45,7 +47,7 @@ describe("pickFilterUpdate", () => {
|
|
|
45
47
|
const raw: Record<string, unknown> = {
|
|
46
48
|
name: "Receipts",
|
|
47
49
|
ttl: 123,
|
|
48
|
-
|
|
50
|
+
disabledReason: "None",
|
|
49
51
|
hasAnchor: true,
|
|
50
52
|
ruleChangedAt: 999,
|
|
51
53
|
filterId: "sneaky",
|
|
@@ -55,6 +57,204 @@ describe("pickFilterUpdate", () => {
|
|
|
55
57
|
});
|
|
56
58
|
});
|
|
57
59
|
|
|
60
|
+
describe("resolveFilterUpdate (#1103)", () => {
|
|
61
|
+
const NOW = Date.parse("2026-09-25T12:00:00Z");
|
|
62
|
+
const standing = {
|
|
63
|
+
scope: FilterScope.Standing,
|
|
64
|
+
expiresAt: undefined,
|
|
65
|
+
state: FilterState.Active,
|
|
66
|
+
disabledReason: FilterDisabledReason.None,
|
|
67
|
+
actionMailboxId: "mbx-invoices",
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
it("carries a state the body names through to the patch", () => {
|
|
71
|
+
assert.equal(
|
|
72
|
+
pickFilterUpdate({ state: FilterState.Disabled }).state,
|
|
73
|
+
FilterState.Disabled,
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("records UserDisabled when the user turns a filter off", () => {
|
|
78
|
+
assert.deepEqual(
|
|
79
|
+
resolveFilterUpdate(standing, { state: FilterState.Disabled }, NOW),
|
|
80
|
+
{
|
|
81
|
+
state: FilterState.Disabled,
|
|
82
|
+
disabledReason: FilterDisabledReason.UserDisabled,
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
for (const reason of [
|
|
88
|
+
FilterDisabledReason.UserDisabled,
|
|
89
|
+
FilterDisabledReason.AwaitingFolder,
|
|
90
|
+
FilterDisabledReason.FolderCreateFailed,
|
|
91
|
+
FilterDisabledReason.FolderMissing,
|
|
92
|
+
]) {
|
|
93
|
+
it(`clears ${reason} when the user turns the filter back on`, () => {
|
|
94
|
+
assert.deepEqual(
|
|
95
|
+
resolveFilterUpdate(
|
|
96
|
+
{
|
|
97
|
+
...standing,
|
|
98
|
+
state: FilterState.Disabled,
|
|
99
|
+
disabledReason: reason,
|
|
100
|
+
},
|
|
101
|
+
{ state: FilterState.Active },
|
|
102
|
+
NOW,
|
|
103
|
+
),
|
|
104
|
+
{
|
|
105
|
+
state: FilterState.Active,
|
|
106
|
+
disabledReason: FilterDisabledReason.None,
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
it("keeps a disabled filter disabled, with its reason, through an expiry edit", () => {
|
|
113
|
+
const resolved = resolveFilterUpdate(
|
|
114
|
+
{ ...standing, state: FilterState.Disabled },
|
|
115
|
+
{
|
|
116
|
+
scope: FilterScope.Temporary,
|
|
117
|
+
expiresAt: "2027-01-01T00:00:00+00:00",
|
|
118
|
+
},
|
|
119
|
+
NOW,
|
|
120
|
+
);
|
|
121
|
+
assert.equal(resolved.state, FilterState.Disabled);
|
|
122
|
+
assert.equal("disabledReason" in resolved, false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
for (const reason of [
|
|
126
|
+
FilterDisabledReason.AwaitingFolder,
|
|
127
|
+
FilterDisabledReason.FolderCreateFailed,
|
|
128
|
+
]) {
|
|
129
|
+
it(`refuses to turn on a ${reason} filter that has no folder`, () => {
|
|
130
|
+
assert.throws(
|
|
131
|
+
() =>
|
|
132
|
+
resolveFilterUpdate(
|
|
133
|
+
{
|
|
134
|
+
...standing,
|
|
135
|
+
state: FilterState.Disabled,
|
|
136
|
+
disabledReason: reason,
|
|
137
|
+
actionMailboxId: "None",
|
|
138
|
+
},
|
|
139
|
+
{ state: FilterState.Active },
|
|
140
|
+
NOW,
|
|
141
|
+
),
|
|
142
|
+
/pick a folder in the rule/i,
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it(`turns on a ${reason} filter once the patch names a folder`, () => {
|
|
147
|
+
assert.equal(
|
|
148
|
+
resolveFilterUpdate(
|
|
149
|
+
{
|
|
150
|
+
...standing,
|
|
151
|
+
state: FilterState.Disabled,
|
|
152
|
+
disabledReason: reason,
|
|
153
|
+
actionMailboxId: "None",
|
|
154
|
+
},
|
|
155
|
+
{ state: FilterState.Active, actionMailboxId: "mbx-picked" },
|
|
156
|
+
NOW,
|
|
157
|
+
).state,
|
|
158
|
+
FilterState.Active,
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const awaiting = {
|
|
164
|
+
...standing,
|
|
165
|
+
state: FilterState.Disabled,
|
|
166
|
+
disabledReason: FilterDisabledReason.AwaitingFolder,
|
|
167
|
+
actionMailboxId: "None",
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
it("keeps a folderless reason when the user turns an awaiting filter off again", () => {
|
|
171
|
+
const resolved = resolveFilterUpdate(
|
|
172
|
+
awaiting,
|
|
173
|
+
{ state: FilterState.Disabled },
|
|
174
|
+
NOW,
|
|
175
|
+
);
|
|
176
|
+
assert.equal(resolved.state, FilterState.Disabled);
|
|
177
|
+
assert.equal("disabledReason" in resolved, false);
|
|
178
|
+
assert.throws(
|
|
179
|
+
() =>
|
|
180
|
+
resolveFilterUpdate(
|
|
181
|
+
{ ...awaiting, state: resolved.state ?? awaiting.state },
|
|
182
|
+
{ state: FilterState.Active },
|
|
183
|
+
NOW,
|
|
184
|
+
),
|
|
185
|
+
/pick a folder in the rule/i,
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("keeps a lapsed folderless filter off when its date moves forward", () => {
|
|
190
|
+
const lapsed = {
|
|
191
|
+
...awaiting,
|
|
192
|
+
scope: FilterScope.Temporary,
|
|
193
|
+
expiresAt: "2026-01-01T00:00:00+00:00",
|
|
194
|
+
disabledReason: FilterDisabledReason.FolderCreateFailed,
|
|
195
|
+
};
|
|
196
|
+
const resolved = resolveFilterUpdate(
|
|
197
|
+
lapsed,
|
|
198
|
+
{
|
|
199
|
+
scope: FilterScope.Temporary,
|
|
200
|
+
expiresAt: "2027-01-01T00:00:00+00:00",
|
|
201
|
+
},
|
|
202
|
+
NOW,
|
|
203
|
+
);
|
|
204
|
+
assert.equal(resolved.state, FilterState.Disabled);
|
|
205
|
+
assert.equal("disabledReason" in resolved, false);
|
|
206
|
+
assert.throws(
|
|
207
|
+
() =>
|
|
208
|
+
resolveFilterUpdate(
|
|
209
|
+
{ ...lapsed, expiresAt: "2027-01-01T00:00:00+00:00" },
|
|
210
|
+
{ state: FilterState.Active },
|
|
211
|
+
NOW,
|
|
212
|
+
),
|
|
213
|
+
/pick a folder in the rule/i,
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("marks a folderless filter UserDisabled once the user picks a folder", () => {
|
|
218
|
+
assert.deepEqual(
|
|
219
|
+
resolveFilterUpdate(awaiting, { actionMailboxId: "mbx-picked" }, NOW),
|
|
220
|
+
{
|
|
221
|
+
actionMailboxId: "mbx-picked",
|
|
222
|
+
disabledReason: FilterDisabledReason.UserDisabled,
|
|
223
|
+
},
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("refuses Expired as a state the user sets", () => {
|
|
228
|
+
assert.throws(
|
|
229
|
+
() => resolveFilterUpdate(standing, { state: FilterState.Expired }, NOW),
|
|
230
|
+
/expires through its date/,
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("refuses to turn on a temporary filter whose date has passed", () => {
|
|
235
|
+
assert.throws(
|
|
236
|
+
() =>
|
|
237
|
+
resolveFilterUpdate(
|
|
238
|
+
{
|
|
239
|
+
...standing,
|
|
240
|
+
scope: FilterScope.Temporary,
|
|
241
|
+
expiresAt: "2026-01-01T00:00:00+00:00",
|
|
242
|
+
state: FilterState.Disabled,
|
|
243
|
+
},
|
|
244
|
+
{ state: FilterState.Active },
|
|
245
|
+
NOW,
|
|
246
|
+
),
|
|
247
|
+
/has expired/,
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("leaves a patch that names neither state nor timing untouched", () => {
|
|
252
|
+
assert.deepEqual(resolveFilterUpdate(standing, { name: "Receipts" }, NOW), {
|
|
253
|
+
name: "Receipts",
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
58
258
|
describe("rejectAnchorMutation", () => {
|
|
59
259
|
it("throws a 400 when the body carries anchorMessageId", () => {
|
|
60
260
|
assert.throws(
|
|
@@ -197,6 +397,7 @@ describe("createFilterWithAnchor (#351)", () => {
|
|
|
197
397
|
name: baseInput.name,
|
|
198
398
|
scope: FilterScope.Standing,
|
|
199
399
|
state: FilterState.Active,
|
|
400
|
+
disabledReason: "None",
|
|
200
401
|
hasAnchor: false,
|
|
201
402
|
ruleChangedAt: 1_700_000_000,
|
|
202
403
|
actionChangedAt: 1_700_000_000,
|
package/src/handlers/filter.ts
CHANGED
|
@@ -12,7 +12,11 @@ import {
|
|
|
12
12
|
type UpdateFilterInput,
|
|
13
13
|
} from "@remit/data-ports";
|
|
14
14
|
import { BadRequestError } from "@remit/data-ports/errors";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
FilterDisabledReason,
|
|
17
|
+
FilterScope,
|
|
18
|
+
FilterState,
|
|
19
|
+
} from "@remit/domain-enums";
|
|
16
20
|
import type { AnchorPayload } from "@remit/search-service";
|
|
17
21
|
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
18
22
|
import { getAccountConfigIdFromEvent } from "../auth.js";
|
|
@@ -76,6 +80,7 @@ export const pickFilterUpdate = (
|
|
|
76
80
|
if (Object.hasOwn(body, "actionMailboxId")) {
|
|
77
81
|
patch.actionMailboxId = body.actionMailboxId;
|
|
78
82
|
}
|
|
83
|
+
if (Object.hasOwn(body, "state")) patch.state = body.state;
|
|
79
84
|
return patch;
|
|
80
85
|
};
|
|
81
86
|
|
|
@@ -164,6 +169,115 @@ export const resolveFilterScopeExpiry = (
|
|
|
164
169
|
return { scope, expiresAt, ttl, state };
|
|
165
170
|
};
|
|
166
171
|
|
|
172
|
+
const hasLapsed = (
|
|
173
|
+
filter: Pick<FilterItem, "scope" | "expiresAt">,
|
|
174
|
+
now: number,
|
|
175
|
+
): boolean =>
|
|
176
|
+
filter.scope === FilterScope.Temporary &&
|
|
177
|
+
filter.expiresAt !== undefined &&
|
|
178
|
+
new Date(filter.expiresAt).getTime() <= now;
|
|
179
|
+
|
|
180
|
+
const FOLDERLESS_REASONS: ReadonlySet<FilterItem["disabledReason"]> = new Set([
|
|
181
|
+
FilterDisabledReason.AwaitingFolder,
|
|
182
|
+
FilterDisabledReason.FolderCreateFailed,
|
|
183
|
+
]);
|
|
184
|
+
|
|
185
|
+
const folderlessRefusal = (
|
|
186
|
+
reason: FilterItem["disabledReason"],
|
|
187
|
+
): BadRequestError =>
|
|
188
|
+
new BadRequestError(
|
|
189
|
+
reason === FilterDisabledReason.AwaitingFolder
|
|
190
|
+
? "This filter has no folder to move mail into yet. Wait for its folder to be created on the mail server, or pick a folder in the rule, then turn it on."
|
|
191
|
+
: "The mail server refused to create this filter's folder. Pick a folder in the rule, then turn it on.",
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
export const resolveFilterUpdate = (
|
|
195
|
+
current: Pick<
|
|
196
|
+
FilterItem,
|
|
197
|
+
"scope" | "expiresAt" | "state" | "disabledReason" | "actionMailboxId"
|
|
198
|
+
>,
|
|
199
|
+
patch: Partial<UpdateFilterInput>,
|
|
200
|
+
now: number = Date.now(),
|
|
201
|
+
): Partial<UpdateFilterInput> => {
|
|
202
|
+
if (patch.state === FilterState.Expired) {
|
|
203
|
+
throw new BadRequestError(
|
|
204
|
+
"A filter expires through its date, not its state. Set it Active or Disabled, or change its expiry.",
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
const folderless =
|
|
208
|
+
current.state === FilterState.Disabled &&
|
|
209
|
+
FOLDERLESS_REASONS.has(current.disabledReason);
|
|
210
|
+
const folder = patch.actionMailboxId ?? current.actionMailboxId;
|
|
211
|
+
const picked = folderless && folder !== FILTER_NO_ACTION;
|
|
212
|
+
const pickedReason = picked
|
|
213
|
+
? { disabledReason: FilterDisabledReason.UserDisabled }
|
|
214
|
+
: {};
|
|
215
|
+
|
|
216
|
+
const touchesTiming =
|
|
217
|
+
Object.hasOwn(patch, "scope") || Object.hasOwn(patch, "expiresAt");
|
|
218
|
+
if (!touchesTiming && patch.state === undefined) {
|
|
219
|
+
return { ...patch, ...pickedReason };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const timing = touchesTiming
|
|
223
|
+
? resolveFilterScopeExpiry(current, patch)
|
|
224
|
+
: undefined;
|
|
225
|
+
const lapsed = timing
|
|
226
|
+
? timing.state === FilterState.Expired
|
|
227
|
+
: hasLapsed(current, now);
|
|
228
|
+
|
|
229
|
+
if (patch.state === FilterState.Active && lapsed) {
|
|
230
|
+
throw new BadRequestError(
|
|
231
|
+
"This filter has expired. Move its date forward or make it Standing to turn it back on.",
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const wanted = patch.state ?? current.state;
|
|
236
|
+
const merged = { ...patch, ...timing };
|
|
237
|
+
if (wanted === FilterState.Disabled) {
|
|
238
|
+
return {
|
|
239
|
+
...merged,
|
|
240
|
+
state: FilterState.Disabled,
|
|
241
|
+
...(folderless
|
|
242
|
+
? pickedReason
|
|
243
|
+
: patch.state === FilterState.Disabled
|
|
244
|
+
? { disabledReason: FilterDisabledReason.UserDisabled }
|
|
245
|
+
: {}),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
if (lapsed) {
|
|
249
|
+
return {
|
|
250
|
+
...merged,
|
|
251
|
+
state: FilterState.Expired,
|
|
252
|
+
disabledReason: FilterDisabledReason.None,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
if (folderless && folder === FILTER_NO_ACTION) {
|
|
256
|
+
throw folderlessRefusal(current.disabledReason);
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
...merged,
|
|
260
|
+
state: FilterState.Active,
|
|
261
|
+
disabledReason: FilterDisabledReason.None,
|
|
262
|
+
};
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const assertEnabledTargetExists = async (
|
|
266
|
+
client: {
|
|
267
|
+
mailbox: Pick<IMailboxRepository, "get" | "resolveAccountId">;
|
|
268
|
+
},
|
|
269
|
+
accountId: string,
|
|
270
|
+
actionMailboxId: string,
|
|
271
|
+
): Promise<void> => {
|
|
272
|
+
if (actionMailboxId === FILTER_NO_ACTION) return;
|
|
273
|
+
if ((await client.mailbox.resolveAccountId(actionMailboxId)) === null) {
|
|
274
|
+
throw new BadRequestError(
|
|
275
|
+
"The folder this filter moves mail into no longer exists. Pick another folder, then turn the filter on.",
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
await assertActionMailboxSettled(client, accountId, actionMailboxId);
|
|
279
|
+
};
|
|
280
|
+
|
|
167
281
|
const toFilterResponse = (item: FilterItem): FilterResponse => ({
|
|
168
282
|
filterId: item.filterId,
|
|
169
283
|
accountConfigId: item.accountConfigId,
|
|
@@ -171,6 +285,7 @@ const toFilterResponse = (item: FilterItem): FilterResponse => ({
|
|
|
171
285
|
scope: item.scope,
|
|
172
286
|
expiresAt: item.expiresAt,
|
|
173
287
|
state: item.state,
|
|
288
|
+
disabledReason: item.disabledReason,
|
|
174
289
|
hasAnchor: item.hasAnchor,
|
|
175
290
|
ruleChangedAt: item.ruleChangedAt,
|
|
176
291
|
actionChangedAt: item.actionChangedAt,
|
|
@@ -340,17 +455,18 @@ export const FilterDetailOperations: Record<
|
|
|
340
455
|
const { filter } = client;
|
|
341
456
|
const patch = pickFilterUpdate(body as Partial<UpdateFilterRequestBody>);
|
|
342
457
|
await assertActionMailboxSettled(client, accountId, patch.actionMailboxId);
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
458
|
+
const current = await filter.get(accountConfigId, filterId);
|
|
459
|
+
const resolvedPatch = resolveFilterUpdate(current, patch);
|
|
460
|
+
if (
|
|
461
|
+
patch.state === FilterState.Active &&
|
|
462
|
+
patch.actionMailboxId === undefined
|
|
463
|
+
) {
|
|
464
|
+
await assertEnabledTargetExists(
|
|
465
|
+
client,
|
|
466
|
+
accountId,
|
|
467
|
+
current.actionMailboxId,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
354
470
|
|
|
355
471
|
const updated = await filter.update(
|
|
356
472
|
accountConfigId,
|