@remit/backend 0.0.33 → 0.0.35
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/scripts/backfill-list-id.ts +68 -0
- package/src/handlers/filter.test.ts +165 -0
- package/src/handlers/filter.ts +128 -14
- package/tsconfig.json +1 -1
package/package.json
CHANGED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { logger } from "@remit/logger-lambda";
|
|
5
|
+
import {
|
|
6
|
+
backfillListIds,
|
|
7
|
+
type ListIdBackfillCheckpoint,
|
|
8
|
+
type ListIdBackfillCheckpointStore,
|
|
9
|
+
} from "@remit/mailbox-service";
|
|
10
|
+
import { getClient } from "../src/service/dynamodb.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One-time, full-corpus `ThreadMessage.listId` backfill (issue #263). Ships as
|
|
14
|
+
* an alternate entrypoint baked into the backend image — "the backend image
|
|
15
|
+
* with a command", the same shape `migrate.mjs` uses — rather than a service
|
|
16
|
+
* of its own. Unlike `migrate`, it is never wired into a compose one-shot: a
|
|
17
|
+
* full pass runs once per install, not on every restart. Invoke it by hand:
|
|
18
|
+
*
|
|
19
|
+
* docker compose -f docker-compose.sqlite.yml run --rm backend \
|
|
20
|
+
* node backfill-list-id.mjs
|
|
21
|
+
*
|
|
22
|
+
* Safe to interrupt: progress is checkpointed to disk after every page and
|
|
23
|
+
* picked back up on the next run, and every row it touches is read-only
|
|
24
|
+
* against the stored message, so a rerun after a partial pass is a no-op for
|
|
25
|
+
* everything already done.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const CHECKPOINT_PATH =
|
|
29
|
+
process.env.LIST_ID_BACKFILL_CHECKPOINT_PATH ??
|
|
30
|
+
"/data/sqlite/list-id-backfill-checkpoint.json";
|
|
31
|
+
|
|
32
|
+
const fileCheckpointStore: ListIdBackfillCheckpointStore = {
|
|
33
|
+
load: async () => {
|
|
34
|
+
if (!existsSync(CHECKPOINT_PATH)) return undefined;
|
|
35
|
+
const raw = await readFile(CHECKPOINT_PATH, "utf8");
|
|
36
|
+
return JSON.parse(raw) as ListIdBackfillCheckpoint;
|
|
37
|
+
},
|
|
38
|
+
save: async (checkpoint) => {
|
|
39
|
+
await mkdir(dirname(CHECKPOINT_PATH), { recursive: true });
|
|
40
|
+
await writeFile(CHECKPOINT_PATH, JSON.stringify(checkpoint));
|
|
41
|
+
},
|
|
42
|
+
clear: async () => {
|
|
43
|
+
await rm(CHECKPOINT_PATH, { force: true });
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const run = async (): Promise<void> => {
|
|
48
|
+
const client = await getClient();
|
|
49
|
+
|
|
50
|
+
const result = await backfillListIds(
|
|
51
|
+
{
|
|
52
|
+
accountConfigService: client.accountConfig,
|
|
53
|
+
threadMessageService: client.threadMessage,
|
|
54
|
+
messageService: client.message,
|
|
55
|
+
storageService: client.storage,
|
|
56
|
+
},
|
|
57
|
+
{ checkpointStore: fileCheckpointStore, logger },
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
console.log("[backfill-list-id] done", result);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
run()
|
|
64
|
+
.then(() => process.exit(0))
|
|
65
|
+
.catch((error: unknown) => {
|
|
66
|
+
console.error("[backfill-list-id] failed", error);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { UpdateFilterInput } from "@remit/data-ports";
|
|
4
|
+
import { FilterScope, FilterState } from "@remit/domain-enums";
|
|
5
|
+
import {
|
|
6
|
+
pickFilterUpdate,
|
|
7
|
+
rejectAnchorMutation,
|
|
8
|
+
resolveFilterScopeExpiry,
|
|
9
|
+
} from "./filter.js";
|
|
10
|
+
|
|
11
|
+
describe("pickFilterUpdate", () => {
|
|
12
|
+
it("carries scope and expiresAt through, alongside the predicate/action fields", () => {
|
|
13
|
+
const patch = pickFilterUpdate({
|
|
14
|
+
scope: FilterScope.Temporary,
|
|
15
|
+
expiresAt: "2027-01-01T00:00:00+00:00",
|
|
16
|
+
matchOperator: "Or",
|
|
17
|
+
});
|
|
18
|
+
assert.equal(patch.scope, FilterScope.Temporary);
|
|
19
|
+
assert.equal(patch.expiresAt, "2027-01-01T00:00:00+00:00");
|
|
20
|
+
assert.equal(patch.matchOperator, "Or");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("leaves scope/expiresAt absent when the body doesn't touch them", () => {
|
|
24
|
+
const patch = pickFilterUpdate({ name: "Receipts" });
|
|
25
|
+
assert.equal("scope" in patch, false);
|
|
26
|
+
assert.equal("expiresAt" in patch, false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("drops any server-derived field smuggled into the body", () => {
|
|
30
|
+
const raw: Record<string, unknown> = {
|
|
31
|
+
name: "Receipts",
|
|
32
|
+
ttl: 123,
|
|
33
|
+
state: "Expired",
|
|
34
|
+
hasAnchor: true,
|
|
35
|
+
ruleChangedAt: 999,
|
|
36
|
+
filterId: "sneaky",
|
|
37
|
+
};
|
|
38
|
+
const patch = pickFilterUpdate(raw as Partial<UpdateFilterInput>);
|
|
39
|
+
assert.deepEqual(patch, { name: "Receipts" });
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("rejectAnchorMutation", () => {
|
|
44
|
+
it("throws a 400 when the body carries anchorMessageId", () => {
|
|
45
|
+
assert.throws(
|
|
46
|
+
() => rejectAnchorMutation({ anchorMessageId: "msg-1" }),
|
|
47
|
+
(error: unknown) => {
|
|
48
|
+
assert.equal((error as { statusCode?: number }).statusCode, 400);
|
|
49
|
+
assert.match(
|
|
50
|
+
(error as Error).message,
|
|
51
|
+
/anchor can't change after creation/,
|
|
52
|
+
);
|
|
53
|
+
return true;
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("does not throw for an ordinary predicate/action/scope patch", () => {
|
|
59
|
+
assert.doesNotThrow(() =>
|
|
60
|
+
rejectAnchorMutation({
|
|
61
|
+
name: "Receipts",
|
|
62
|
+
scope: "Temporary",
|
|
63
|
+
expiresAt: "2027-01-01T00:00:00+00:00",
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("resolveFilterScopeExpiry (reader #266)", () => {
|
|
70
|
+
it("moves Standing to Temporary given a future expiresAt", () => {
|
|
71
|
+
const resolved = resolveFilterScopeExpiry(
|
|
72
|
+
{ scope: FilterScope.Standing, expiresAt: undefined },
|
|
73
|
+
{ scope: FilterScope.Temporary, expiresAt: "2099-01-01T00:00:00+00:00" },
|
|
74
|
+
);
|
|
75
|
+
assert.equal(resolved.scope, FilterScope.Temporary);
|
|
76
|
+
assert.equal(resolved.expiresAt, "2099-01-01T00:00:00+00:00");
|
|
77
|
+
assert.equal(resolved.state, FilterState.Active);
|
|
78
|
+
assert.ok(resolved.ttl && resolved.ttl > 0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("rejects moving to Temporary without an expiresAt", () => {
|
|
82
|
+
assert.throws(
|
|
83
|
+
() =>
|
|
84
|
+
resolveFilterScopeExpiry(
|
|
85
|
+
{ scope: FilterScope.Standing, expiresAt: undefined },
|
|
86
|
+
{ scope: FilterScope.Temporary },
|
|
87
|
+
),
|
|
88
|
+
(error: unknown) => {
|
|
89
|
+
assert.equal((error as { statusCode?: number }).statusCode, 400);
|
|
90
|
+
assert.match((error as Error).message, /needs expiresAt/);
|
|
91
|
+
return true;
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("rejects an expiresAt patch that isn't paired with a Temporary scope", () => {
|
|
97
|
+
assert.throws(
|
|
98
|
+
() =>
|
|
99
|
+
resolveFilterScopeExpiry(
|
|
100
|
+
{ scope: FilterScope.Standing, expiresAt: undefined },
|
|
101
|
+
{ expiresAt: "2099-01-01T00:00:00+00:00" },
|
|
102
|
+
),
|
|
103
|
+
(error: unknown) => {
|
|
104
|
+
assert.equal((error as { statusCode?: number }).statusCode, 400);
|
|
105
|
+
assert.match((error as Error).message, /only applies to a Temporary/);
|
|
106
|
+
return true;
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("clears expiresAt/ttl and reports Active when moving Temporary to Standing", () => {
|
|
112
|
+
const resolved = resolveFilterScopeExpiry(
|
|
113
|
+
{ scope: FilterScope.Temporary, expiresAt: "2020-01-01T00:00:00+00:00" },
|
|
114
|
+
{ scope: FilterScope.Standing },
|
|
115
|
+
);
|
|
116
|
+
assert.equal(resolved.scope, FilterScope.Standing);
|
|
117
|
+
assert.equal(resolved.expiresAt, undefined);
|
|
118
|
+
assert.equal(resolved.ttl, undefined);
|
|
119
|
+
assert.equal(resolved.state, FilterState.Active);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("recomputes ttl for an expiresAt-only change on an already-Temporary filter", () => {
|
|
123
|
+
const resolved = resolveFilterScopeExpiry(
|
|
124
|
+
{ scope: FilterScope.Temporary, expiresAt: "2026-08-01T00:00:00+00:00" },
|
|
125
|
+
{ expiresAt: "2099-06-01T00:00:00+00:00" },
|
|
126
|
+
);
|
|
127
|
+
assert.equal(resolved.scope, FilterScope.Temporary);
|
|
128
|
+
assert.equal(resolved.expiresAt, "2099-06-01T00:00:00+00:00");
|
|
129
|
+
assert.equal(
|
|
130
|
+
resolved.ttl,
|
|
131
|
+
Math.floor(new Date("2099-06-01T00:00:00+00:00").getTime() / 1000),
|
|
132
|
+
);
|
|
133
|
+
assert.equal(resolved.state, FilterState.Active);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("reactivates a lapsed filter extended into the future", () => {
|
|
137
|
+
const resolved = resolveFilterScopeExpiry(
|
|
138
|
+
{ scope: FilterScope.Temporary, expiresAt: "2020-01-01T00:00:00+00:00" },
|
|
139
|
+
{ expiresAt: "2099-01-01T00:00:00+00:00" },
|
|
140
|
+
);
|
|
141
|
+
assert.equal(resolved.state, FilterState.Active);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("reads Expired immediately when the new expiresAt is already in the past", () => {
|
|
145
|
+
const resolved = resolveFilterScopeExpiry(
|
|
146
|
+
{ scope: FilterScope.Temporary, expiresAt: "2099-01-01T00:00:00+00:00" },
|
|
147
|
+
{ expiresAt: "2020-01-01T00:00:00+00:00" },
|
|
148
|
+
);
|
|
149
|
+
assert.equal(resolved.state, FilterState.Expired);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("rejects an unparseable expiresAt", () => {
|
|
153
|
+
assert.throws(
|
|
154
|
+
() =>
|
|
155
|
+
resolveFilterScopeExpiry(
|
|
156
|
+
{ scope: FilterScope.Standing, expiresAt: undefined },
|
|
157
|
+
{ scope: FilterScope.Temporary, expiresAt: "not-a-date" },
|
|
158
|
+
),
|
|
159
|
+
(error: unknown) => {
|
|
160
|
+
assert.equal((error as { statusCode?: number }).statusCode, 400);
|
|
161
|
+
return true;
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
});
|
package/src/handlers/filter.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
CreateFilterInput,
|
|
3
3
|
FilterResponse,
|
|
4
|
-
UpdateFilterInput,
|
|
5
4
|
} from "@remit/api-openapi-types";
|
|
6
|
-
import type { FilterItem } from "@remit/data-ports";
|
|
7
|
-
import {
|
|
8
|
-
import { FilterScope } from "@remit/domain-enums";
|
|
5
|
+
import type { FilterItem, UpdateFilterInput } from "@remit/data-ports";
|
|
6
|
+
import { BadRequestError } from "@remit/data-ports/errors";
|
|
7
|
+
import { FilterScope, FilterState } from "@remit/domain-enums";
|
|
9
8
|
import type { AnchorPayload } from "@remit/search-service";
|
|
10
9
|
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
11
10
|
import { getAccountConfigIdFromEvent } from "../auth.js";
|
|
@@ -40,7 +39,7 @@ export interface FilterCrudDeps {
|
|
|
40
39
|
update(
|
|
41
40
|
accountConfigId: string,
|
|
42
41
|
filterId: string,
|
|
43
|
-
input:
|
|
42
|
+
input: UpdateFilterInput,
|
|
44
43
|
): Promise<FilterItem>;
|
|
45
44
|
delete(accountConfigId: string, filterId: string): Promise<void>;
|
|
46
45
|
refreshExpiry(item: FilterItem): Promise<FilterItem>;
|
|
@@ -78,24 +77,40 @@ export const deriveFilterTtl = (
|
|
|
78
77
|
if (scope !== FilterScope.Temporary || !expiresAt) return undefined;
|
|
79
78
|
const ms = new Date(expiresAt).getTime();
|
|
80
79
|
if (Number.isNaN(ms)) {
|
|
81
|
-
throw new
|
|
80
|
+
throw new BadRequestError(`Invalid expiresAt: ${expiresAt}`);
|
|
82
81
|
}
|
|
83
82
|
return Math.floor(ms / 1000);
|
|
84
83
|
};
|
|
85
84
|
|
|
86
85
|
/**
|
|
87
|
-
* Reduce a PATCH body to the fields a filter update may set (RFC 034
|
|
88
|
-
* absence: a key not present in the body is not present in
|
|
89
|
-
* name-only rename yields `{ name }` and never touches a
|
|
90
|
-
* — the service's
|
|
91
|
-
*
|
|
92
|
-
*
|
|
86
|
+
* Reduce a PATCH body to the fields a filter update may set (RFC 034, reader
|
|
87
|
+
* #266). Preserves absence: a key not present in the body is not present in
|
|
88
|
+
* the patch, so a name-only rename yields `{ name }` and never touches a
|
|
89
|
+
* predicate/action/scope/expiresAt field — the service's
|
|
90
|
+
* `changesRuleAssertion` guard then leaves `ruleChangedAt` untouched (Decision
|
|
91
|
+
* 3.2). Any field outside this set — most notably a server-derived
|
|
92
|
+
* `state`/`ttl`/`hasAnchor`/`ruleChangedAt` smuggled into the body — is
|
|
93
|
+
* dropped. `scope`/`expiresAt` land here as the caller sent them; a patch that
|
|
94
|
+
* touches either still needs `resolveFilterScopeExpiry` to merge them against
|
|
95
|
+
* 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.
|
|
93
106
|
*/
|
|
94
107
|
export const pickFilterUpdate = (
|
|
95
108
|
body: Partial<UpdateFilterInput>,
|
|
96
109
|
): Partial<UpdateFilterInput> => {
|
|
97
110
|
const patch: Partial<UpdateFilterInput> = {};
|
|
98
111
|
if (Object.hasOwn(body, "name")) patch.name = body.name;
|
|
112
|
+
if (Object.hasOwn(body, "scope")) patch.scope = body.scope;
|
|
113
|
+
if (Object.hasOwn(body, "expiresAt")) patch.expiresAt = body.expiresAt;
|
|
99
114
|
if (Object.hasOwn(body, "matchOperator")) {
|
|
100
115
|
patch.matchOperator = body.matchOperator;
|
|
101
116
|
}
|
|
@@ -111,6 +126,91 @@ export const pickFilterUpdate = (
|
|
|
111
126
|
return patch;
|
|
112
127
|
};
|
|
113
128
|
|
|
129
|
+
/**
|
|
130
|
+
* A filter's semantic anchor is set once, from `anchorMessageId`, only on
|
|
131
|
+
* `CreateFilterInput` (RFC 034 Decision 2) — the generated `UpdateFilterInput`
|
|
132
|
+
* carries no anchor field at all, so a well-behaved client cannot express this.
|
|
133
|
+
* This only catches a caller handing the raw PATCH body an anchor field
|
|
134
|
+
* anyway, and rejects it loudly rather than silently dropping it (reader
|
|
135
|
+
* #266): repointing an anchor would silently change what a saved filter
|
|
136
|
+
* matches with nothing visible changing, and that deserves a new filter
|
|
137
|
+
* instead — scope and expiry cover the rest of what changed here.
|
|
138
|
+
*/
|
|
139
|
+
const ANCHOR_MUTATION_FIELDS = ["anchorMessageId"] as const;
|
|
140
|
+
|
|
141
|
+
export const rejectAnchorMutation = (body: Record<string, unknown>): void => {
|
|
142
|
+
const attempted = ANCHOR_MUTATION_FIELDS.find((field) =>
|
|
143
|
+
Object.hasOwn(body, field),
|
|
144
|
+
);
|
|
145
|
+
if (!attempted) return;
|
|
146
|
+
throw new BadRequestError(
|
|
147
|
+
"A filter's semantic anchor can't change after creation — create a new filter instead.",
|
|
148
|
+
);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export interface ResolvedScopeExpiry {
|
|
152
|
+
scope: FilterItem["scope"];
|
|
153
|
+
expiresAt?: string;
|
|
154
|
+
ttl?: number;
|
|
155
|
+
state: FilterItem["state"];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Merge a PATCH body's `scope`/`expiresAt` against the filter's stored values
|
|
160
|
+
* and resolve the full set the row must land on (reader #266). Only called
|
|
161
|
+
* when the patch touches `scope` or `expiresAt` — one that touches neither
|
|
162
|
+
* leaves both, and `ttl`/`state`, untouched exactly as before this ticket.
|
|
163
|
+
*
|
|
164
|
+
* A `Temporary` result always carries a defined `expiresAt` — moving to
|
|
165
|
+
* `Temporary` without one is rejected, matching the Filter model's invariant
|
|
166
|
+
* that a Temporary filter always has an expiry (RFC 034 Decision 1.1). Moving
|
|
167
|
+
* to `Standing` clears `expiresAt`/`ttl`, matching the model's invariant that
|
|
168
|
+
* both stay absent outside `Temporary` (RFC 034 Decision 1.4) — the reserved
|
|
169
|
+
* `ttl` attribute must never linger once a filter is no longer temporary.
|
|
170
|
+
*
|
|
171
|
+
* `state` is recomputed the same comparison `refreshExpiry` runs, so a filter
|
|
172
|
+
* extended past a lapsed `expiresAt` (or switched to `Standing`) reads Active
|
|
173
|
+
* immediately — the index-time worker's `byAccountAndState` query needs that
|
|
174
|
+
* now, not on whatever read happens to touch the row next.
|
|
175
|
+
*/
|
|
176
|
+
export const resolveFilterScopeExpiry = (
|
|
177
|
+
current: Pick<FilterItem, "scope" | "expiresAt">,
|
|
178
|
+
patch: Partial<UpdateFilterInput>,
|
|
179
|
+
): ResolvedScopeExpiry => {
|
|
180
|
+
const scope = patch.scope ?? current.scope;
|
|
181
|
+
|
|
182
|
+
if (scope !== FilterScope.Temporary) {
|
|
183
|
+
if (Object.hasOwn(patch, "expiresAt") && patch.expiresAt) {
|
|
184
|
+
throw new BadRequestError(
|
|
185
|
+
"expiresAt only applies to a Temporary filter — switch scope to Temporary to set one.",
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
scope,
|
|
190
|
+
expiresAt: undefined,
|
|
191
|
+
ttl: undefined,
|
|
192
|
+
state: FilterState.Active,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const expiresAt = Object.hasOwn(patch, "expiresAt")
|
|
197
|
+
? patch.expiresAt
|
|
198
|
+
: current.expiresAt;
|
|
199
|
+
if (!expiresAt) {
|
|
200
|
+
throw new BadRequestError(
|
|
201
|
+
"A Temporary filter needs expiresAt — pick a date, or switch scope to Standing.",
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const ttl = deriveFilterTtl(scope, expiresAt);
|
|
206
|
+
const state =
|
|
207
|
+
new Date(expiresAt).getTime() > Date.now()
|
|
208
|
+
? FilterState.Active
|
|
209
|
+
: FilterState.Expired;
|
|
210
|
+
|
|
211
|
+
return { scope, expiresAt, ttl, state };
|
|
212
|
+
};
|
|
213
|
+
|
|
114
214
|
const toFilterResponse = (item: FilterItem): FilterResponse => ({
|
|
115
215
|
filterId: item.filterId,
|
|
116
216
|
accountConfigId: item.accountConfigId,
|
|
@@ -260,17 +360,31 @@ export const FilterDetailOperations: Record<
|
|
|
260
360
|
accountId: string;
|
|
261
361
|
filterId: string;
|
|
262
362
|
};
|
|
263
|
-
const body = context.request.requestBody as
|
|
363
|
+
const body = context.request.requestBody as Record<string, unknown>;
|
|
364
|
+
rejectAnchorMutation(body);
|
|
264
365
|
|
|
265
366
|
const client = await getClient();
|
|
266
367
|
const account = await client.account.get(accountId);
|
|
267
368
|
assertAccountOwnership(account, accountConfigId, "act");
|
|
268
369
|
|
|
269
370
|
const { filter } = client;
|
|
371
|
+
const patch = pickFilterUpdate(body as Partial<UpdateFilterInput>);
|
|
372
|
+
const touchesScopeOrExpiry =
|
|
373
|
+
Object.hasOwn(patch, "scope") || Object.hasOwn(patch, "expiresAt");
|
|
374
|
+
const resolvedPatch: Partial<UpdateFilterInput> = touchesScopeOrExpiry
|
|
375
|
+
? {
|
|
376
|
+
...patch,
|
|
377
|
+
...resolveFilterScopeExpiry(
|
|
378
|
+
await filter.get(accountConfigId, filterId),
|
|
379
|
+
patch,
|
|
380
|
+
),
|
|
381
|
+
}
|
|
382
|
+
: patch;
|
|
383
|
+
|
|
270
384
|
const updated = await filter.update(
|
|
271
385
|
accountConfigId,
|
|
272
386
|
filterId,
|
|
273
|
-
|
|
387
|
+
resolvedPatch,
|
|
274
388
|
);
|
|
275
389
|
return toFilterResponse(updated);
|
|
276
390
|
},
|
package/tsconfig.json
CHANGED