@company-semantics/contracts 43.0.0 → 44.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 +155 -0
- package/src/generated/openapi-routes.ts +3 -0
- package/src/index.ts +26 -0
- package/src/resource-keys.ts +11 -0
- package/src/user-events/index.ts +2 -0
- package/src/user-events/schemas.ts +41 -0
- package/src/user-notifications/README.md +72 -0
- package/src/user-notifications/__tests__/README.md +43 -0
- package/src/user-notifications/__tests__/vocabulary.test.ts +170 -0
- package/src/user-notifications/index.ts +25 -0
- package/src/user-notifications/kinds.ts +69 -0
- package/src/user-notifications/schemas.ts +133 -0
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 = '815c01c0b920' as const;
|
|
3
|
+
export const SPEC_HASH_FULL = '815c01c0b920961e20e9bbe921d37145daa661873ce15b6a3cb426d6761cf80f' as const;
|
package/src/api/generated.ts
CHANGED
|
@@ -2876,6 +2876,60 @@ export interface paths {
|
|
|
2876
2876
|
patch?: never;
|
|
2877
2877
|
trace?: never;
|
|
2878
2878
|
};
|
|
2879
|
+
"/api/me/feed": {
|
|
2880
|
+
parameters: {
|
|
2881
|
+
query?: never;
|
|
2882
|
+
header?: never;
|
|
2883
|
+
path?: never;
|
|
2884
|
+
cookie?: never;
|
|
2885
|
+
};
|
|
2886
|
+
/**
|
|
2887
|
+
* What I owe and what happened, merged, newest first
|
|
2888
|
+
* @description Actionable rows first, then informational rows newest-first — grouped, not chronologically interleaved. Only the informational half paginates.
|
|
2889
|
+
*/
|
|
2890
|
+
get: operations["listMyFeed"];
|
|
2891
|
+
put?: never;
|
|
2892
|
+
post?: never;
|
|
2893
|
+
delete?: never;
|
|
2894
|
+
options?: never;
|
|
2895
|
+
head?: never;
|
|
2896
|
+
patch?: never;
|
|
2897
|
+
trace?: never;
|
|
2898
|
+
};
|
|
2899
|
+
"/api/me/feed/read": {
|
|
2900
|
+
parameters: {
|
|
2901
|
+
query?: never;
|
|
2902
|
+
header?: never;
|
|
2903
|
+
path?: never;
|
|
2904
|
+
cookie?: never;
|
|
2905
|
+
};
|
|
2906
|
+
get?: never;
|
|
2907
|
+
put?: never;
|
|
2908
|
+
/** Mark specific notifications read */
|
|
2909
|
+
post: operations["markMyFeedRead"];
|
|
2910
|
+
delete?: never;
|
|
2911
|
+
options?: never;
|
|
2912
|
+
head?: never;
|
|
2913
|
+
patch?: never;
|
|
2914
|
+
trace?: never;
|
|
2915
|
+
};
|
|
2916
|
+
"/api/me/feed/read-all": {
|
|
2917
|
+
parameters: {
|
|
2918
|
+
query?: never;
|
|
2919
|
+
header?: never;
|
|
2920
|
+
path?: never;
|
|
2921
|
+
cookie?: never;
|
|
2922
|
+
};
|
|
2923
|
+
get?: never;
|
|
2924
|
+
put?: never;
|
|
2925
|
+
/** Mark every notification read */
|
|
2926
|
+
post: operations["markMyFeedAllRead"];
|
|
2927
|
+
delete?: never;
|
|
2928
|
+
options?: never;
|
|
2929
|
+
head?: never;
|
|
2930
|
+
patch?: never;
|
|
2931
|
+
trace?: never;
|
|
2932
|
+
};
|
|
2879
2933
|
"/api/me/meetings/recordings/start": {
|
|
2880
2934
|
parameters: {
|
|
2881
2935
|
query?: never;
|
|
@@ -5513,6 +5567,51 @@ export interface components {
|
|
|
5513
5567
|
createdAt: string;
|
|
5514
5568
|
}[];
|
|
5515
5569
|
};
|
|
5570
|
+
FeedListResponse: {
|
|
5571
|
+
items: ({
|
|
5572
|
+
/** @constant */
|
|
5573
|
+
row: "actionable";
|
|
5574
|
+
item: {
|
|
5575
|
+
id: string;
|
|
5576
|
+
/** @enum {string} */
|
|
5577
|
+
kind: "companyMd.access_request_pending" | "execution.confirmation_pending";
|
|
5578
|
+
target: {
|
|
5579
|
+
/** @enum {string} */
|
|
5580
|
+
type: "company_md" | "org_unit" | "org" | "execution";
|
|
5581
|
+
id: string;
|
|
5582
|
+
};
|
|
5583
|
+
unitPath: string | null;
|
|
5584
|
+
title: string;
|
|
5585
|
+
detail: string | null;
|
|
5586
|
+
contextLabel: string;
|
|
5587
|
+
href: string;
|
|
5588
|
+
createdAt: string;
|
|
5589
|
+
};
|
|
5590
|
+
} | {
|
|
5591
|
+
/** @constant */
|
|
5592
|
+
row: "informational";
|
|
5593
|
+
notification: {
|
|
5594
|
+
id: string;
|
|
5595
|
+
/** @enum {string} */
|
|
5596
|
+
kind: "companyMd.access_requested" | "companyMd.access_request_approved" | "companyMd.access_request_denied";
|
|
5597
|
+
target: {
|
|
5598
|
+
/** @enum {string} */
|
|
5599
|
+
type: "company_md" | "org_unit" | "org" | "execution";
|
|
5600
|
+
id: string;
|
|
5601
|
+
};
|
|
5602
|
+
unitPath: string | null;
|
|
5603
|
+
title: string;
|
|
5604
|
+
detail: string | null;
|
|
5605
|
+
contextLabel: string;
|
|
5606
|
+
href: string | null;
|
|
5607
|
+
createdAt: string;
|
|
5608
|
+
readAt: string | null;
|
|
5609
|
+
};
|
|
5610
|
+
})[];
|
|
5611
|
+
nextCursor: string | null;
|
|
5612
|
+
unreadCount: number;
|
|
5613
|
+
truncated: boolean;
|
|
5614
|
+
};
|
|
5516
5615
|
/** @description Recording + work_item ids and the transcription session grant. */
|
|
5517
5616
|
StartMeetingRecordingResponse: {
|
|
5518
5617
|
/** @description ULID; unified trace key for the recording (INV-MTG-7). */
|
|
@@ -10766,6 +10865,62 @@ export interface operations {
|
|
|
10766
10865
|
};
|
|
10767
10866
|
};
|
|
10768
10867
|
};
|
|
10868
|
+
listMyFeed: {
|
|
10869
|
+
parameters: {
|
|
10870
|
+
query?: never;
|
|
10871
|
+
header?: never;
|
|
10872
|
+
path?: never;
|
|
10873
|
+
cookie?: never;
|
|
10874
|
+
};
|
|
10875
|
+
requestBody?: never;
|
|
10876
|
+
responses: {
|
|
10877
|
+
/** @description One page of the merged feed for the current user */
|
|
10878
|
+
200: {
|
|
10879
|
+
headers: {
|
|
10880
|
+
[name: string]: unknown;
|
|
10881
|
+
};
|
|
10882
|
+
content: {
|
|
10883
|
+
"application/json": components["schemas"]["FeedListResponse"];
|
|
10884
|
+
};
|
|
10885
|
+
};
|
|
10886
|
+
};
|
|
10887
|
+
};
|
|
10888
|
+
markMyFeedRead: {
|
|
10889
|
+
parameters: {
|
|
10890
|
+
query?: never;
|
|
10891
|
+
header?: never;
|
|
10892
|
+
path?: never;
|
|
10893
|
+
cookie?: never;
|
|
10894
|
+
};
|
|
10895
|
+
requestBody?: never;
|
|
10896
|
+
responses: {
|
|
10897
|
+
/** @description Read state applied to the actor’s own rows */
|
|
10898
|
+
200: {
|
|
10899
|
+
headers: {
|
|
10900
|
+
[name: string]: unknown;
|
|
10901
|
+
};
|
|
10902
|
+
content?: never;
|
|
10903
|
+
};
|
|
10904
|
+
};
|
|
10905
|
+
};
|
|
10906
|
+
markMyFeedAllRead: {
|
|
10907
|
+
parameters: {
|
|
10908
|
+
query?: never;
|
|
10909
|
+
header?: never;
|
|
10910
|
+
path?: never;
|
|
10911
|
+
cookie?: never;
|
|
10912
|
+
};
|
|
10913
|
+
requestBody?: never;
|
|
10914
|
+
responses: {
|
|
10915
|
+
/** @description Read state applied to the actor’s own rows */
|
|
10916
|
+
200: {
|
|
10917
|
+
headers: {
|
|
10918
|
+
[name: string]: unknown;
|
|
10919
|
+
};
|
|
10920
|
+
content?: never;
|
|
10921
|
+
};
|
|
10922
|
+
};
|
|
10923
|
+
};
|
|
10769
10924
|
startMeetingRecording: {
|
|
10770
10925
|
parameters: {
|
|
10771
10926
|
query?: never;
|
|
@@ -75,6 +75,9 @@ export const openApiRoutes = {
|
|
|
75
75
|
'/api/me': ['GET'],
|
|
76
76
|
'/api/me/access-list': ['GET'],
|
|
77
77
|
'/api/me/action-items': ['GET'],
|
|
78
|
+
'/api/me/feed': ['GET'],
|
|
79
|
+
'/api/me/feed/read': ['POST'],
|
|
80
|
+
'/api/me/feed/read-all': ['POST'],
|
|
78
81
|
'/api/me/meetings/recordings/start': ['POST'],
|
|
79
82
|
'/api/me/meetings/recordings/{id}': ['DELETE'],
|
|
80
83
|
'/api/me/meetings/recordings/{id}/complete': ['POST'],
|
package/src/index.ts
CHANGED
|
@@ -289,6 +289,7 @@ export {
|
|
|
289
289
|
} from "./user-events/index";
|
|
290
290
|
|
|
291
291
|
export type {
|
|
292
|
+
NotificationCreatedEvent,
|
|
292
293
|
ResourceInvalidatedEvent,
|
|
293
294
|
UserEventConnected,
|
|
294
295
|
UserEventResync,
|
|
@@ -296,6 +297,31 @@ export type {
|
|
|
296
297
|
UserSseEvent,
|
|
297
298
|
} from "./user-events/index";
|
|
298
299
|
|
|
300
|
+
// Durable in-app inbox + the merged feed that renders it (ADR-CONT-108). The
|
|
301
|
+
// THIRD vocabulary beside NotificationKind and ActionItemKind: a notification
|
|
302
|
+
// leaves the building, an action item is standing state you can resolve, and
|
|
303
|
+
// one of these is a stored fact you can only look at. Rows hold identifiers
|
|
304
|
+
// only — every prose field is rendered at read time under the reader's CURRENT
|
|
305
|
+
// authority, which is what keeps a stale row from leaking anything.
|
|
306
|
+
// See src/user-notifications/README.md.
|
|
307
|
+
export { USER_NOTIFICATION_KINDS } from "./user-notifications/index";
|
|
308
|
+
|
|
309
|
+
export {
|
|
310
|
+
FeedItemSchema,
|
|
311
|
+
FeedListResponseSchema,
|
|
312
|
+
FeedMarkReadRequestSchema,
|
|
313
|
+
UserNotificationKindSchema,
|
|
314
|
+
UserNotificationSchema,
|
|
315
|
+
} from "./user-notifications/index";
|
|
316
|
+
|
|
317
|
+
export type {
|
|
318
|
+
FeedItem,
|
|
319
|
+
FeedListResponse,
|
|
320
|
+
FeedMarkReadRequest,
|
|
321
|
+
UserNotification,
|
|
322
|
+
UserNotificationKind,
|
|
323
|
+
} from "./user-notifications/index";
|
|
324
|
+
|
|
299
325
|
// Chat domain types
|
|
300
326
|
// @see PRD-00142 for share chat design rationale
|
|
301
327
|
export type {
|
package/src/resource-keys.ts
CHANGED
|
@@ -71,6 +71,15 @@ export type ResourceKey =
|
|
|
71
71
|
// `timeline`, which are likewise viewer-filtered but org-partitioned — and it
|
|
72
72
|
// inherits `resolveScope`'s impersonation handling for free.
|
|
73
73
|
| { type: "actionItems"; orgId: string }
|
|
74
|
+
// The merged top-of-/me/work read: action items AND the durable inbox
|
|
75
|
+
// (ADR-CONT-108). Org-scoped for the same reason `actionItems` is — the
|
|
76
|
+
// content is per-viewer but the bucket differs per org, so a user-keyed entry
|
|
77
|
+
// would serve one org's feed after switching to another.
|
|
78
|
+
//
|
|
79
|
+
// Distinct from `actionItems` rather than replacing it: that key still backs
|
|
80
|
+
// every badge in the shell, and the two have different invalidation triggers
|
|
81
|
+
// (a notification being READ changes the feed and not the bucket).
|
|
82
|
+
| { type: "feed"; orgId: string }
|
|
74
83
|
// User-scoped
|
|
75
84
|
| { type: "dismissedBanners"; userId: string }
|
|
76
85
|
| { type: "userOrgs"; userId: string }
|
|
@@ -115,6 +124,7 @@ const ORG_SCOPED_TYPES = [
|
|
|
115
124
|
"peopleOrgChart",
|
|
116
125
|
"orgUnitOwners",
|
|
117
126
|
"actionItems",
|
|
127
|
+
"feed",
|
|
118
128
|
] as const;
|
|
119
129
|
|
|
120
130
|
const USER_SCOPED_TYPES = [
|
|
@@ -201,6 +211,7 @@ export function toQueryKey(key: ResourceKey): readonly string[] {
|
|
|
201
211
|
case "peopleOrgChart":
|
|
202
212
|
case "orgUnitOwners":
|
|
203
213
|
case "actionItems":
|
|
214
|
+
case "feed":
|
|
204
215
|
return [key.type, key.orgId] as const;
|
|
205
216
|
|
|
206
217
|
// System-scoped (ADR-CONTRACTS-052) — tenant-less super-admin resources
|
package/src/user-events/index.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
export {
|
|
9
9
|
MAX_INVALIDATION_KEYS,
|
|
10
|
+
NotificationCreatedEventSchema,
|
|
10
11
|
ResourceInvalidatedEventSchema,
|
|
11
12
|
USER_EVENT_RESYNC_REASONS,
|
|
12
13
|
UserEventBaseSchema,
|
|
@@ -17,6 +18,7 @@ export {
|
|
|
17
18
|
} from "./schemas";
|
|
18
19
|
|
|
19
20
|
export type {
|
|
21
|
+
NotificationCreatedEvent,
|
|
20
22
|
ResourceInvalidatedEvent,
|
|
21
23
|
UserEventConnected,
|
|
22
24
|
UserEventResync,
|
|
@@ -134,6 +134,46 @@ export const UserEventResyncSchema = UserEventBaseSchema.partial({
|
|
|
134
134
|
});
|
|
135
135
|
export type UserEventResync = z.infer<typeof UserEventResyncSchema>;
|
|
136
136
|
|
|
137
|
+
// =============================================================================
|
|
138
|
+
// notification.created — an ACCELERATOR, never the source of truth
|
|
139
|
+
// =============================================================================
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A durable notification row was written for this user.
|
|
143
|
+
*
|
|
144
|
+
* AN ACCELERATOR ONLY. The row is already committed and the inbox
|
|
145
|
+
* (`GET /api/me/feed`) is the authority; this frame exists so a connected
|
|
146
|
+
* client sees it without waiting for a refetch. A client that misses it loses
|
|
147
|
+
* nothing — the row is still there on the next read, which is exactly why this
|
|
148
|
+
* stream is allowed to stay best-effort.
|
|
149
|
+
*
|
|
150
|
+
* That is also why it carries only the id and the unread count, not the
|
|
151
|
+
* rendered notification. Rendering happens at READ TIME under the reader's
|
|
152
|
+
* current authority (see `UserNotificationSchema`); a pre-rendered payload on
|
|
153
|
+
* the wire would be a snapshot of authority taken at write time, which is the
|
|
154
|
+
* one thing that must not be cached. It would also put entity content on a
|
|
155
|
+
* `pg_notify` payload, which the transport forbids for its own reasons.
|
|
156
|
+
*
|
|
157
|
+
* There is deliberately NO `notification.read` counterpart. Read state is a
|
|
158
|
+
* mutable column on a row whose id was allocated at creation, so a read event
|
|
159
|
+
* has no valid position in a stream ordered by anything — it would either move
|
|
160
|
+
* an id backwards or reuse one already observed. Read state is reconciled from
|
|
161
|
+
* the authoritative query instead.
|
|
162
|
+
*/
|
|
163
|
+
export const NotificationCreatedEventSchema = UserEventBaseSchema.partial({
|
|
164
|
+
v: true,
|
|
165
|
+
timestamp: true,
|
|
166
|
+
}).extend({
|
|
167
|
+
type: z.literal("notification.created"),
|
|
168
|
+
/** The row id, so a client that already has it can dedupe. */
|
|
169
|
+
notificationId: z.string(),
|
|
170
|
+
/** Total unread after this write, so the badge updates without a fetch. */
|
|
171
|
+
unreadCount: z.number().int().nonnegative(),
|
|
172
|
+
});
|
|
173
|
+
export type NotificationCreatedEvent = z.infer<
|
|
174
|
+
typeof NotificationCreatedEventSchema
|
|
175
|
+
>;
|
|
176
|
+
|
|
137
177
|
// =============================================================================
|
|
138
178
|
// connected — transport-level
|
|
139
179
|
// =============================================================================
|
|
@@ -167,6 +207,7 @@ export type UserEventConnected = z.infer<typeof UserEventConnectedSchema>;
|
|
|
167
207
|
*/
|
|
168
208
|
export const UserSseEventSchema = z.discriminatedUnion("type", [
|
|
169
209
|
ResourceInvalidatedEventSchema,
|
|
210
|
+
NotificationCreatedEventSchema,
|
|
170
211
|
UserEventResyncSchema,
|
|
171
212
|
UserEventConnectedSchema,
|
|
172
213
|
]);
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# user-notifications/
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
The published shapes of the **durable in-app inbox** — informational facts
|
|
6
|
+
addressed to one user, which they can only look at — and of the **merged feed**
|
|
7
|
+
that renders them alongside action items at the top of `/me/work`.
|
|
8
|
+
|
|
9
|
+
## Invariants
|
|
10
|
+
|
|
11
|
+
- **A stored row holds IDENTIFIERS ONLY**: `kind` plus a `ref` of ids. No
|
|
12
|
+
titles, no paths, no names, no bodies. Everything in `UserNotificationSchema`
|
|
13
|
+
that reads like prose is produced at READ TIME by the producing domain's
|
|
14
|
+
renderer, under the reader's CURRENT authority.
|
|
15
|
+
- **A stored row therefore never contains protected content**, so a stale row
|
|
16
|
+
cannot leak one. This is the invariant the whole design turns on: it is what
|
|
17
|
+
makes copy evolution a renderer change rather than a migration, and what makes
|
|
18
|
+
revocation a re-check rather than a backfill.
|
|
19
|
+
- **`href` is nullable and a tombstone is a legitimate render.** A reader who
|
|
20
|
+
lost access, or whose client cannot decode the stored `ref_version`, still
|
|
21
|
+
sees that the thing happened — it simply does not link anywhere. "You were
|
|
22
|
+
granted access to something you can no longer see" is true and worth saying.
|
|
23
|
+
- **Feed ordering is grouped, not chronologically interleaved.** Actionable rows
|
|
24
|
+
first, then informational rows newest-first. "Merged feed" names one visual
|
|
25
|
+
surface, not one timeline.
|
|
26
|
+
- **Only notifications paginate.** Action items are already bounded and are
|
|
27
|
+
standing state, not a timeline. Page 1 = all action items + the first page of
|
|
28
|
+
notifications; every later page is notifications only. Paginating two
|
|
29
|
+
independent sources against one cursor is where merged feeds go wrong.
|
|
30
|
+
- **`unreadCount` is total, never windowed.** A windowed count under-reports the
|
|
31
|
+
moment the inbox exceeds one page, and it drives a badge whose whole job is to
|
|
32
|
+
be trusted.
|
|
33
|
+
- **The cursor encodes `(createdAt, id)`.** The id breaks ties so two rows
|
|
34
|
+
written in one transaction cannot straddle a page boundary and be served twice
|
|
35
|
+
or skipped.
|
|
36
|
+
|
|
37
|
+
## Public API
|
|
38
|
+
|
|
39
|
+
| Export | Description |
|
|
40
|
+
| --------------------------- | -------------------------------------------------------------- |
|
|
41
|
+
| `UserNotificationSchema` | One informational row, as rendered |
|
|
42
|
+
| `USER_NOTIFICATION_KINDS` | The closed kind vocabulary |
|
|
43
|
+
| `FeedItemSchema` | `actionable` \| `informational`, discriminated on `row` |
|
|
44
|
+
| `FeedListResponseSchema` | `GET /api/me/feed` — items, nextCursor, unreadCount, truncated |
|
|
45
|
+
| `FeedMarkReadRequestSchema` | `POST /api/me/feed/read` |
|
|
46
|
+
|
|
47
|
+
## Dependencies
|
|
48
|
+
|
|
49
|
+
- `zod` — schemas are canonical, types are inferred.
|
|
50
|
+
- `../action-items` — `ActionItemSchema`, `ActionItemTargetSchema`. The feed
|
|
51
|
+
UNIONS with action items; it does not redefine them, and it does not modify
|
|
52
|
+
them.
|
|
53
|
+
|
|
54
|
+
## How this differs from the two vocabularies beside it
|
|
55
|
+
|
|
56
|
+
Three things in this package look like "a notification". ADR-CONT-104 forbids
|
|
57
|
+
collapsing the first two; this adds a third rather than bending either:
|
|
58
|
+
|
|
59
|
+
- **`NotificationKind`** (`../notifications`) names a message that **leaves the
|
|
60
|
+
building** — composed into prose, delivered once to an address, lifecycle over
|
|
61
|
+
at send.
|
|
62
|
+
- **`ActionItemKind`** (`../action-items`) names **standing state** — a decision
|
|
63
|
+
you owe, re-derived on every read, gone the moment anyone resolves it. Never
|
|
64
|
+
stored, no read state.
|
|
65
|
+
- **`UserNotificationKind`** names a **stored fact about the past**, addressed to
|
|
66
|
+
one person, that they can only look at. `action-items/kinds.ts` named this gap
|
|
67
|
+
itself: _"a kind whose item cannot be acted on is not an action item — that is
|
|
68
|
+
the informational feed, which this layer does not model (yet)."_
|
|
69
|
+
|
|
70
|
+
They are not in bijection. `companyMd.access_request_pending` is an action item
|
|
71
|
+
and deliberately has **no** member here: the owner can resolve it, so modelling
|
|
72
|
+
it in both places would give one fact two lifecycles that could disagree.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# user-notifications/\_\_tests\_\_/
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Locks the claims `../README.md` and ADR-CONT-108 make that the compiler cannot.
|
|
6
|
+
|
|
7
|
+
- `vocabulary.test.ts` — four things a type signature does not say:
|
|
8
|
+
1. **The three vocabularies stay disjoint.** `UserNotificationKind`,
|
|
9
|
+
`ActionItemKind` and `NotificationKind` overlap in subject matter and look
|
|
10
|
+
mergeable; they are not, because a notification's lifecycle ends at send, an
|
|
11
|
+
action item's ends when anyone resolves it, and one of these ends never.
|
|
12
|
+
Specifically: `companyMd.access_request_pending` must NOT appear here — it
|
|
13
|
+
is resolvable, so modelling it in both places would give one fact two
|
|
14
|
+
lifecycles that could disagree.
|
|
15
|
+
2. **A tombstone parses.** `href: null` is a legitimate render, not a
|
|
16
|
+
degenerate one: a reader who lost access still learns the thing happened.
|
|
17
|
+
If this stops parsing, revocation silently becomes an error path.
|
|
18
|
+
3. **`FeedItem` discriminates on `row`, not on field-sniffing.** A client must
|
|
19
|
+
never have to infer "informational" from the presence of `readAt`.
|
|
20
|
+
4. **The response carries no field named `version`.** The app's
|
|
21
|
+
`invalidateResource` probes cached payloads for that name structurally and
|
|
22
|
+
SKIPS the invalidation when the cached value is at least as new — the same
|
|
23
|
+
trap `action-items/__tests__` guards, and it would wedge the feed badge
|
|
24
|
+
with no error anywhere.
|
|
25
|
+
|
|
26
|
+
## Invariants
|
|
27
|
+
|
|
28
|
+
- These assert VOCABULARY and SHAPE, never behaviour. Anything needing a
|
|
29
|
+
renderer, a database or an authority gate belongs in backend's
|
|
30
|
+
`src/user-notifications/__tests__/`.
|
|
31
|
+
- The disjointness test reads the real kind arrays rather than restating them. A
|
|
32
|
+
hand-copied list drifts and starts passing vacuously.
|
|
33
|
+
- Negative cases mutate ONE field of a well-formed fixture. A hand-built broken
|
|
34
|
+
object can pass for the wrong reason.
|
|
35
|
+
|
|
36
|
+
## Public API
|
|
37
|
+
|
|
38
|
+
None — test-only.
|
|
39
|
+
|
|
40
|
+
## Dependencies
|
|
41
|
+
|
|
42
|
+
`vitest`, the sibling modules under test, and `../../action-items` /
|
|
43
|
+
`../../notifications` for the real kind lists.
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The inbox vocabulary's invariants, as tests rather than prose (ADR-CONT-108).
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from "vitest";
|
|
5
|
+
import { USER_NOTIFICATION_KINDS } from "../kinds.js";
|
|
6
|
+
import {
|
|
7
|
+
FeedItemSchema,
|
|
8
|
+
FeedListResponseSchema,
|
|
9
|
+
UserNotificationSchema,
|
|
10
|
+
} from "../schemas.js";
|
|
11
|
+
import { ACTION_ITEM_KINDS } from "../../action-items/index.js";
|
|
12
|
+
import { NOTIFICATION_DEFINITIONS } from "../../notifications/index.js";
|
|
13
|
+
|
|
14
|
+
const ORG_DOC = "22222222-2222-4222-8222-222222222222";
|
|
15
|
+
|
|
16
|
+
function makeNotification(over: Record<string, unknown> = {}) {
|
|
17
|
+
return {
|
|
18
|
+
id: "n-1",
|
|
19
|
+
kind: "companyMd.access_request_approved",
|
|
20
|
+
target: { type: "company_md", id: ORG_DOC },
|
|
21
|
+
unitPath: "acme.sales",
|
|
22
|
+
title: "Dev Patel approved your request",
|
|
23
|
+
detail: null,
|
|
24
|
+
contextLabel: "Q3 OKRs",
|
|
25
|
+
href: "/@acme/md/doc-1",
|
|
26
|
+
createdAt: "2026-07-30T12:00:00.000Z",
|
|
27
|
+
readAt: null,
|
|
28
|
+
...over,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("the three vocabularies stay disjoint", () => {
|
|
33
|
+
it("shares no member with ActionItemKind", () => {
|
|
34
|
+
// They overlap in SUBJECT MATTER and look mergeable. They are not: an
|
|
35
|
+
// action item is standing state that ends when anyone resolves it; one of
|
|
36
|
+
// these is a stored fact that ends never.
|
|
37
|
+
const actionItems = new Set<string>(ACTION_ITEM_KINDS);
|
|
38
|
+
for (const kind of USER_NOTIFICATION_KINDS) {
|
|
39
|
+
expect(actionItems.has(kind)).toBe(false);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("does NOT model the pending request, which is resolvable", () => {
|
|
44
|
+
// The load-bearing case. `companyMd.access_request_pending` is an ACTION
|
|
45
|
+
// ITEM — the owner can settle it. A durable twin would give one fact two
|
|
46
|
+
// lifecycles that could disagree: resolve the action item and the stored
|
|
47
|
+
// row would still claim something is pending.
|
|
48
|
+
expect(USER_NOTIFICATION_KINDS).not.toContain(
|
|
49
|
+
"companyMd.access_request_pending",
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("names kinds in {domain}.{type} form, matching both siblings", () => {
|
|
54
|
+
for (const kind of USER_NOTIFICATION_KINDS) {
|
|
55
|
+
expect(kind).toMatch(/^[a-z][a-zA-Z0-9]*\.[a-z][a-z0-9_]*$/);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("overlaps NotificationKind by NAME without inheriting its lifecycle", () => {
|
|
60
|
+
// Reading the real definitions rather than restating them, so this cannot
|
|
61
|
+
// pass vacuously. An overlap here is EXPECTED and fine — the email and the
|
|
62
|
+
// stored row are two different things about one event. What must not happen
|
|
63
|
+
// is one union being derived from the other.
|
|
64
|
+
const sent = new Set(Object.keys(NOTIFICATION_DEFINITIONS));
|
|
65
|
+
const shared = USER_NOTIFICATION_KINDS.filter((k) => sent.has(k));
|
|
66
|
+
expect(shared.length).toBeGreaterThan(0);
|
|
67
|
+
expect(USER_NOTIFICATION_KINDS.length).not.toBe(sent.size);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("UserNotificationSchema", () => {
|
|
72
|
+
it("parses a well-formed row", () => {
|
|
73
|
+
expect(UserNotificationSchema.safeParse(makeNotification()).success).toBe(
|
|
74
|
+
true,
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("parses a TOMBSTONE — href null is a legitimate render", () => {
|
|
79
|
+
// A reader who lost access, or whose client cannot decode the stored
|
|
80
|
+
// ref_version, still learns the thing happened. If this stops parsing,
|
|
81
|
+
// revocation silently becomes an error path instead of a render.
|
|
82
|
+
expect(
|
|
83
|
+
UserNotificationSchema.safeParse(makeNotification({ href: null }))
|
|
84
|
+
.success,
|
|
85
|
+
).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("carries read state, unlike an action item", () => {
|
|
89
|
+
const read = UserNotificationSchema.parse(
|
|
90
|
+
makeNotification({ readAt: "2026-07-30T13:00:00.000Z" }),
|
|
91
|
+
);
|
|
92
|
+
expect(read.readAt).not.toBeNull();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("rejects an unknown kind", () => {
|
|
96
|
+
expect(
|
|
97
|
+
UserNotificationSchema.safeParse(makeNotification({ kind: "made.up" }))
|
|
98
|
+
.success,
|
|
99
|
+
).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("FeedItem discriminates on `row`", () => {
|
|
104
|
+
it("accepts an informational row", () => {
|
|
105
|
+
const parsed = FeedItemSchema.safeParse({
|
|
106
|
+
row: "informational",
|
|
107
|
+
notification: makeNotification(),
|
|
108
|
+
});
|
|
109
|
+
expect(parsed.success).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("accepts an actionable row", () => {
|
|
113
|
+
const parsed = FeedItemSchema.safeParse({
|
|
114
|
+
row: "actionable",
|
|
115
|
+
item: {
|
|
116
|
+
id: "companyMd.access_request_pending:req-1",
|
|
117
|
+
kind: "companyMd.access_request_pending",
|
|
118
|
+
target: { type: "company_md", id: ORG_DOC },
|
|
119
|
+
unitPath: null,
|
|
120
|
+
title: "Maya Chen wants access",
|
|
121
|
+
detail: null,
|
|
122
|
+
contextLabel: "Q3 OKRs",
|
|
123
|
+
href: "/@acme/md/doc-1?share=1&request=req-1",
|
|
124
|
+
createdAt: "2026-07-30T12:00:00.000Z",
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
expect(parsed.success).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("cannot be satisfied by field-sniffing — the tag is required", () => {
|
|
131
|
+
// A client must never infer "informational" from the presence of `readAt`.
|
|
132
|
+
expect(
|
|
133
|
+
FeedItemSchema.safeParse({ notification: makeNotification() }).success,
|
|
134
|
+
).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("FeedListResponse", () => {
|
|
139
|
+
const base = {
|
|
140
|
+
items: [],
|
|
141
|
+
nextCursor: null,
|
|
142
|
+
unreadCount: 0,
|
|
143
|
+
truncated: false,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
it("parses an empty feed", () => {
|
|
147
|
+
expect(FeedListResponseSchema.safeParse(base).success).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("carries a cursor for the next page of informational rows", () => {
|
|
151
|
+
expect(
|
|
152
|
+
FeedListResponseSchema.safeParse({ ...base, nextCursor: "opaque" })
|
|
153
|
+
.success,
|
|
154
|
+
).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("has NO field named `version`", () => {
|
|
158
|
+
// The app's invalidateResource probes cached payloads for that name
|
|
159
|
+
// structurally and SKIPS the invalidation when the cached value is at least
|
|
160
|
+
// as new. A field of that name here would wedge the feed badge with no
|
|
161
|
+
// error anywhere. Same trap `action-items/__tests__` guards.
|
|
162
|
+
expect(Object.keys(FeedListResponseSchema.shape)).not.toContain("version");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("rejects a negative unread count", () => {
|
|
166
|
+
expect(
|
|
167
|
+
FeedListResponseSchema.safeParse({ ...base, unreadCount: -1 }).success,
|
|
168
|
+
).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* user-notifications/ — the durable in-app inbox, and the merged feed that
|
|
3
|
+
* renders it alongside action items.
|
|
4
|
+
*
|
|
5
|
+
* See ./README.md for the domain, and ADR-CONT-108 for why this is a third
|
|
6
|
+
* vocabulary rather than a bend in either of the two beside it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export { USER_NOTIFICATION_KINDS } from "./kinds";
|
|
10
|
+
export type { UserNotificationKind } from "./kinds";
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
FeedItemSchema,
|
|
14
|
+
FeedListResponseSchema,
|
|
15
|
+
FeedMarkReadRequestSchema,
|
|
16
|
+
UserNotificationKindSchema,
|
|
17
|
+
UserNotificationSchema,
|
|
18
|
+
} from "./schemas";
|
|
19
|
+
|
|
20
|
+
export type {
|
|
21
|
+
FeedItem,
|
|
22
|
+
FeedListResponse,
|
|
23
|
+
FeedMarkReadRequest,
|
|
24
|
+
UserNotification,
|
|
25
|
+
} from "./schemas";
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What kinds of durable in-app notification exist.
|
|
3
|
+
*
|
|
4
|
+
* A user notification is an INFORMATIONAL row addressed to one user: something
|
|
5
|
+
* that happened which they should know about and cannot act on. It is stored,
|
|
6
|
+
* it has read state, and it survives the user being offline.
|
|
7
|
+
*
|
|
8
|
+
* WHY THIS IS A THIRD VOCABULARY, alongside `NotificationKind` and
|
|
9
|
+
* `ActionItemKind` (ADR-CONT-104 forbids collapsing those two; this adds a
|
|
10
|
+
* third rather than bending either):
|
|
11
|
+
*
|
|
12
|
+
* - `NotificationKind` names a message that LEAVES THE BUILDING — an email or a
|
|
13
|
+
* Slack post, composed by a render pipeline into prose carrying a brand and a
|
|
14
|
+
* year, delivered once to an address. Its lifecycle ends at send.
|
|
15
|
+
* - `ActionItemKind` names STANDING STATE — a decision you owe, re-derived from
|
|
16
|
+
* its domain's pending rows on every read, which stops existing the moment
|
|
17
|
+
* anyone resolves it. It is never stored and has no read state, because
|
|
18
|
+
* "resolved" is the only clearing mechanism it needs.
|
|
19
|
+
* - A user notification is neither. It is a FACT about the past, addressed to
|
|
20
|
+
* one person, which they can only look at. `action-items/kinds.ts` already
|
|
21
|
+
* named this gap: "a kind whose item cannot be acted on is not an action item
|
|
22
|
+
* — that is the informational feed, which this layer does not model (yet)."
|
|
23
|
+
* This is that layer.
|
|
24
|
+
*
|
|
25
|
+
* The three are not in bijection and never will be. `auth.otp` is a
|
|
26
|
+
* notification and can be neither of the others. `execution.confirmation_pending`
|
|
27
|
+
* is an action item with no notification. `companyMd.access_request_approved` is
|
|
28
|
+
* all three shapes' subject matter and gets a member in each, because the email,
|
|
29
|
+
* the (now absent) pending decision, and the durable "you were granted access"
|
|
30
|
+
* row are three different things with three different lifecycles.
|
|
31
|
+
*
|
|
32
|
+
* INVARIANTS:
|
|
33
|
+
* - `{domain}.{type}` dot notation, matching both sibling unions.
|
|
34
|
+
* - These strings go on the wire AND into a database column. Renaming one is a
|
|
35
|
+
* migration, not a tidy-up.
|
|
36
|
+
* - A kind earns membership by being something a user should be able to come
|
|
37
|
+
* back to. If it is only worth seeing while connected, it is a transient
|
|
38
|
+
* frame on the user-event stream, not a row here.
|
|
39
|
+
* - A kind's row stores IDENTIFIERS ONLY (see `./schemas`). Copy lives in the
|
|
40
|
+
* producing domain's renderer and is resolved at read time.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
// =============================================================================
|
|
44
|
+
// UserNotificationKind Union
|
|
45
|
+
// =============================================================================
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* New kinds MUST be added to:
|
|
49
|
+
* 1. This union
|
|
50
|
+
* 2. A `NotificationRenderer` in the PRODUCING domain, which applies that
|
|
51
|
+
* domain's own authority gate in bulk and returns a tombstone when the
|
|
52
|
+
* reader may no longer see the referenced entity
|
|
53
|
+
*
|
|
54
|
+
* The three members below are the informational half of the company.md
|
|
55
|
+
* request-access lifecycle. Note what is NOT here:
|
|
56
|
+
* `companyMd.access_request_pending` is an ACTION ITEM — the owner can resolve
|
|
57
|
+
* it, so it is standing state, and modelling it here as well would give one
|
|
58
|
+
* fact two lifecycles that could disagree.
|
|
59
|
+
*/
|
|
60
|
+
export const USER_NOTIFICATION_KINDS = [
|
|
61
|
+
/** Someone asked for access to a doc you own. Informational twin of the action item. */
|
|
62
|
+
"companyMd.access_requested",
|
|
63
|
+
/** Your request was approved — you now have access. */
|
|
64
|
+
"companyMd.access_request_approved",
|
|
65
|
+
/** Your request was declined. */
|
|
66
|
+
"companyMd.access_request_denied",
|
|
67
|
+
] as const;
|
|
68
|
+
|
|
69
|
+
export type UserNotificationKind = (typeof USER_NOTIFICATION_KINDS)[number];
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The published shapes of the durable notification inbox and the merged feed
|
|
3
|
+
* that renders it (`GET /api/me/feed`).
|
|
4
|
+
*
|
|
5
|
+
* Zod-canonical: schema is the source of truth, the type is inferred.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { ActionItemSchema, ActionItemTargetSchema } from "../action-items";
|
|
9
|
+
import { USER_NOTIFICATION_KINDS } from "./kinds";
|
|
10
|
+
|
|
11
|
+
/** Zod mirror of the vocabulary. `./kinds` owns the list; this never restates it. */
|
|
12
|
+
export const UserNotificationKindSchema = z.enum(USER_NOTIFICATION_KINDS);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One informational row, AS RENDERED — never as stored.
|
|
16
|
+
*
|
|
17
|
+
* The stored row holds identifiers only (`kind` + a `ref` of ids). Everything
|
|
18
|
+
* below that reads like prose — `title`, `detail`, `contextLabel`, `href` — is
|
|
19
|
+
* produced at READ TIME by the producing domain's renderer, under the reader's
|
|
20
|
+
* CURRENT authority. Three problems dissolve at once:
|
|
21
|
+
*
|
|
22
|
+
* - **Copy evolution.** There is no frozen prose in the database to decode, so
|
|
23
|
+
* changing wording is a renderer change, not a migration.
|
|
24
|
+
* - **Authorization drift.** A user who lost access to a document after being
|
|
25
|
+
* notified about it must not keep reading its title out of an old row. The
|
|
26
|
+
* renderer re-checks and returns a tombstone instead.
|
|
27
|
+
* - **Deletion.** A notification about a deleted doc renders as a tombstone
|
|
28
|
+
* rather than dangling.
|
|
29
|
+
*
|
|
30
|
+
* The invariant that makes this safe is on the storage side and is worth
|
|
31
|
+
* stating here too, because this is where someone would be tempted to break it:
|
|
32
|
+
* **a stored notification row never contains protected content**, so a stale row
|
|
33
|
+
* cannot leak one.
|
|
34
|
+
*
|
|
35
|
+
* Deliberately shaped PARALLEL to `ActionItemSchema` — same field names, same
|
|
36
|
+
* meanings — so the merged feed is a union over two near-identical rows and the
|
|
37
|
+
* existing roll-up helpers keep working unchanged.
|
|
38
|
+
*/
|
|
39
|
+
export const UserNotificationSchema = z.object({
|
|
40
|
+
/** The stored row's id. Unlike an action item's, this IS a row id. */
|
|
41
|
+
id: z.string(),
|
|
42
|
+
kind: UserNotificationKindSchema,
|
|
43
|
+
target: ActionItemTargetSchema,
|
|
44
|
+
/** ltree anchor for subtree roll-up; `null` = org-wide. */
|
|
45
|
+
unitPath: z.string().nullable(),
|
|
46
|
+
/** One line naming what happened, e.g. "Dev Patel approved your request". */
|
|
47
|
+
title: z.string(),
|
|
48
|
+
/** Optional second line — the decider's note, a reason. */
|
|
49
|
+
detail: z.string().nullable(),
|
|
50
|
+
/** The thing it is about, as the user names it, e.g. "Sales.md". */
|
|
51
|
+
contextLabel: z.string(),
|
|
52
|
+
/**
|
|
53
|
+
* Where to go to SEE it — a RELATIVE app path.
|
|
54
|
+
*
|
|
55
|
+
* `null` when the reader can no longer reach the referent (access revoked,
|
|
56
|
+
* doc deleted, or a `ref` this client's version cannot decode). A tombstoned
|
|
57
|
+
* row still renders, because "you were granted access to something you can no
|
|
58
|
+
* longer see" is itself true and useful; it simply does not link anywhere.
|
|
59
|
+
*/
|
|
60
|
+
href: z.string().nullable(),
|
|
61
|
+
createdAt: z.string(),
|
|
62
|
+
/** `null` while unread. The only mutable field on the row. */
|
|
63
|
+
readAt: z.string().nullable(),
|
|
64
|
+
});
|
|
65
|
+
export type UserNotification = z.infer<typeof UserNotificationSchema>;
|
|
66
|
+
|
|
67
|
+
// =============================================================================
|
|
68
|
+
// The merged feed
|
|
69
|
+
// =============================================================================
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One row of `/me/work`'s top section, discriminated by what it DEMANDS.
|
|
73
|
+
*
|
|
74
|
+
* `actionable` rows are derived standing state you can resolve; `informational`
|
|
75
|
+
* rows are stored facts you can only read. They are merged for PRESENTATION —
|
|
76
|
+
* one list under one heading — and nowhere else: the two producing vocabularies
|
|
77
|
+
* stay separate (ADR-CONT-104), and the `action-items` domain still owns no
|
|
78
|
+
* tables.
|
|
79
|
+
*
|
|
80
|
+
* Discriminating on `row` rather than sniffing for `readAt` keeps the client
|
|
81
|
+
* from having to know which fields imply which lifecycle.
|
|
82
|
+
*/
|
|
83
|
+
export const FeedItemSchema = z.discriminatedUnion("row", [
|
|
84
|
+
z.object({ row: z.literal("actionable"), item: ActionItemSchema }),
|
|
85
|
+
z.object({
|
|
86
|
+
row: z.literal("informational"),
|
|
87
|
+
notification: UserNotificationSchema,
|
|
88
|
+
}),
|
|
89
|
+
]);
|
|
90
|
+
export type FeedItem = z.infer<typeof FeedItemSchema>;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* `GET /api/me/feed`.
|
|
94
|
+
*
|
|
95
|
+
* ORDERING IS INTENTIONALLY GROUPED, NOT CHRONOLOGICALLY INTERLEAVED: every
|
|
96
|
+
* `actionable` row first, then `informational` rows newest-first. "Merged feed"
|
|
97
|
+
* names one visual surface, not one timeline. Grouping is what keeps "what do I
|
|
98
|
+
* owe?" answerable at a glance instead of scattered between announcements — and
|
|
99
|
+
* it is what makes the pagination rule below coherent.
|
|
100
|
+
*
|
|
101
|
+
* ONLY NOTIFICATIONS PAGINATE. Action items are already bounded by
|
|
102
|
+
* `ACTION_ITEM_LIMIT` and are standing state rather than a timeline, so page 1
|
|
103
|
+
* carries all of them plus the first page of notifications, and every
|
|
104
|
+
* subsequent page is notifications only. Paginating two independent sources
|
|
105
|
+
* against one cursor is where merged feeds go wrong.
|
|
106
|
+
*/
|
|
107
|
+
export const FeedListResponseSchema = z.object({
|
|
108
|
+
items: z.array(FeedItemSchema),
|
|
109
|
+
/**
|
|
110
|
+
* Opaque cursor for the NEXT page of informational rows; `null` at the end.
|
|
111
|
+
*
|
|
112
|
+
* Encodes `(createdAt, id)` — `id` breaks ties so two rows written in the same
|
|
113
|
+
* transaction cannot straddle a page boundary and be served twice or skipped.
|
|
114
|
+
*/
|
|
115
|
+
nextCursor: z.string().nullable(),
|
|
116
|
+
/**
|
|
117
|
+
* Total unread across ALL retained notifications — NOT within the returned
|
|
118
|
+
* window.
|
|
119
|
+
*
|
|
120
|
+
* A windowed count would silently under-report the moment the inbox grew past
|
|
121
|
+
* one page, and this number drives a badge whose entire job is to be trusted.
|
|
122
|
+
*/
|
|
123
|
+
unreadCount: z.number().int().nonnegative(),
|
|
124
|
+
/** True when the ACTIONABLE half hit its cap; notifications say so via `nextCursor`. */
|
|
125
|
+
truncated: z.boolean(),
|
|
126
|
+
});
|
|
127
|
+
export type FeedListResponse = z.infer<typeof FeedListResponseSchema>;
|
|
128
|
+
|
|
129
|
+
/** `POST /api/me/feed/read` — mark specific informational rows read. */
|
|
130
|
+
export const FeedMarkReadRequestSchema = z.object({
|
|
131
|
+
notificationIds: z.array(z.string()).min(1).max(200),
|
|
132
|
+
});
|
|
133
|
+
export type FeedMarkReadRequest = z.infer<typeof FeedMarkReadRequestSchema>;
|