@company-semantics/contracts 82.0.0 → 83.1.0
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/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +137 -0
- package/src/generated/openapi-routes.ts +2 -0
- package/src/index.ts +51 -1
- package/src/user-notifications/README.md +99 -12
- package/src/user-notifications/__tests__/README.md +19 -0
- package/src/user-notifications/__tests__/compile-fixtures.ts +54 -0
- package/src/user-notifications/__tests__/condition-keys.test.ts +126 -0
- package/src/user-notifications/__tests__/feed-lifecycle.test.ts +260 -0
- package/src/user-notifications/__tests__/lifecycle.test.ts +114 -0
- package/src/user-notifications/__tests__/vocabulary.test.ts +6 -1
- package/src/user-notifications/condition-keys.ts +64 -0
- package/src/user-notifications/feed-lifecycle.ts +76 -0
- package/src/user-notifications/index.ts +49 -2
- package/src/user-notifications/lifecycle-registry.ts +123 -0
- package/src/user-notifications/lifecycle.ts +75 -0
- package/src/user-notifications/schemas.ts +27 -6
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The feed's views, selectors and verb wire, and the inbox row's lifecycle
|
|
3
|
+
* fields (notification-lifecycle-is-declared-per-kind).
|
|
4
|
+
*
|
|
5
|
+
* INVARIANTS TESTED:
|
|
6
|
+
* - An `ids` selector names 1..FEED_SELECTOR_MAX_IDS ids.
|
|
7
|
+
* - A `view` selector carries an `asOf` snapshot instant; without one it fails.
|
|
8
|
+
* - The views are exactly `all` and `unread` — there is no `archived` view.
|
|
9
|
+
* - A delete returns an opaque restore key; a restore reports the rows it
|
|
10
|
+
* restored and the rows a newer active row had superseded.
|
|
11
|
+
* - `activityAt`, `occurrenceCount` and `resolvedAt` are REQUIRED on a row, and
|
|
12
|
+
* `viewCount` on the feed response.
|
|
13
|
+
* - Mark-read takes a selector, never a bare id list.
|
|
14
|
+
* - Every export is reachable from both the domain barrel and the root barrel.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, expect, it } from "vitest";
|
|
17
|
+
import * as root from "../../index";
|
|
18
|
+
import * as domain from "../index";
|
|
19
|
+
import {
|
|
20
|
+
FEED_SELECTOR_MAX_IDS,
|
|
21
|
+
FEED_VIEWS,
|
|
22
|
+
FeedDeleteRequestSchema,
|
|
23
|
+
FeedDeleteResponseSchema,
|
|
24
|
+
FeedMarkUnreadRequestSchema,
|
|
25
|
+
FeedMutationResponseSchema,
|
|
26
|
+
FeedRestoreRequestSchema,
|
|
27
|
+
FeedRestoreResponseSchema,
|
|
28
|
+
FeedSelectorSchema,
|
|
29
|
+
FeedViewSchema,
|
|
30
|
+
} from "../feed-lifecycle";
|
|
31
|
+
import {
|
|
32
|
+
FeedListResponseSchema,
|
|
33
|
+
FeedMarkReadRequestSchema,
|
|
34
|
+
UserNotificationSchema,
|
|
35
|
+
} from "../schemas";
|
|
36
|
+
|
|
37
|
+
const AS_OF = "2026-07-30T12:00:00.000Z";
|
|
38
|
+
|
|
39
|
+
function ids(count: number): string[] {
|
|
40
|
+
return Array.from({ length: count }, (_, i) => `n-${i}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function makeNotification(over: Record<string, unknown> = {}) {
|
|
44
|
+
return {
|
|
45
|
+
id: "n-1",
|
|
46
|
+
kind: "proactive.brief_morning",
|
|
47
|
+
target: { type: "org", id: "22222222-2222-4222-8222-222222222222" },
|
|
48
|
+
unitPath: null,
|
|
49
|
+
title: "Your morning brief is ready",
|
|
50
|
+
detail: null,
|
|
51
|
+
contextLabel: "Morning brief",
|
|
52
|
+
provider: null,
|
|
53
|
+
href: "/brief",
|
|
54
|
+
createdAt: "2026-07-28T08:00:00.000Z",
|
|
55
|
+
readAt: null,
|
|
56
|
+
activityAt: "2026-07-30T08:00:00.000Z",
|
|
57
|
+
occurrenceCount: 3,
|
|
58
|
+
resolvedAt: null,
|
|
59
|
+
...over,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe("FeedSelector", () => {
|
|
64
|
+
it("accepts exactly the maximum number of ids", () => {
|
|
65
|
+
expect(FEED_SELECTOR_MAX_IDS).toBe(200);
|
|
66
|
+
expect(
|
|
67
|
+
FeedSelectorSchema.safeParse({
|
|
68
|
+
kind: "ids",
|
|
69
|
+
notificationIds: ids(FEED_SELECTOR_MAX_IDS),
|
|
70
|
+
}).success,
|
|
71
|
+
).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("an ids selector refuses more than 200 ids", () => {
|
|
75
|
+
expect(
|
|
76
|
+
FeedSelectorSchema.safeParse({ kind: "ids", notificationIds: ids(201) })
|
|
77
|
+
.success,
|
|
78
|
+
).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("an ids selector refuses an empty selection", () => {
|
|
82
|
+
expect(
|
|
83
|
+
FeedSelectorSchema.safeParse({ kind: "ids", notificationIds: [] })
|
|
84
|
+
.success,
|
|
85
|
+
).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("a view selector requires asOf", () => {
|
|
89
|
+
// Without the snapshot, "everything in this view" would sweep in rows that
|
|
90
|
+
// arrived after the person acted.
|
|
91
|
+
expect(
|
|
92
|
+
FeedSelectorSchema.safeParse({ kind: "view", view: "unread" }).success,
|
|
93
|
+
).toBe(false);
|
|
94
|
+
expect(
|
|
95
|
+
FeedSelectorSchema.safeParse({ kind: "view", view: "unread", asOf: "" })
|
|
96
|
+
.success,
|
|
97
|
+
).toBe(false);
|
|
98
|
+
expect(
|
|
99
|
+
FeedSelectorSchema.safeParse({
|
|
100
|
+
kind: "view",
|
|
101
|
+
view: "unread",
|
|
102
|
+
asOf: AS_OF,
|
|
103
|
+
}).success,
|
|
104
|
+
).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("an unknown view is rejected", () => {
|
|
108
|
+
// There is no Archive — and so no archived view.
|
|
109
|
+
expect(FEED_VIEWS).toEqual(["all", "unread"]);
|
|
110
|
+
expect(FeedViewSchema.safeParse("archived").success).toBe(false);
|
|
111
|
+
expect(
|
|
112
|
+
FeedSelectorSchema.safeParse({
|
|
113
|
+
kind: "view",
|
|
114
|
+
view: "archived",
|
|
115
|
+
asOf: AS_OF,
|
|
116
|
+
}).success,
|
|
117
|
+
).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("refuses a selector without its discriminant", () => {
|
|
121
|
+
expect(
|
|
122
|
+
FeedSelectorSchema.safeParse({ notificationIds: ids(1) }).success,
|
|
123
|
+
).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("the verbs take a selector", () => {
|
|
128
|
+
const selector = { kind: "ids", notificationIds: ids(2) };
|
|
129
|
+
|
|
130
|
+
it("mark read, mark unread and delete all accept { selector }", () => {
|
|
131
|
+
for (const schema of [
|
|
132
|
+
FeedMarkReadRequestSchema,
|
|
133
|
+
FeedMarkUnreadRequestSchema,
|
|
134
|
+
FeedDeleteRequestSchema,
|
|
135
|
+
]) {
|
|
136
|
+
expect(schema.safeParse({ selector }).success).toBe(true);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("mark read no longer accepts a bare id list", () => {
|
|
141
|
+
expect(
|
|
142
|
+
FeedMarkReadRequestSchema.safeParse({ notificationIds: ids(2) }).success,
|
|
143
|
+
).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("a restore request needs a non-empty restore key", () => {
|
|
147
|
+
expect(
|
|
148
|
+
FeedRestoreRequestSchema.safeParse({ restoreKey: "op-1" }).success,
|
|
149
|
+
).toBe(true);
|
|
150
|
+
expect(FeedRestoreRequestSchema.safeParse({ restoreKey: "" }).success).toBe(
|
|
151
|
+
false,
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("the verb responses", () => {
|
|
157
|
+
it("a mutation reports a non-negative affected count", () => {
|
|
158
|
+
expect(FeedMutationResponseSchema.safeParse({ affected: 0 }).success).toBe(
|
|
159
|
+
true,
|
|
160
|
+
);
|
|
161
|
+
expect(FeedMutationResponseSchema.safeParse({ affected: -1 }).success).toBe(
|
|
162
|
+
false,
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("a delete returns an opaque restore key", () => {
|
|
167
|
+
expect(
|
|
168
|
+
FeedDeleteResponseSchema.safeParse({ affected: 2, restoreKey: "op-1" })
|
|
169
|
+
.success,
|
|
170
|
+
).toBe(true);
|
|
171
|
+
expect(FeedDeleteResponseSchema.safeParse({ affected: 2 }).success).toBe(
|
|
172
|
+
false,
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("a restore response counts superseded rows", () => {
|
|
177
|
+
const parsed = FeedRestoreResponseSchema.parse({
|
|
178
|
+
restored: 1,
|
|
179
|
+
superseded: 1,
|
|
180
|
+
});
|
|
181
|
+
expect(parsed.superseded).toBe(1);
|
|
182
|
+
expect(FeedRestoreResponseSchema.safeParse({ restored: 1 }).success).toBe(
|
|
183
|
+
false,
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("the inbox row's lifecycle fields", () => {
|
|
189
|
+
it("parses a superseded, unresolved row", () => {
|
|
190
|
+
expect(UserNotificationSchema.safeParse(makeNotification()).success).toBe(
|
|
191
|
+
true,
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("parses a resolved row", () => {
|
|
196
|
+
expect(
|
|
197
|
+
UserNotificationSchema.safeParse(
|
|
198
|
+
makeNotification({ resolvedAt: "2026-07-30T09:00:00.000Z" }),
|
|
199
|
+
).success,
|
|
200
|
+
).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("a notification without activityAt fails to parse", () => {
|
|
204
|
+
const { activityAt: _omitted, ...withoutActivity } = makeNotification();
|
|
205
|
+
expect(UserNotificationSchema.safeParse(withoutActivity).success).toBe(
|
|
206
|
+
false,
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("requires occurrenceCount, and refuses zero", () => {
|
|
211
|
+
const { occurrenceCount: _omitted, ...withoutCount } = makeNotification();
|
|
212
|
+
expect(UserNotificationSchema.safeParse(withoutCount).success).toBe(false);
|
|
213
|
+
expect(
|
|
214
|
+
UserNotificationSchema.safeParse(makeNotification({ occurrenceCount: 0 }))
|
|
215
|
+
.success,
|
|
216
|
+
).toBe(false);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("requires resolvedAt — null says 'standing', absence says nothing", () => {
|
|
220
|
+
const { resolvedAt: _omitted, ...withoutResolved } = makeNotification();
|
|
221
|
+
expect(UserNotificationSchema.safeParse(withoutResolved).success).toBe(
|
|
222
|
+
false,
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("the feed response requires viewCount", () => {
|
|
227
|
+
const base = {
|
|
228
|
+
items: [],
|
|
229
|
+
nextCursor: null,
|
|
230
|
+
unreadCount: 0,
|
|
231
|
+
truncated: false,
|
|
232
|
+
};
|
|
233
|
+
expect(FeedListResponseSchema.safeParse(base).success).toBe(false);
|
|
234
|
+
expect(
|
|
235
|
+
FeedListResponseSchema.safeParse({ ...base, viewCount: 0 }).success,
|
|
236
|
+
).toBe(true);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe("barrel reachability", () => {
|
|
241
|
+
const values = {
|
|
242
|
+
FEED_SELECTOR_MAX_IDS,
|
|
243
|
+
FEED_VIEWS,
|
|
244
|
+
FeedDeleteRequestSchema,
|
|
245
|
+
FeedDeleteResponseSchema,
|
|
246
|
+
FeedMarkUnreadRequestSchema,
|
|
247
|
+
FeedMutationResponseSchema,
|
|
248
|
+
FeedRestoreRequestSchema,
|
|
249
|
+
FeedRestoreResponseSchema,
|
|
250
|
+
FeedSelectorSchema,
|
|
251
|
+
FeedViewSchema,
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
it("every feed-lifecycle value is exported from both barrels", () => {
|
|
255
|
+
for (const [name, value] of Object.entries(values)) {
|
|
256
|
+
expect((domain as Record<string, unknown>)[name]).toBe(value);
|
|
257
|
+
expect((root as Record<string, unknown>)[name]).toBe(value);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-kind notification lifecycle (notification-lifecycle-is-declared-per-kind).
|
|
3
|
+
*
|
|
4
|
+
* INVARIANTS TESTED:
|
|
5
|
+
* - Every kind in USER_NOTIFICATION_KINDS declares exactly one lifecycle, and
|
|
6
|
+
* the registry names no kind outside the union (the annotation proves both at
|
|
7
|
+
* compile time; this proves it against the real runtime array).
|
|
8
|
+
* - A source-resolved kind always has a condition identity to resolve by.
|
|
9
|
+
* - Access requests are addressed per request and never supersede.
|
|
10
|
+
* - No kind is `persistent` yet — persistence is declared, never accidental.
|
|
11
|
+
* - The illegal lifecycles do not compile (./compile-fixtures.ts is the
|
|
12
|
+
* checked proof; the directives below restate it where it is read).
|
|
13
|
+
* - Every export is reachable from both the domain barrel and the root barrel.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, it } from "vitest";
|
|
16
|
+
import * as root from "../../index";
|
|
17
|
+
import * as domain from "../index";
|
|
18
|
+
import { USER_NOTIFICATION_KINDS } from "../kinds";
|
|
19
|
+
import {
|
|
20
|
+
NOTIFICATION_ATTENTION_ON_SUPERSEDE,
|
|
21
|
+
NOTIFICATION_RESOLUTION_AUDIENCES,
|
|
22
|
+
NOTIFICATION_RETENTION_CLASSES,
|
|
23
|
+
type NotificationLifecycle,
|
|
24
|
+
} from "../lifecycle";
|
|
25
|
+
import { NOTIFICATION_LIFECYCLE } from "../lifecycle-registry";
|
|
26
|
+
import { ILLEGAL_LIFECYCLES } from "./compile-fixtures";
|
|
27
|
+
|
|
28
|
+
describe("NOTIFICATION_LIFECYCLE", () => {
|
|
29
|
+
it("every notification kind declares a lifecycle", () => {
|
|
30
|
+
for (const kind of USER_NOTIFICATION_KINDS) {
|
|
31
|
+
expect(NOTIFICATION_LIFECYCLE[kind], kind).toBeDefined();
|
|
32
|
+
}
|
|
33
|
+
expect(Object.keys(NOTIFICATION_LIFECYCLE).sort()).toEqual(
|
|
34
|
+
[...USER_NOTIFICATION_KINDS].sort(),
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("a source-resolved kind always has a condition identity", () => {
|
|
39
|
+
for (const [kind, lifecycle] of Object.entries(NOTIFICATION_LIFECYCLE)) {
|
|
40
|
+
if (lifecycle.resolution !== "never") {
|
|
41
|
+
expect(lifecycle.condition, kind).not.toBe("none");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("access requests do not supersede one another", () => {
|
|
47
|
+
const lifecycle = NOTIFICATION_LIFECYCLE["companyMd.access_requested"];
|
|
48
|
+
expect(lifecycle.condition).toEqual({
|
|
49
|
+
scope: "fields",
|
|
50
|
+
fields: ["requestId"],
|
|
51
|
+
});
|
|
52
|
+
expect(lifecycle.condition !== "none" && lifecycle.supersedes).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("no kind is persistent yet", () => {
|
|
56
|
+
for (const [kind, lifecycle] of Object.entries(NOTIFICATION_LIFECYCLE)) {
|
|
57
|
+
expect(lifecycle.retention, kind).toBe("standard");
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("the closed vocabularies are pinned member-for-member", () => {
|
|
62
|
+
expect(NOTIFICATION_ATTENTION_ON_SUPERSEDE).toEqual(["renew", "preserve"]);
|
|
63
|
+
expect(NOTIFICATION_RESOLUTION_AUDIENCES).toEqual([
|
|
64
|
+
"all-recipients",
|
|
65
|
+
"recipient",
|
|
66
|
+
]);
|
|
67
|
+
expect(NOTIFICATION_RETENTION_CLASSES).toEqual(["standard", "persistent"]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("an illegal lifecycle does not compile", () => {
|
|
71
|
+
// Unchecked here (tsc excludes *.test.ts); ./compile-fixtures.ts holds the
|
|
72
|
+
// same three directives where `pnpm typecheck` enforces them.
|
|
73
|
+
const illegal: NotificationLifecycle[] = [
|
|
74
|
+
// @ts-expect-error — source resolution needs a condition
|
|
75
|
+
{
|
|
76
|
+
condition: "none",
|
|
77
|
+
resolution: { mode: "source", audience: "recipient" },
|
|
78
|
+
retention: "standard",
|
|
79
|
+
},
|
|
80
|
+
// @ts-expect-error — attention without supersession
|
|
81
|
+
{
|
|
82
|
+
condition: { scope: "kind" },
|
|
83
|
+
supersedes: false,
|
|
84
|
+
attentionOnSupersede: "renew",
|
|
85
|
+
resolution: "never",
|
|
86
|
+
retention: "standard",
|
|
87
|
+
},
|
|
88
|
+
// @ts-expect-error — a condition-less kind cannot supersede
|
|
89
|
+
{
|
|
90
|
+
condition: "none",
|
|
91
|
+
supersedes: true,
|
|
92
|
+
attentionOnSupersede: "renew",
|
|
93
|
+
resolution: "never",
|
|
94
|
+
retention: "standard",
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
expect(illegal).toHaveLength(ILLEGAL_LIFECYCLES.length);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("every lifecycle export is reachable from both barrels", () => {
|
|
101
|
+
for (const barrel of [root, domain]) {
|
|
102
|
+
expect(barrel.NOTIFICATION_LIFECYCLE).toBe(NOTIFICATION_LIFECYCLE);
|
|
103
|
+
expect(barrel.NOTIFICATION_ATTENTION_ON_SUPERSEDE).toBe(
|
|
104
|
+
NOTIFICATION_ATTENTION_ON_SUPERSEDE,
|
|
105
|
+
);
|
|
106
|
+
expect(barrel.NOTIFICATION_RESOLUTION_AUDIENCES).toBe(
|
|
107
|
+
NOTIFICATION_RESOLUTION_AUDIENCES,
|
|
108
|
+
);
|
|
109
|
+
expect(barrel.NOTIFICATION_RETENTION_CLASSES).toBe(
|
|
110
|
+
NOTIFICATION_RETENTION_CLASSES,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The inbox vocabulary's invariants, as tests rather than prose
|
|
2
|
+
* The inbox vocabulary's invariants, as tests rather than prose
|
|
3
|
+
* (user-notification-inbox-and-merged-feed).
|
|
3
4
|
*/
|
|
4
5
|
import { describe, it, expect } from "vitest";
|
|
5
6
|
import { USER_NOTIFICATION_KINDS } from "../kinds.js";
|
|
@@ -26,6 +27,9 @@ function makeNotification(over: Record<string, unknown> = {}) {
|
|
|
26
27
|
href: "/@acme/md/doc-1",
|
|
27
28
|
createdAt: "2026-07-30T12:00:00.000Z",
|
|
28
29
|
readAt: null,
|
|
30
|
+
activityAt: "2026-07-30T12:00:00.000Z",
|
|
31
|
+
occurrenceCount: 1,
|
|
32
|
+
resolvedAt: null,
|
|
29
33
|
...over,
|
|
30
34
|
};
|
|
31
35
|
}
|
|
@@ -203,6 +207,7 @@ describe("FeedListResponse", () => {
|
|
|
203
207
|
const base = {
|
|
204
208
|
items: [],
|
|
205
209
|
nextCursor: null,
|
|
210
|
+
viewCount: 0,
|
|
206
211
|
unreadCount: 0,
|
|
207
212
|
truncated: false,
|
|
208
213
|
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE key builder. The backend stores exactly what this returns and never
|
|
3
|
+
* builds a key any other way — two spellings of one condition would be two
|
|
4
|
+
* conditions, and resolution would silently miss.
|
|
5
|
+
*
|
|
6
|
+
* kind scope → "proactive.brief_morning"
|
|
7
|
+
* fields scope → "comment.reply|subjectId=doc-1|subjectType=company_md"
|
|
8
|
+
* (fields sorted by name; values encodeURIComponent'd, never
|
|
9
|
+
* trimmed or lowercased)
|
|
10
|
+
* none → null
|
|
11
|
+
*
|
|
12
|
+
* The supersession key is not a second identity: it is the condition key of a
|
|
13
|
+
* kind that supersedes, and null otherwise.
|
|
14
|
+
*/
|
|
15
|
+
import type { UserNotificationKind } from "./kinds";
|
|
16
|
+
import { NOTIFICATION_LIFECYCLE } from "./lifecycle-registry";
|
|
17
|
+
|
|
18
|
+
/** A condition field the kind's lifecycle names is absent, not a string, or empty. */
|
|
19
|
+
// @vocabulary-exempt: Error subclass so callers can `instanceof` a malformed domain ref
|
|
20
|
+
export class NotificationConditionKeyError extends Error {
|
|
21
|
+
constructor(
|
|
22
|
+
readonly kind: UserNotificationKind,
|
|
23
|
+
readonly field: string,
|
|
24
|
+
) {
|
|
25
|
+
super(
|
|
26
|
+
`notification kind ${kind} needs a non-empty string "${field}" in its domain ref`,
|
|
27
|
+
);
|
|
28
|
+
this.name = "NotificationConditionKeyError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function conditionKeyOf(
|
|
33
|
+
kind: UserNotificationKind,
|
|
34
|
+
domainRef: Readonly<Record<string, unknown>>,
|
|
35
|
+
): string | null {
|
|
36
|
+
const { condition } = NOTIFICATION_LIFECYCLE[kind];
|
|
37
|
+
if (condition === "none") return null;
|
|
38
|
+
if (condition.scope === "kind") return kind;
|
|
39
|
+
const parts = [...condition.fields].sort().map((field) => {
|
|
40
|
+
const value = domainRef[field];
|
|
41
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
42
|
+
throw new NotificationConditionKeyError(kind, field);
|
|
43
|
+
}
|
|
44
|
+
return `${field}=${encodeURIComponent(value)}`;
|
|
45
|
+
});
|
|
46
|
+
return [kind, ...parts].join("|");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type NotificationKeys = {
|
|
50
|
+
/** Resolution identity. Null only for an independent (`condition: "none"`) kind. */
|
|
51
|
+
readonly conditionKey: string | null;
|
|
52
|
+
/** Active-row uniqueness identity: the condition key of a superseding kind, else null. */
|
|
53
|
+
readonly supersessionKey: string | null;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export function notificationKeysOf(
|
|
57
|
+
kind: UserNotificationKind,
|
|
58
|
+
domainRef: Readonly<Record<string, unknown>>,
|
|
59
|
+
): NotificationKeys {
|
|
60
|
+
const lifecycle = NOTIFICATION_LIFECYCLE[kind];
|
|
61
|
+
const conditionKey = conditionKeyOf(kind, domainRef);
|
|
62
|
+
const supersedes = lifecycle.condition !== "none" && lifecycle.supersedes;
|
|
63
|
+
return { conditionKey, supersessionKey: supersedes ? conditionKey : null };
|
|
64
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The feed's views, selectors and verbs.
|
|
3
|
+
*
|
|
4
|
+
* A SELECTOR says which rows a verb touches. `ids` is what the person ticked;
|
|
5
|
+
* `view` is "everything in this view AS OF `asOf`" — rows that arrive after the
|
|
6
|
+
* person acted are never swept in (the snapshot rule). Verbs touch the caller's
|
|
7
|
+
* own notification rows only, never action items.
|
|
8
|
+
*
|
|
9
|
+
* There is no Archive: no archived view and no archive verb. A delete is undone
|
|
10
|
+
* by restoring with the opaque key it returned.
|
|
11
|
+
*
|
|
12
|
+
* Imports only `zod` — `./schemas` imports this module, never the reverse.
|
|
13
|
+
*/
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
|
|
16
|
+
export const FEED_VIEWS = ["all", "unread"] as const;
|
|
17
|
+
export const FeedViewSchema = z.enum(FEED_VIEWS);
|
|
18
|
+
export type FeedView = z.infer<typeof FeedViewSchema>;
|
|
19
|
+
|
|
20
|
+
/** The most ids one explicit selection may name. */
|
|
21
|
+
export const FEED_SELECTOR_MAX_IDS = 200;
|
|
22
|
+
|
|
23
|
+
export const FeedSelectorSchema = z.discriminatedUnion("kind", [
|
|
24
|
+
z.object({
|
|
25
|
+
kind: z.literal("ids"),
|
|
26
|
+
notificationIds: z.array(z.string()).min(1).max(FEED_SELECTOR_MAX_IDS),
|
|
27
|
+
}),
|
|
28
|
+
z.object({
|
|
29
|
+
kind: z.literal("view"),
|
|
30
|
+
view: FeedViewSchema,
|
|
31
|
+
/** The snapshot instant: only rows whose activity is at or before it are touched. */
|
|
32
|
+
asOf: z.string().datetime(),
|
|
33
|
+
}),
|
|
34
|
+
]);
|
|
35
|
+
export type FeedSelector = z.infer<typeof FeedSelectorSchema>;
|
|
36
|
+
|
|
37
|
+
/** Mark the selected informational rows unread. */
|
|
38
|
+
export const FeedMarkUnreadRequestSchema = z.object({
|
|
39
|
+
selector: FeedSelectorSchema,
|
|
40
|
+
});
|
|
41
|
+
export type FeedMarkUnreadRequest = z.infer<typeof FeedMarkUnreadRequestSchema>;
|
|
42
|
+
|
|
43
|
+
/** Delete the selected informational rows; undone by a restore. */
|
|
44
|
+
export const FeedDeleteRequestSchema = z.object({
|
|
45
|
+
selector: FeedSelectorSchema,
|
|
46
|
+
});
|
|
47
|
+
export type FeedDeleteRequest = z.infer<typeof FeedDeleteRequestSchema>;
|
|
48
|
+
|
|
49
|
+
/** Undo. `restoreKey` is the opaque operation id a delete returned. */
|
|
50
|
+
export const FeedRestoreRequestSchema = z.object({
|
|
51
|
+
restoreKey: z.string().min(1),
|
|
52
|
+
});
|
|
53
|
+
export type FeedRestoreRequest = z.infer<typeof FeedRestoreRequestSchema>;
|
|
54
|
+
|
|
55
|
+
/** A read / unread mutation: how many rows it changed. */
|
|
56
|
+
export const FeedMutationResponseSchema = z.object({
|
|
57
|
+
affected: z.number().int().nonnegative(),
|
|
58
|
+
});
|
|
59
|
+
export type FeedMutationResponse = z.infer<typeof FeedMutationResponseSchema>;
|
|
60
|
+
|
|
61
|
+
/** A delete (or clear-resolved). `restoreKey` is opaque — never parse it. */
|
|
62
|
+
export const FeedDeleteResponseSchema = z.object({
|
|
63
|
+
affected: z.number().int().nonnegative(),
|
|
64
|
+
restoreKey: z.string().min(1),
|
|
65
|
+
});
|
|
66
|
+
export type FeedDeleteResponse = z.infer<typeof FeedDeleteResponseSchema>;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* `superseded` counts rows left deleted because a newer active row already
|
|
70
|
+
* represents their condition — a restore never duplicates a condition.
|
|
71
|
+
*/
|
|
72
|
+
export const FeedRestoreResponseSchema = z.object({
|
|
73
|
+
restored: z.number().int().nonnegative(),
|
|
74
|
+
superseded: z.number().int().nonnegative(),
|
|
75
|
+
});
|
|
76
|
+
export type FeedRestoreResponse = z.infer<typeof FeedRestoreResponseSchema>;
|
|
@@ -2,13 +2,37 @@
|
|
|
2
2
|
* user-notifications/ — the durable in-app inbox, and the merged feed that
|
|
3
3
|
* renders it alongside action items.
|
|
4
4
|
*
|
|
5
|
-
* See ./README.md for the domain, and
|
|
6
|
-
* vocabulary rather than a bend in either of the two
|
|
5
|
+
* See ./README.md for the domain, and user-notification-inbox-and-merged-feed
|
|
6
|
+
* for why this is a third vocabulary rather than a bend in either of the two
|
|
7
|
+
* beside it.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
export { USER_NOTIFICATION_KINDS } from "./kinds";
|
|
10
11
|
export type { UserNotificationKind } from "./kinds";
|
|
11
12
|
|
|
13
|
+
export {
|
|
14
|
+
NOTIFICATION_ATTENTION_ON_SUPERSEDE,
|
|
15
|
+
NOTIFICATION_RESOLUTION_AUDIENCES,
|
|
16
|
+
NOTIFICATION_RETENTION_CLASSES,
|
|
17
|
+
} from "./lifecycle";
|
|
18
|
+
export type {
|
|
19
|
+
NotificationAttentionOnSupersede,
|
|
20
|
+
NotificationCondition,
|
|
21
|
+
NotificationLifecycle,
|
|
22
|
+
NotificationResolution,
|
|
23
|
+
NotificationResolutionAudience,
|
|
24
|
+
NotificationRetention,
|
|
25
|
+
} from "./lifecycle";
|
|
26
|
+
|
|
27
|
+
export { NOTIFICATION_LIFECYCLE } from "./lifecycle-registry";
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
conditionKeyOf,
|
|
31
|
+
NotificationConditionKeyError,
|
|
32
|
+
notificationKeysOf,
|
|
33
|
+
} from "./condition-keys";
|
|
34
|
+
export type { NotificationKeys } from "./condition-keys";
|
|
35
|
+
|
|
12
36
|
export {
|
|
13
37
|
FeedItemSchema,
|
|
14
38
|
FeedListResponseSchema,
|
|
@@ -23,3 +47,26 @@ export type {
|
|
|
23
47
|
FeedMarkReadRequest,
|
|
24
48
|
UserNotification,
|
|
25
49
|
} from "./schemas";
|
|
50
|
+
|
|
51
|
+
export {
|
|
52
|
+
FEED_SELECTOR_MAX_IDS,
|
|
53
|
+
FEED_VIEWS,
|
|
54
|
+
FeedDeleteRequestSchema,
|
|
55
|
+
FeedDeleteResponseSchema,
|
|
56
|
+
FeedMarkUnreadRequestSchema,
|
|
57
|
+
FeedMutationResponseSchema,
|
|
58
|
+
FeedRestoreRequestSchema,
|
|
59
|
+
FeedRestoreResponseSchema,
|
|
60
|
+
FeedSelectorSchema,
|
|
61
|
+
FeedViewSchema,
|
|
62
|
+
} from "./feed-lifecycle";
|
|
63
|
+
export type {
|
|
64
|
+
FeedDeleteRequest,
|
|
65
|
+
FeedDeleteResponse,
|
|
66
|
+
FeedMarkUnreadRequest,
|
|
67
|
+
FeedMutationResponse,
|
|
68
|
+
FeedRestoreRequest,
|
|
69
|
+
FeedRestoreResponse,
|
|
70
|
+
FeedSelector,
|
|
71
|
+
FeedView,
|
|
72
|
+
} from "./feed-lifecycle";
|