@company-semantics/contracts 82.0.0 → 83.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +137 -0
- package/src/generated/openapi-routes.ts +2 -0
- package/src/index.ts +51 -1
- package/src/user-notifications/README.md +99 -12
- package/src/user-notifications/__tests__/README.md +19 -0
- package/src/user-notifications/__tests__/compile-fixtures.ts +54 -0
- package/src/user-notifications/__tests__/condition-keys.test.ts +126 -0
- package/src/user-notifications/__tests__/feed-lifecycle.test.ts +260 -0
- package/src/user-notifications/__tests__/lifecycle.test.ts +114 -0
- package/src/user-notifications/__tests__/vocabulary.test.ts +6 -1
- package/src/user-notifications/condition-keys.ts +64 -0
- package/src/user-notifications/feed-lifecycle.ts +76 -0
- package/src/user-notifications/index.ts +49 -2
- package/src/user-notifications/lifecycle-registry.ts +123 -0
- package/src/user-notifications/lifecycle.ts +75 -0
- package/src/user-notifications/schemas.ts +27 -6
|
@@ -0,0 +1,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>;
|