@company-semantics/contracts 42.1.0 → 44.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.
@@ -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>;