@company-semantics/contracts 82.0.0 → 83.0.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 +4 -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
package/package.json
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// AUTO-GENERATED — do not edit. Run pnpm generate:spec-hash to regenerate.
|
|
2
|
-
export const SPEC_HASH = '
|
|
3
|
-
export const SPEC_HASH_FULL = '
|
|
2
|
+
export const SPEC_HASH = '9286d0cf0c7c' as const;
|
|
3
|
+
export const SPEC_HASH_FULL = '9286d0cf0c7c1457a11e830402e9e860ee80e5b0b263b79d4ec15308d7ecb271' as const;
|
package/src/api/generated.ts
CHANGED
|
@@ -7629,9 +7629,13 @@ export interface components {
|
|
|
7629
7629
|
href: string | null;
|
|
7630
7630
|
createdAt: string;
|
|
7631
7631
|
readAt: string | null;
|
|
7632
|
+
activityAt: string;
|
|
7633
|
+
occurrenceCount: number;
|
|
7634
|
+
resolvedAt: string | null;
|
|
7632
7635
|
};
|
|
7633
7636
|
})[];
|
|
7634
7637
|
nextCursor: string | null;
|
|
7638
|
+
viewCount: number;
|
|
7635
7639
|
unreadCount: number;
|
|
7636
7640
|
truncated: boolean;
|
|
7637
7641
|
};
|
package/src/index.ts
CHANGED
|
@@ -327,7 +327,8 @@ export type {
|
|
|
327
327
|
UserSseEvent,
|
|
328
328
|
} from "./user-events/index";
|
|
329
329
|
|
|
330
|
-
// Durable in-app inbox + the merged feed that renders it
|
|
330
|
+
// Durable in-app inbox + the merged feed that renders it
|
|
331
|
+
// (user-notification-inbox-and-merged-feed). The
|
|
331
332
|
// THIRD vocabulary beside NotificationKind and ActionItemKind: a notification
|
|
332
333
|
// leaves the building, an action item is standing state you can resolve, and
|
|
333
334
|
// one of these is a stored fact you can only look at. Rows hold identifiers
|
|
@@ -352,6 +353,55 @@ export type {
|
|
|
352
353
|
UserNotificationKind,
|
|
353
354
|
} from "./user-notifications/index";
|
|
354
355
|
|
|
356
|
+
// Each inbox kind's declared lifecycle — condition, supersession, resolution,
|
|
357
|
+
// retention (notification-lifecycle-is-declared-per-kind) — and the one
|
|
358
|
+
// function that turns a kind + domain ref into its condition/supersession keys.
|
|
359
|
+
export {
|
|
360
|
+
conditionKeyOf,
|
|
361
|
+
NOTIFICATION_ATTENTION_ON_SUPERSEDE,
|
|
362
|
+
NOTIFICATION_LIFECYCLE,
|
|
363
|
+
NOTIFICATION_RESOLUTION_AUDIENCES,
|
|
364
|
+
NOTIFICATION_RETENTION_CLASSES,
|
|
365
|
+
NotificationConditionKeyError,
|
|
366
|
+
notificationKeysOf,
|
|
367
|
+
} from "./user-notifications/index";
|
|
368
|
+
|
|
369
|
+
export type {
|
|
370
|
+
NotificationAttentionOnSupersede,
|
|
371
|
+
NotificationCondition,
|
|
372
|
+
NotificationKeys,
|
|
373
|
+
NotificationLifecycle,
|
|
374
|
+
NotificationResolution,
|
|
375
|
+
NotificationResolutionAudience,
|
|
376
|
+
NotificationRetention,
|
|
377
|
+
} from "./user-notifications/index";
|
|
378
|
+
|
|
379
|
+
// The feed's views, selectors (explicit ids, or a view as of `asOf`) and verb
|
|
380
|
+
// wire — read / unread / delete / restore — for the caller's inbox rows.
|
|
381
|
+
export {
|
|
382
|
+
FEED_SELECTOR_MAX_IDS,
|
|
383
|
+
FEED_VIEWS,
|
|
384
|
+
FeedDeleteRequestSchema,
|
|
385
|
+
FeedDeleteResponseSchema,
|
|
386
|
+
FeedMarkUnreadRequestSchema,
|
|
387
|
+
FeedMutationResponseSchema,
|
|
388
|
+
FeedRestoreRequestSchema,
|
|
389
|
+
FeedRestoreResponseSchema,
|
|
390
|
+
FeedSelectorSchema,
|
|
391
|
+
FeedViewSchema,
|
|
392
|
+
} from "./user-notifications/index";
|
|
393
|
+
|
|
394
|
+
export type {
|
|
395
|
+
FeedDeleteRequest,
|
|
396
|
+
FeedDeleteResponse,
|
|
397
|
+
FeedMarkUnreadRequest,
|
|
398
|
+
FeedMutationResponse,
|
|
399
|
+
FeedRestoreRequest,
|
|
400
|
+
FeedRestoreResponse,
|
|
401
|
+
FeedSelector,
|
|
402
|
+
FeedView,
|
|
403
|
+
} from "./user-notifications/index";
|
|
404
|
+
|
|
355
405
|
// Proactive delivery — how the system speaks first (ADR-CONTRACTS-142). A
|
|
356
406
|
// COMPOSER above the three notification vocabularies, not a fourth peer: the
|
|
357
407
|
// presentation class is a TOTAL FUNCTION from class to surfaces
|
|
@@ -21,8 +21,8 @@ that renders them alongside action items at the top of `/me/work`.
|
|
|
21
21
|
sees that the thing happened — it simply does not link anywhere. "You were
|
|
22
22
|
granted access to something you can no longer see" is true and worth saying.
|
|
23
23
|
- **Feed ordering is grouped, not chronologically interleaved.** Actionable rows
|
|
24
|
-
first, then informational rows
|
|
25
|
-
surface, not one timeline.
|
|
24
|
+
first, then informational rows unread-and-unresolved first, then `activityAt`
|
|
25
|
+
DESC, `id` DESC. "Merged feed" names one visual surface, not one timeline.
|
|
26
26
|
- **Only notifications paginate.** Action items are already bounded and are
|
|
27
27
|
standing state, not a timeline. Page 1 = all action items + the first page of
|
|
28
28
|
notifications; every later page is notifications only. Paginating two
|
|
@@ -30,9 +30,9 @@ that renders them alongside action items at the top of `/me/work`.
|
|
|
30
30
|
- **`unreadCount` is total, never windowed.** A windowed count under-reports the
|
|
31
31
|
moment the inbox exceeds one page, and it drives a badge whose whole job is to
|
|
32
32
|
be trusted.
|
|
33
|
-
- **The cursor encodes `(
|
|
34
|
-
|
|
35
|
-
or skipped.
|
|
33
|
+
- **The cursor is opaque and encodes `(unread, activityAt, id)`**, mirroring the
|
|
34
|
+
ordering. The id breaks ties so two rows with the same activity cannot
|
|
35
|
+
straddle a page boundary and be served twice or skipped.
|
|
36
36
|
- **A row can be BORN READ.** Every proactive kind writes a durable row here
|
|
37
37
|
as its paper trail, but only the class whose badge carrier is `inbox` writes
|
|
38
38
|
it unread; `proactive.org_became_shared` is an `announcement`, so the banner
|
|
@@ -83,15 +83,102 @@ that renders them alongside action items at the top of `/me/work`.
|
|
|
83
83
|
anywhere, and the proactive `unread` flag on a chat summary stays a one-time
|
|
84
84
|
arrival badge that ordinary conversation never re-lights.
|
|
85
85
|
|
|
86
|
+
## Lifecycle
|
|
87
|
+
|
|
88
|
+
A row is the **current attention item for one condition**, not an event record
|
|
89
|
+
(control ADR `notification-inbox-is-an-attention-surface`; contracts ADR
|
|
90
|
+
`notification-lifecycle-is-declared-per-kind`). Every kind declares its
|
|
91
|
+
lifecycle in `NOTIFICATION_LIFECYCLE`, typed
|
|
92
|
+
`Readonly<Record<UserNotificationKind, NotificationLifecycle>>`, so a kind
|
|
93
|
+
without one — or an entry for a kind that does not exist — fails to compile.
|
|
94
|
+
Each entry declares four things:
|
|
95
|
+
|
|
96
|
+
- **Condition — what the row is about.** `none` (an independent delivery with no
|
|
97
|
+
identity), `{ scope: 'kind' }` (one condition per kind), or
|
|
98
|
+
`{ scope: 'fields', fields }` (named fields of the producing domain's ref). The
|
|
99
|
+
condition is the identity RESOLUTION addresses.
|
|
100
|
+
- **Supersession — a property of the condition, never a second identity.**
|
|
101
|
+
`supersedes: true` means a repeat occurrence updates the active row for that
|
|
102
|
+
condition instead of appending another; `supersedes: false` means each
|
|
103
|
+
occurrence is its own row (access requests: every request stands on its own).
|
|
104
|
+
A `none` condition cannot supersede.
|
|
105
|
+
- **Renew vs preserve.** A superseding kind declares `attentionOnSupersede`.
|
|
106
|
+
`renew` restores the kind's normal initial attention state — unread, or read
|
|
107
|
+
when its proactive class is born read (`bornRead` decides, not this registry);
|
|
108
|
+
the repeat is news again. `preserve` keeps the row's current read state; the
|
|
109
|
+
repeat is a quiet update to a standing condition. Only a superseding kind may
|
|
110
|
+
declare it.
|
|
111
|
+
- **Resolution, with an explicit audience.** `never`, or
|
|
112
|
+
`{ mode: 'source', audience }`: the producing DOMAIN resolves the condition,
|
|
113
|
+
for `all-recipients` or only the `recipient`. Source resolution needs a
|
|
114
|
+
condition to address, so `none` + `source` does not compile.
|
|
115
|
+
- **Retention class.** `standard` or `persistent`. Every kind is `standard`
|
|
116
|
+
today; `persistent` must be declared, never accidental. The class is all
|
|
117
|
+
contracts carries — the retention DAY values live in the backend.
|
|
118
|
+
|
|
119
|
+
The union makes the three illegal combinations unrepresentable (source
|
|
120
|
+
resolution without a condition, `attentionOnSupersede` without
|
|
121
|
+
`supersedes: true`, supersession of `none`); `__tests__/compile-fixtures.ts`
|
|
122
|
+
pins each with `@ts-expect-error`.
|
|
123
|
+
|
|
124
|
+
**One key builder.** `conditionKeyOf(kind, domainRef)` is the only way a
|
|
125
|
+
condition key is spelled: the kind itself for `scope: 'kind'`; for
|
|
126
|
+
`scope: 'fields'`, the kind then each named field sorted by name as
|
|
127
|
+
`name=encodeURIComponent(value)`, joined by `|`; `null` for `none`. Values are
|
|
128
|
+
never trimmed or lowercased, and a named field that is absent, not a string, or
|
|
129
|
+
empty throws `NotificationConditionKeyError`. `notificationKeysOf` returns
|
|
130
|
+
`{ conditionKey, supersessionKey }`, where the supersession key is the condition
|
|
131
|
+
key of a superseding kind and `null` otherwise — not a second identity.
|
|
132
|
+
|
|
133
|
+
**The row carries its lifecycle.** `activityAt` is the time of the latest
|
|
134
|
+
occurrence (the ordering and retention clock), `occurrenceCount` how many
|
|
135
|
+
occurrences the row has absorbed by supersession, and `resolvedAt` when the
|
|
136
|
+
domain resolved the condition (`null` while it stands). All three are required.
|
|
137
|
+
|
|
138
|
+
**The verbs take a selector** (`feed-lifecycle.ts`): explicit `ids` (at most
|
|
139
|
+
`FEED_SELECTOR_MAX_IDS`), or a `view` (`all` | `unread`) AS OF `asOf`, so rows
|
|
140
|
+
that arrive after the person acted are never swept in. `viewCount` on the feed
|
|
141
|
+
response counts the notification rows in the requested view. A delete returns
|
|
142
|
+
an opaque `restoreKey`; a restore reports `{ restored, superseded }`, where
|
|
143
|
+
`superseded` counts rows left deleted because a newer active row already
|
|
144
|
+
represents their condition.
|
|
145
|
+
|
|
146
|
+
**There is no Archive.** No archived state, no archived view, no archive verb.
|
|
147
|
+
A row is current, resolved, or deleted — and a deleted row is never served.
|
|
148
|
+
|
|
149
|
+
The per-kind table and the retention and Undo values live in
|
|
150
|
+
company-semantics-control docs/notification-inbox.md; the registry mirrors that
|
|
151
|
+
table entry for entry.
|
|
152
|
+
|
|
86
153
|
## Public API
|
|
87
154
|
|
|
88
|
-
| Export
|
|
89
|
-
|
|
|
90
|
-
| `UserNotificationSchema`
|
|
91
|
-
| `USER_NOTIFICATION_KINDS`
|
|
92
|
-
| `FeedItemSchema`
|
|
93
|
-
| `FeedListResponseSchema`
|
|
94
|
-
| `FeedMarkReadRequestSchema`
|
|
155
|
+
| Export | Description |
|
|
156
|
+
| ------------------------------------- | ------------------------------------------------------------------------- |
|
|
157
|
+
| `UserNotificationSchema` | One informational row, as rendered |
|
|
158
|
+
| `USER_NOTIFICATION_KINDS` | The closed kind vocabulary |
|
|
159
|
+
| `FeedItemSchema` | `actionable` \| `informational`, discriminated on `row` |
|
|
160
|
+
| `FeedListResponseSchema` | `GET /api/me/feed` — items, nextCursor, viewCount, unreadCount, truncated |
|
|
161
|
+
| `FeedMarkReadRequestSchema` | `POST /api/me/feed/read` — `{ selector }` |
|
|
162
|
+
| `FEED_VIEWS` / `FeedViewSchema` | `all` \| `unread` |
|
|
163
|
+
| `FeedSelectorSchema` | `{ kind: 'ids', notificationIds }` \| `{ kind: 'view', view, asOf }` |
|
|
164
|
+
| `FEED_SELECTOR_MAX_IDS` | The most ids one explicit selection may name (200) |
|
|
165
|
+
| `FeedMarkUnreadRequestSchema` | Mark unread — `{ selector }` |
|
|
166
|
+
| `FeedDeleteRequestSchema` | Delete — `{ selector }` |
|
|
167
|
+
| `FeedRestoreRequestSchema` | Undo a delete — `{ restoreKey }` |
|
|
168
|
+
| `FeedMutationResponseSchema` | `{ affected }` |
|
|
169
|
+
| `FeedDeleteResponseSchema` | `{ affected, restoreKey }` — the key is opaque |
|
|
170
|
+
| `FeedRestoreResponseSchema` | `{ restored, superseded }` |
|
|
171
|
+
| `NOTIFICATION_LIFECYCLE` | Every kind's declared lifecycle |
|
|
172
|
+
| `NotificationLifecycle` | The lifecycle union (condition, supersession, resolution, …) |
|
|
173
|
+
| `NotificationCondition` | `{ scope: 'kind' }` \| `{ scope: 'fields', fields }` |
|
|
174
|
+
| `NotificationResolution` | `never` \| `{ mode: 'source', audience }` |
|
|
175
|
+
| `NOTIFICATION_ATTENTION_ON_SUPERSEDE` | `renew` \| `preserve` |
|
|
176
|
+
| `NOTIFICATION_RESOLUTION_AUDIENCES` | `all-recipients` \| `recipient` |
|
|
177
|
+
| `NOTIFICATION_RETENTION_CLASSES` | `standard` \| `persistent` |
|
|
178
|
+
| `conditionKeyOf` | The one condition-key builder (kind + domain ref → key) |
|
|
179
|
+
| `notificationKeysOf` | `{ conditionKey, supersessionKey }` for a kind + domain ref |
|
|
180
|
+
| `NotificationKeys` | The pair `notificationKeysOf` returns |
|
|
181
|
+
| `NotificationConditionKeyError` | A named condition field is absent, not a string, or empty |
|
|
95
182
|
|
|
96
183
|
## Dependencies
|
|
97
184
|
|
|
@@ -23,6 +23,25 @@ Locks the claims `../README.md` and ADR-CONT-108 make that the compiler cannot.
|
|
|
23
23
|
trap `action-items/__tests__` guards, and it would wedge the feed badge
|
|
24
24
|
with no error anywhere.
|
|
25
25
|
|
|
26
|
+
- `lifecycle.test.ts` — the per-kind lifecycle registry: every kind in the
|
|
27
|
+
real `USER_NOTIFICATION_KINDS` declares one, a source-resolved kind always has
|
|
28
|
+
a condition, access requests never supersede, and no kind is `persistent`
|
|
29
|
+
yet.
|
|
30
|
+
- `condition-keys.test.ts` — the one key builder's exact format: fields sorted
|
|
31
|
+
by name and URI-encoded (never trimmed or lowercased), a missing or empty
|
|
32
|
+
field throws, an independent kind has no key, a non-superseding kind has no
|
|
33
|
+
supersession key, and ref fields the lifecycle does not name never change it.
|
|
34
|
+
- `feed-lifecycle.test.ts` — the feed wire: an `ids` selector names 1..200
|
|
35
|
+
ids, a `view` selector needs its `asOf` snapshot, there is no `archived`
|
|
36
|
+
view, mark-read takes a selector, a delete returns a restore key and a
|
|
37
|
+
restore counts superseded rows, and the row's `activityAt`,
|
|
38
|
+
`occurrenceCount` and `resolvedAt` (and the response's `viewCount`) are
|
|
39
|
+
required.
|
|
40
|
+
- `compile-fixtures.ts` — not a test file, so `tsc -b` compiles it: the three
|
|
41
|
+
illegal lifecycles, each pinned with `@ts-expect-error`. A directive written
|
|
42
|
+
in a `*.test.ts` is never typechecked; one here fails `pnpm typecheck` with
|
|
43
|
+
TS2578 the moment the union loosens.
|
|
44
|
+
|
|
26
45
|
## Invariants
|
|
27
46
|
|
|
28
47
|
- These assert VOCABULARY and SHAPE, never behavior. Anything needing a
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Negative typecheck fixtures — the illegal lifecycles the per-kind registry
|
|
3
|
+
* (notification-lifecycle-is-declared-per-kind) refuses, each pinned with
|
|
4
|
+
* `@ts-expect-error`.
|
|
5
|
+
*
|
|
6
|
+
* The directive is the proof. If the `NotificationLifecycle` union ever loosens
|
|
7
|
+
* so one of these becomes representable, the directive goes UNUSED and
|
|
8
|
+
* `pnpm typecheck` fails with TS2578.
|
|
9
|
+
*
|
|
10
|
+
* WHY A COMPILED MODULE. `src/tsconfig.json` excludes every `*.test.ts` file
|
|
11
|
+
* and vitest does not typecheck, so a directive inside `lifecycle.test.ts` is
|
|
12
|
+
* never checked. This file is not a `*.test.ts`, so `tsc -b` compiles it (the
|
|
13
|
+
* same placement as `../../proactive/__tests__/compile-fixtures.ts`), and
|
|
14
|
+
* `lifecycle.test.ts` imports it.
|
|
15
|
+
*
|
|
16
|
+
* Each directive sits IMMEDIATELY above the line the compiler reports on.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { NotificationLifecycle } from "../lifecycle";
|
|
20
|
+
|
|
21
|
+
/** CLAIM 1 — source resolution needs a condition identity to resolve. */
|
|
22
|
+
const sourceWithoutCondition: NotificationLifecycle = {
|
|
23
|
+
condition: "none",
|
|
24
|
+
// @ts-expect-error a condition-less kind resolves `never`
|
|
25
|
+
resolution: { mode: "source", audience: "recipient" },
|
|
26
|
+
retention: "standard",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** CLAIM 2 — attention on supersede means nothing without supersession. */
|
|
30
|
+
const attentionWithoutSupersession: NotificationLifecycle = {
|
|
31
|
+
condition: { scope: "kind" },
|
|
32
|
+
supersedes: false,
|
|
33
|
+
// @ts-expect-error `attentionOnSupersede` exists only when `supersedes: true`
|
|
34
|
+
attentionOnSupersede: "renew",
|
|
35
|
+
resolution: "never",
|
|
36
|
+
retention: "standard",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** CLAIM 3 — a condition-less kind has nothing to supersede by. */
|
|
40
|
+
const supersedingWithoutCondition: NotificationLifecycle = {
|
|
41
|
+
condition: "none",
|
|
42
|
+
// @ts-expect-error a `none` condition carries no `supersedes`
|
|
43
|
+
supersedes: true,
|
|
44
|
+
attentionOnSupersede: "renew",
|
|
45
|
+
resolution: "never",
|
|
46
|
+
retention: "standard",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** The fixtures, exported so the directives are not dead code. */
|
|
50
|
+
export const ILLEGAL_LIFECYCLES = [
|
|
51
|
+
sourceWithoutCondition,
|
|
52
|
+
attentionWithoutSupersession,
|
|
53
|
+
supersedingWithoutCondition,
|
|
54
|
+
] as const;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one condition-key builder (notification-lifecycle-is-declared-per-kind).
|
|
3
|
+
*
|
|
4
|
+
* INVARIANTS TESTED:
|
|
5
|
+
* - A `fields` key is the kind, then each named field sorted by name as
|
|
6
|
+
* name=encodeURIComponent(value), joined by `|` — whatever order the ref
|
|
7
|
+
* lists them in.
|
|
8
|
+
* - A named field that is absent, not a string, or empty throws
|
|
9
|
+
* NotificationConditionKeyError naming the kind and the field.
|
|
10
|
+
* - An independent kind has neither key; a non-superseding kind has a
|
|
11
|
+
* condition key but no supersession key; a `kind` key is the kind itself.
|
|
12
|
+
* - Values are never trimmed or lowercased, and fields the lifecycle does not
|
|
13
|
+
* name never change the key.
|
|
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
|
+
conditionKeyOf,
|
|
21
|
+
NotificationConditionKeyError,
|
|
22
|
+
notificationKeysOf,
|
|
23
|
+
} from "../condition-keys";
|
|
24
|
+
|
|
25
|
+
describe("conditionKeyOf / notificationKeysOf", () => {
|
|
26
|
+
it("condition key orders fields by name", () => {
|
|
27
|
+
const a = conditionKeyOf("comment.reply", {
|
|
28
|
+
subjectType: "company_md",
|
|
29
|
+
subjectId: "doc-1",
|
|
30
|
+
});
|
|
31
|
+
const b = conditionKeyOf("comment.reply", {
|
|
32
|
+
subjectId: "doc-1",
|
|
33
|
+
subjectType: "company_md",
|
|
34
|
+
});
|
|
35
|
+
expect(a).toBe("comment.reply|subjectId=doc-1|subjectType=company_md");
|
|
36
|
+
expect(b).toBe(a);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("condition key refuses a missing field", () => {
|
|
40
|
+
const build = () =>
|
|
41
|
+
conditionKeyOf("companyMd.access_requested", { docId: "doc-1" });
|
|
42
|
+
expect(build).toThrow(NotificationConditionKeyError);
|
|
43
|
+
try {
|
|
44
|
+
build();
|
|
45
|
+
} catch (error) {
|
|
46
|
+
expect(error).toMatchObject({
|
|
47
|
+
name: "NotificationConditionKeyError",
|
|
48
|
+
kind: "companyMd.access_requested",
|
|
49
|
+
field: "requestId",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("condition key refuses an empty or non-string field", () => {
|
|
55
|
+
for (const requestId of ["", 42, null]) {
|
|
56
|
+
expect(() =>
|
|
57
|
+
conditionKeyOf("companyMd.access_requested", { requestId }),
|
|
58
|
+
).toThrow(NotificationConditionKeyError);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("an independent kind has no condition key", () => {
|
|
63
|
+
expect(
|
|
64
|
+
notificationKeysOf("companyMd.access_request_approved", {
|
|
65
|
+
requestId: "req-1",
|
|
66
|
+
}),
|
|
67
|
+
).toEqual({ conditionKey: null, supersessionKey: null });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("a non-superseding kind has a condition key but no supersession key", () => {
|
|
71
|
+
expect(
|
|
72
|
+
notificationKeysOf("companyMd.access_requested", { requestId: "req-1" }),
|
|
73
|
+
).toEqual({
|
|
74
|
+
conditionKey: "companyMd.access_requested|requestId=req-1",
|
|
75
|
+
supersessionKey: null,
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("a superseding kind's supersession key is its condition key", () => {
|
|
80
|
+
const keys = notificationKeysOf("chat.mention", { chatId: "chat-1" });
|
|
81
|
+
expect(keys.conditionKey).toBe("chat.mention|chatId=chat-1");
|
|
82
|
+
expect(keys.supersessionKey).toBe(keys.conditionKey);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("a per-kind condition key is the kind itself", () => {
|
|
86
|
+
expect(
|
|
87
|
+
notificationKeysOf("proactive.brief_morning", {
|
|
88
|
+
orgLocalDate: "2026-09-25",
|
|
89
|
+
}),
|
|
90
|
+
).toEqual({
|
|
91
|
+
conditionKey: "proactive.brief_morning",
|
|
92
|
+
supersessionKey: "proactive.brief_morning",
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("extra ref fields never change the key", () => {
|
|
97
|
+
const candidates = conditionKeyOf("proactive.source_sync_review_ready", {
|
|
98
|
+
checkpoint: "candidates",
|
|
99
|
+
runId: "run-1",
|
|
100
|
+
});
|
|
101
|
+
const again = conditionKeyOf("proactive.source_sync_review_ready", {
|
|
102
|
+
checkpoint: "candidates",
|
|
103
|
+
batchId: "batch-9",
|
|
104
|
+
});
|
|
105
|
+
expect(candidates).toBe(
|
|
106
|
+
"proactive.source_sync_review_ready|checkpoint=candidates",
|
|
107
|
+
);
|
|
108
|
+
expect(again).toBe(candidates);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("values are URI-encoded, never trimmed or lowercased", () => {
|
|
112
|
+
expect(conditionKeyOf("chat.mention", { chatId: " Chat|A=B " })).toBe(
|
|
113
|
+
`chat.mention|chatId=${encodeURIComponent(" Chat|A=B ")}`,
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("every condition-key export is reachable from both barrels", () => {
|
|
118
|
+
for (const barrel of [root, domain]) {
|
|
119
|
+
expect(barrel.conditionKeyOf).toBe(conditionKeyOf);
|
|
120
|
+
expect(barrel.notificationKeysOf).toBe(notificationKeysOf);
|
|
121
|
+
expect(barrel.NotificationConditionKeyError).toBe(
|
|
122
|
+
NotificationConditionKeyError,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -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";
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NOTIFICATION_LIFECYCLE — every inbox kind's declared lifecycle
|
|
3
|
+
* (notification-lifecycle-is-declared-per-kind). See ./lifecycle for what each
|
|
4
|
+
* axis means.
|
|
5
|
+
*/
|
|
6
|
+
import type { UserNotificationKind } from "./kinds";
|
|
7
|
+
import type { NotificationLifecycle } from "./lifecycle";
|
|
8
|
+
|
|
9
|
+
const INDEPENDENT = {
|
|
10
|
+
condition: "none",
|
|
11
|
+
resolution: "never",
|
|
12
|
+
retention: "standard",
|
|
13
|
+
} as const;
|
|
14
|
+
const PER_KIND = { scope: "kind" } as const;
|
|
15
|
+
const RESOLVED_FOR_ALL = {
|
|
16
|
+
mode: "source",
|
|
17
|
+
audience: "all-recipients",
|
|
18
|
+
} as const;
|
|
19
|
+
/** One active row per kind; each repeat is news again. */
|
|
20
|
+
const RENEWING = {
|
|
21
|
+
condition: PER_KIND,
|
|
22
|
+
supersedes: true,
|
|
23
|
+
attentionOnSupersede: "renew",
|
|
24
|
+
resolution: "never",
|
|
25
|
+
retention: "standard",
|
|
26
|
+
} as const;
|
|
27
|
+
/** A standing condition: repeats update it quietly; the domain resolves it. */
|
|
28
|
+
const STANDING = {
|
|
29
|
+
condition: PER_KIND,
|
|
30
|
+
supersedes: true,
|
|
31
|
+
attentionOnSupersede: "preserve",
|
|
32
|
+
resolution: RESOLVED_FOR_ALL,
|
|
33
|
+
retention: "standard",
|
|
34
|
+
} as const;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Every kind's lifecycle. Annotated (not `satisfies`) so a missing kind and an
|
|
38
|
+
* excess key are both compile errors. Values mirror
|
|
39
|
+
* company-semantics-control docs/notification-inbox.md § Per-kind lifecycle.
|
|
40
|
+
* (prettier-ignore keeps the annotation on one line, where it is greppable.)
|
|
41
|
+
*/
|
|
42
|
+
// prettier-ignore
|
|
43
|
+
export const NOTIFICATION_LIFECYCLE: Readonly<Record<UserNotificationKind, NotificationLifecycle>> = {
|
|
44
|
+
"companyMd.access_requested": {
|
|
45
|
+
condition: { scope: "fields", fields: ["requestId"] },
|
|
46
|
+
supersedes: false,
|
|
47
|
+
resolution: RESOLVED_FOR_ALL,
|
|
48
|
+
retention: "standard",
|
|
49
|
+
},
|
|
50
|
+
"companyMd.access_request_approved": INDEPENDENT,
|
|
51
|
+
"companyMd.access_request_denied": INDEPENDENT,
|
|
52
|
+
"comment.mention": {
|
|
53
|
+
condition: { scope: "fields", fields: ["subjectId", "subjectType"] },
|
|
54
|
+
supersedes: true,
|
|
55
|
+
attentionOnSupersede: "renew",
|
|
56
|
+
resolution: "never",
|
|
57
|
+
retention: "standard",
|
|
58
|
+
},
|
|
59
|
+
"comment.reply": {
|
|
60
|
+
condition: { scope: "fields", fields: ["subjectId", "subjectType"] },
|
|
61
|
+
supersedes: true,
|
|
62
|
+
attentionOnSupersede: "renew",
|
|
63
|
+
resolution: "never",
|
|
64
|
+
retention: "standard",
|
|
65
|
+
},
|
|
66
|
+
"chat.mention": {
|
|
67
|
+
condition: { scope: "fields", fields: ["chatId"] },
|
|
68
|
+
supersedes: true,
|
|
69
|
+
attentionOnSupersede: "renew",
|
|
70
|
+
resolution: "never",
|
|
71
|
+
retention: "standard",
|
|
72
|
+
},
|
|
73
|
+
"proactive.org_became_shared": INDEPENDENT,
|
|
74
|
+
"proactive.brief_morning": RENEWING,
|
|
75
|
+
"proactive.billing_balance_below_half": STANDING,
|
|
76
|
+
"proactive.billing_balance_below_threshold": STANDING,
|
|
77
|
+
"proactive.billing_balance_exhausted": STANDING,
|
|
78
|
+
"proactive.billing_auto_recharge_failed": STANDING,
|
|
79
|
+
"proactive.billing_payment_past_due": STANDING,
|
|
80
|
+
"proactive.billing_suspended": STANDING,
|
|
81
|
+
"proactive.billing_byo_key_invalid": {
|
|
82
|
+
condition: { scope: "fields", fields: ["configId"] },
|
|
83
|
+
supersedes: true,
|
|
84
|
+
attentionOnSupersede: "preserve",
|
|
85
|
+
resolution: RESOLVED_FOR_ALL,
|
|
86
|
+
retention: "standard",
|
|
87
|
+
},
|
|
88
|
+
"proactive.billing_user_monthly_limit_reached": {
|
|
89
|
+
condition: { scope: "fields", fields: ["userId"] },
|
|
90
|
+
supersedes: true,
|
|
91
|
+
attentionOnSupersede: "renew",
|
|
92
|
+
resolution: "never",
|
|
93
|
+
retention: "standard",
|
|
94
|
+
},
|
|
95
|
+
"proactive.billing_auto_recharge_succeeded": RENEWING,
|
|
96
|
+
"proactive.billing_top_up_credited": RENEWING,
|
|
97
|
+
"proactive.billing_monthly_budget_80": RENEWING,
|
|
98
|
+
"proactive.billing_monthly_budget_100": RENEWING,
|
|
99
|
+
"proactive.billing_provider_price_changed": RENEWING,
|
|
100
|
+
"proactive.billing_payment_recovered": RENEWING,
|
|
101
|
+
"proactive.billing_subscription_canceled": RENEWING,
|
|
102
|
+
"proactive.source_sync_docs_updated": {
|
|
103
|
+
condition: PER_KIND,
|
|
104
|
+
supersedes: true,
|
|
105
|
+
attentionOnSupersede: "preserve",
|
|
106
|
+
resolution: "never",
|
|
107
|
+
retention: "standard",
|
|
108
|
+
},
|
|
109
|
+
"proactive.source_sync_review_ready": {
|
|
110
|
+
condition: { scope: "fields", fields: ["checkpoint"] },
|
|
111
|
+
supersedes: true,
|
|
112
|
+
attentionOnSupersede: "preserve",
|
|
113
|
+
resolution: RESOLVED_FOR_ALL,
|
|
114
|
+
retention: "standard",
|
|
115
|
+
},
|
|
116
|
+
"proactive.org_structure_review_ready": {
|
|
117
|
+
condition: PER_KIND,
|
|
118
|
+
supersedes: true,
|
|
119
|
+
attentionOnSupersede: "renew",
|
|
120
|
+
resolution: RESOLVED_FOR_ALL,
|
|
121
|
+
retention: "standard",
|
|
122
|
+
},
|
|
123
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lifecycle of an inbox row, declared per kind.
|
|
3
|
+
*
|
|
4
|
+
* A notification row is the CURRENT ATTENTION ITEM for one condition, not an
|
|
5
|
+
* event record (control ADR `notification-inbox-is-an-attention-surface`).
|
|
6
|
+
*
|
|
7
|
+
* condition — what the row is about; addresses RESOLUTION. `none` = an
|
|
8
|
+
* independent delivery with no identity.
|
|
9
|
+
* supersedes — a property of the condition: a repeat occurrence updates the
|
|
10
|
+
* active row instead of appending one.
|
|
11
|
+
* attentionOnSupersede — `renew` restores the kind's normal initial attention
|
|
12
|
+
* state (unread, or read when its class is born read);
|
|
13
|
+
* `preserve` keeps the row's current read state.
|
|
14
|
+
* resolution — `never`, or resolved by its producing DOMAIN (`source`) for
|
|
15
|
+
* `all-recipients` or only the `recipient`.
|
|
16
|
+
* retention — `standard`, or `persistent` (longer bounds; declared, never
|
|
17
|
+
* accidental).
|
|
18
|
+
*
|
|
19
|
+
* There is no Archive. See company-semantics-control docs/notification-inbox.md.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const NOTIFICATION_ATTENTION_ON_SUPERSEDE = [
|
|
23
|
+
"renew",
|
|
24
|
+
"preserve",
|
|
25
|
+
] as const;
|
|
26
|
+
export type NotificationAttentionOnSupersede =
|
|
27
|
+
(typeof NOTIFICATION_ATTENTION_ON_SUPERSEDE)[number];
|
|
28
|
+
|
|
29
|
+
export const NOTIFICATION_RESOLUTION_AUDIENCES = [
|
|
30
|
+
"all-recipients",
|
|
31
|
+
"recipient",
|
|
32
|
+
] as const;
|
|
33
|
+
export type NotificationResolutionAudience =
|
|
34
|
+
(typeof NOTIFICATION_RESOLUTION_AUDIENCES)[number];
|
|
35
|
+
|
|
36
|
+
export const NOTIFICATION_RETENTION_CLASSES = [
|
|
37
|
+
"standard",
|
|
38
|
+
"persistent",
|
|
39
|
+
] as const;
|
|
40
|
+
export type NotificationRetention =
|
|
41
|
+
(typeof NOTIFICATION_RETENTION_CLASSES)[number];
|
|
42
|
+
|
|
43
|
+
export type NotificationCondition =
|
|
44
|
+
| { readonly scope: "kind" }
|
|
45
|
+
| {
|
|
46
|
+
readonly scope: "fields";
|
|
47
|
+
readonly fields: readonly [string, ...string[]];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type NotificationResolution =
|
|
51
|
+
| "never"
|
|
52
|
+
| {
|
|
53
|
+
readonly mode: "source";
|
|
54
|
+
readonly audience: NotificationResolutionAudience;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
type Supersession =
|
|
58
|
+
| { readonly supersedes: false }
|
|
59
|
+
| {
|
|
60
|
+
readonly supersedes: true;
|
|
61
|
+
readonly attentionOnSupersede: NotificationAttentionOnSupersede;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type NotificationLifecycle =
|
|
65
|
+
| {
|
|
66
|
+
/** An independent delivery: no identity, cannot resolve, cannot supersede. */
|
|
67
|
+
readonly condition: "none";
|
|
68
|
+
readonly resolution: "never";
|
|
69
|
+
readonly retention: NotificationRetention;
|
|
70
|
+
}
|
|
71
|
+
| ({
|
|
72
|
+
readonly condition: NotificationCondition;
|
|
73
|
+
readonly resolution: NotificationResolution;
|
|
74
|
+
readonly retention: NotificationRetention;
|
|
75
|
+
} & Supersession);
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import { ActionItemSchema, ActionItemTargetSchema } from "../action-items";
|
|
9
|
+
import { FeedSelectorSchema } from "./feed-lifecycle";
|
|
9
10
|
import { USER_NOTIFICATION_KINDS } from "./kinds";
|
|
10
11
|
|
|
11
12
|
/** Zod mirror of the vocabulary. `./kinds` owns the list; this never restates it. */
|
|
@@ -68,9 +69,22 @@ export const UserNotificationSchema = z.object({
|
|
|
68
69
|
* longer see" is itself true and useful; it simply does not link anywhere.
|
|
69
70
|
*/
|
|
70
71
|
href: z.string().nullable(),
|
|
72
|
+
/** When the row was first written — its FIRST occurrence. */
|
|
71
73
|
createdAt: z.string(),
|
|
72
|
-
/** `null` while unread.
|
|
74
|
+
/** `null` while unread. Changed by the read/unread verbs and by supersession. */
|
|
73
75
|
readAt: z.string().nullable(),
|
|
76
|
+
/**
|
|
77
|
+
* The time of the LATEST occurrence this row represents — the ordering and
|
|
78
|
+
* retention clock. Equals `createdAt` until a repeat supersedes into the row.
|
|
79
|
+
*/
|
|
80
|
+
activityAt: z.string(),
|
|
81
|
+
/** How many occurrences this row has absorbed by supersession. 1 for a fresh row. */
|
|
82
|
+
occurrenceCount: z.number().int().positive(),
|
|
83
|
+
/**
|
|
84
|
+
* Set when the producing domain resolved the row's condition; `null` while
|
|
85
|
+
* it stands. A resolved row still renders (muted) until retention removes it.
|
|
86
|
+
*/
|
|
87
|
+
resolvedAt: z.string().nullable(),
|
|
74
88
|
});
|
|
75
89
|
export type UserNotification = z.infer<typeof UserNotificationSchema>;
|
|
76
90
|
|
|
@@ -103,7 +117,8 @@ export type FeedItem = z.infer<typeof FeedItemSchema>;
|
|
|
103
117
|
* `GET /api/me/feed`.
|
|
104
118
|
*
|
|
105
119
|
* ORDERING IS INTENTIONALLY GROUPED, NOT CHRONOLOGICALLY INTERLEAVED: every
|
|
106
|
-
* `actionable` row first, then `informational` rows
|
|
120
|
+
* `actionable` row first, then `informational` rows — unread-and-unresolved
|
|
121
|
+
* first, then `activityAt` DESC, `id` DESC. "Merged feed"
|
|
107
122
|
* names one visual surface, not one timeline. Grouping is what keeps "what do I
|
|
108
123
|
* owe?" answerable at a glance instead of scattered between announcements — and
|
|
109
124
|
* it is what makes the pagination rule below coherent.
|
|
@@ -119,10 +134,16 @@ export const FeedListResponseSchema = z.object({
|
|
|
119
134
|
/**
|
|
120
135
|
* Opaque cursor for the NEXT page of informational rows; `null` at the end.
|
|
121
136
|
*
|
|
122
|
-
* Encodes `(
|
|
123
|
-
*
|
|
137
|
+
* Encodes `(unread, activityAt, id)`, mirroring the ordering above — `id`
|
|
138
|
+
* breaks ties so two rows with the same activity cannot straddle a page
|
|
139
|
+
* boundary and be served twice or skipped. Never parse it.
|
|
124
140
|
*/
|
|
125
141
|
nextCursor: z.string().nullable(),
|
|
142
|
+
/**
|
|
143
|
+
* The number of notification rows matching the requested view — never action
|
|
144
|
+
* items, never a window. What "Select all in this view" would touch.
|
|
145
|
+
*/
|
|
146
|
+
viewCount: z.number().int().nonnegative(),
|
|
126
147
|
/**
|
|
127
148
|
* Total unread across ALL retained notifications — NOT within the returned
|
|
128
149
|
* window.
|
|
@@ -136,8 +157,8 @@ export const FeedListResponseSchema = z.object({
|
|
|
136
157
|
});
|
|
137
158
|
export type FeedListResponse = z.infer<typeof FeedListResponseSchema>;
|
|
138
159
|
|
|
139
|
-
/** `POST /api/me/feed/read` — mark
|
|
160
|
+
/** `POST /api/me/feed/read` — mark the selected informational rows read. */
|
|
140
161
|
export const FeedMarkReadRequestSchema = z.object({
|
|
141
|
-
|
|
162
|
+
selector: FeedSelectorSchema,
|
|
142
163
|
});
|
|
143
164
|
export type FeedMarkReadRequest = z.infer<typeof FeedMarkReadRequestSchema>;
|