@company-semantics/contracts 39.3.0 → 39.4.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 +5 -5
- package/src/action-items/README.md +73 -0
- package/src/action-items/__tests__/README.md +39 -0
- package/src/action-items/__tests__/vocabulary.test.ts +133 -0
- package/src/action-items/index.ts +33 -0
- package/src/action-items/kinds.ts +82 -0
- package/src/action-items/schemas.ts +87 -0
- package/src/index.ts +26 -0
- package/src/resource-keys.ts +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@company-semantics/contracts",
|
|
3
|
-
"version": "39.
|
|
3
|
+
"version": "39.4.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -147,14 +147,14 @@
|
|
|
147
147
|
"overrides": {
|
|
148
148
|
"brace-expansion@<5.0.8": ">=5.0.8",
|
|
149
149
|
"minimatch@<10.2.3": ">=10.2.3",
|
|
150
|
+
"js-yaml@>=4.0.0 <4.3.0": ">=4.3.0 <5.0.0",
|
|
151
|
+
"js-yaml@>=5.0.0 <5.2.2": ">=5.2.2 <6.0.0",
|
|
150
152
|
"rollup@<4.59.0": ">=4.59.0",
|
|
151
153
|
"picomatch@<4.0.4": ">=4.0.4",
|
|
152
154
|
"esbuild@<0.28.1": ">=0.28.1",
|
|
153
155
|
"linkify-it@<5.0.2": ">=5.0.2",
|
|
154
156
|
"postcss@<8.5.18": ">=8.5.18",
|
|
155
|
-
"markdown-it@<14.2.0": ">=14.2.0"
|
|
156
|
-
"js-yaml@>=4.0.0 <4.3.0": ">=4.3.0 <5.0.0",
|
|
157
|
-
"js-yaml@>=5.0.0 <5.2.2": ">=5.2.2"
|
|
157
|
+
"markdown-it@<14.2.0": ">=14.2.0"
|
|
158
158
|
}
|
|
159
159
|
},
|
|
160
160
|
"lint-staged": {
|
|
@@ -162,5 +162,5 @@
|
|
|
162
162
|
"*.md": "markdownlint-cli2",
|
|
163
163
|
"package.json": "node -e \"JSON.parse(require('fs').readFileSync('package.json'))\""
|
|
164
164
|
},
|
|
165
|
-
"securityRequirementsVersion": "
|
|
165
|
+
"securityRequirementsVersion": "888e8f888ed3bee8311fd39dee4b24b4f23a6638f1df183e3e6b4e0455704ae2"
|
|
166
166
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# action-items/
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
The vocabulary for **things awaiting a particular user's decision** — the
|
|
6
|
+
published shape of `GET /api/me/action-items`, which is the one read that
|
|
7
|
+
answers "what needs me?" across every domain at once.
|
|
8
|
+
|
|
9
|
+
Before this, every pending-thing read in the system was scoped to one entity
|
|
10
|
+
(`/docs/{id}/access-requests`) or one org (`/api/workspace/invites`). Nothing
|
|
11
|
+
was keyed by _actor = me, across resources_, so a pending access request on a
|
|
12
|
+
document was invisible from the tree, the sidebar, and the app shell — the only
|
|
13
|
+
real notice was an email.
|
|
14
|
+
|
|
15
|
+
An action item is **derived standing state, never a stored row**. A provider in
|
|
16
|
+
`backend/src/action-items/providers/` answers "what does user U owe action on in
|
|
17
|
+
org O?" from its domain's own pending rows, under its domain's own authority
|
|
18
|
+
gate. Nothing is written, so nothing has to be cleared: when the first owner
|
|
19
|
+
approves a request, the source row leaves `pending` and the item is simply
|
|
20
|
+
absent from the next read. There is no clearing code to forget to write.
|
|
21
|
+
|
|
22
|
+
- `kinds.ts` — `ACTION_ITEM_KINDS` and the `ActionItemKind` union derived from
|
|
23
|
+
it. Also the argument for why this is a different vocabulary from
|
|
24
|
+
`NotificationKind`, and the list of things deliberately excluded.
|
|
25
|
+
- `schemas.ts` — `ActionItem`, `ActionItemTarget`, `ActionItemListResponse`.
|
|
26
|
+
Zod-canonical.
|
|
27
|
+
|
|
28
|
+
## Invariants
|
|
29
|
+
|
|
30
|
+
- **A kind names STANDING STATE, not an event.** `NotificationKind` names
|
|
31
|
+
things that get SENT (`companyMd.access_requested` — an email left the
|
|
32
|
+
building); an `ActionItemKind` names a decision that is outstanding right now
|
|
33
|
+
(`companyMd.access_request_pending`). They are not in bijection: `auth.otp`
|
|
34
|
+
can never be an action item, `execution.confirmation_pending` has no
|
|
35
|
+
notification, and one item can be owed by many users where the first to act
|
|
36
|
+
closes it for all. Collapsing the unions makes one of those meanings lie.
|
|
37
|
+
- **Copy is not shared with `notifications/`.** An email is prose carrying a
|
|
38
|
+
brand and a year; an action-item row is a title, a detail line and the name of
|
|
39
|
+
the thing. Bending the send-shaped render pipeline to also emit list rows
|
|
40
|
+
would couple derived state to a delivery mechanism. This is a decision, not an
|
|
41
|
+
oversight — see ADR-CONT-104 before "fixing" it.
|
|
42
|
+
- **`href` must RESOLVE the item, not merely show it.** An item you can only
|
|
43
|
+
look at is a notification. This is what keeps the layer honest about being
|
|
44
|
+
actionable-only.
|
|
45
|
+
- **`id` is `${kind}:${sourceRowId}`** — a function of the source row, stable
|
|
46
|
+
across reads. It is not an id of its own, because there is no row of its own.
|
|
47
|
+
- **`unitPath` is a roll-up ANCHOR, not a materialised ancestor path.** It is
|
|
48
|
+
the ltree `path` of the anchor org unit, read live from the same column
|
|
49
|
+
`OrgUnitSchema.path` carries. Clients count a subtree by prefix match
|
|
50
|
+
(`p === path || p.startsWith(`${path}.`)`). Because this and the org tree read
|
|
51
|
+
the same live column, they cannot disagree, and moving a unit needs no
|
|
52
|
+
backfill. Storing an ancestor list here would reintroduce exactly that
|
|
53
|
+
backfill. `null` means org-wide: it rolls up to the root and nowhere below.
|
|
54
|
+
- **Authority is the provider's, never this layer's.** These are shapes; nothing
|
|
55
|
+
here filters. A provider that returns an item the viewer may not act on is a
|
|
56
|
+
provider bug, and its test is the one that must catch it.
|
|
57
|
+
- Types and pure vocabulary only — no classes, no side effects, no imports
|
|
58
|
+
beyond `zod` (vocabulary-guard enforced).
|
|
59
|
+
|
|
60
|
+
## Public API
|
|
61
|
+
|
|
62
|
+
| Export | Description |
|
|
63
|
+
| --------------------------------------------------- | ------------------------------------------------------------ |
|
|
64
|
+
| `ActionItemKind` / `ACTION_ITEM_KINDS` | The closed vocabulary of outstanding-decision kinds |
|
|
65
|
+
| `ActionItem` / `ActionItemSchema` | One outstanding decision |
|
|
66
|
+
| `ActionItemTarget` / `ActionItemTargetSchema` | What the item is about — the entity a surface badges against |
|
|
67
|
+
| `ActionItemTargetType` / `ACTION_ITEM_TARGET_TYPES` | `company_md` \| `org_unit` \| `org` \| `execution` |
|
|
68
|
+
| `ActionItemListResponse` / `…Schema` | The `GET /api/me/action-items` response |
|
|
69
|
+
|
|
70
|
+
## Dependencies
|
|
71
|
+
|
|
72
|
+
`zod`, and `./kinds` from `./schemas`. Nothing else — this domain depends on
|
|
73
|
+
nothing outside itself, including `notifications/`.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# action-items/\_\_tests\_\_/
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Locks the claims `../README.md` and ADR-CONT-104 make that the compiler cannot.
|
|
6
|
+
|
|
7
|
+
- `vocabulary.test.ts` — the three things a type signature does not say:
|
|
8
|
+
1. **`ActionItemKind` and `NotificationKind` share no member.** This is the
|
|
9
|
+
load-bearing test of the whole domain. The two unions look mergeable and
|
|
10
|
+
are not: one names events that get SENT, the other names standing state.
|
|
11
|
+
If someone "unifies" them, this is what stops it.
|
|
12
|
+
2. **The response carries no field named `version`.** The app's
|
|
13
|
+
`invalidateResource` probes cached payloads for that name structurally and
|
|
14
|
+
SKIPS the invalidation when the cached value is at least as new. A field of
|
|
15
|
+
that name here would silently wedge every badge in the product, with no
|
|
16
|
+
error anywhere. Nothing else in the stack would catch it.
|
|
17
|
+
3. **A null `unitPath` parses.** An execution has no owning org unit, so it
|
|
18
|
+
must be able to say so rather than carry a fabricated anchor.
|
|
19
|
+
|
|
20
|
+
## Invariants
|
|
21
|
+
|
|
22
|
+
- These tests assert VOCABULARY, never behaviour — there is no behaviour here to
|
|
23
|
+
assert. A test that needs a provider, a fetch, or a database belongs in
|
|
24
|
+
backend's `src/action-items/__tests__/`, not here.
|
|
25
|
+
- The disjointness test reads `NOTIFICATION_DEFINITIONS` rather than restating
|
|
26
|
+
the notification kinds. A hand-copied list would drift and start passing
|
|
27
|
+
vacuously.
|
|
28
|
+
- `makeItem()` returns a well-formed item and every negative case mutates ONE
|
|
29
|
+
field of it. A negative test that hand-builds a broken object can pass for the
|
|
30
|
+
wrong reason.
|
|
31
|
+
|
|
32
|
+
## Public API
|
|
33
|
+
|
|
34
|
+
None — test-only.
|
|
35
|
+
|
|
36
|
+
## Dependencies
|
|
37
|
+
|
|
38
|
+
`vitest`, the sibling modules under test, `../../notifications` (for the real
|
|
39
|
+
kind list), and `../../resource-keys` (for the key round-trip).
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The action-item vocabulary's invariants, as tests rather than prose
|
|
3
|
+
* (ADR-CONT-104).
|
|
4
|
+
*
|
|
5
|
+
* These lock the two claims the README makes that a compiler cannot: that this
|
|
6
|
+
* is a SEPARATE vocabulary from NotificationKind, and that an action item is
|
|
7
|
+
* addressable both as a cache key and as a roll-up anchor.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect } from "vitest";
|
|
10
|
+
import { ACTION_ITEM_KINDS, type ActionItemKind } from "../kinds.js";
|
|
11
|
+
import {
|
|
12
|
+
ACTION_ITEM_TARGET_TYPES,
|
|
13
|
+
ActionItemListResponseSchema,
|
|
14
|
+
ActionItemSchema,
|
|
15
|
+
} from "../schemas.js";
|
|
16
|
+
import { NOTIFICATION_DEFINITIONS } from "../../notifications/index.js";
|
|
17
|
+
import {
|
|
18
|
+
toQueryKey,
|
|
19
|
+
fromQueryKey,
|
|
20
|
+
type ResourceKey,
|
|
21
|
+
} from "../../resource-keys.js";
|
|
22
|
+
|
|
23
|
+
const ORG_ID = "11111111-1111-4111-8111-111111111111";
|
|
24
|
+
|
|
25
|
+
function makeItem(over: Partial<Record<string, unknown>> = {}) {
|
|
26
|
+
return {
|
|
27
|
+
id: "companyMd.access_request_pending:req-1",
|
|
28
|
+
kind: "companyMd.access_request_pending" satisfies ActionItemKind,
|
|
29
|
+
target: { type: "company_md", id: "doc-1" },
|
|
30
|
+
unitPath: "acme.sales",
|
|
31
|
+
title: "Maya Chen wants access",
|
|
32
|
+
detail: "Reviewing the cross-team roadmap",
|
|
33
|
+
contextLabel: "Sales.md",
|
|
34
|
+
href: "/@acme/md/doc-1?share=1&request=req-1",
|
|
35
|
+
createdAt: "2026-07-27T00:00:00.000Z",
|
|
36
|
+
...over,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("ActionItemKind is a separate vocabulary from NotificationKind", () => {
|
|
41
|
+
// The core claim of ADR-CONT-104. If someone "unifies" the two unions,
|
|
42
|
+
// this is the test that should stop them: the overlap must stay empty, because
|
|
43
|
+
// the two name different things (a sent event vs. standing state).
|
|
44
|
+
it("shares no member with NotificationKind", () => {
|
|
45
|
+
const notificationKinds = new Set(Object.keys(NOTIFICATION_DEFINITIONS));
|
|
46
|
+
const overlap = ACTION_ITEM_KINDS.filter((k) => notificationKinds.has(k));
|
|
47
|
+
expect(overlap).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("names standing state — every kind reads as a pending condition", () => {
|
|
51
|
+
for (const kind of ACTION_ITEM_KINDS) {
|
|
52
|
+
expect(kind).toMatch(/_pending$/);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("uses the shared {domain}.{type} convention", () => {
|
|
57
|
+
for (const kind of ACTION_ITEM_KINDS) {
|
|
58
|
+
expect(kind).toMatch(/^[a-zA-Z]+\.[a-z_]+$/);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("ActionItemSchema", () => {
|
|
64
|
+
it("accepts a well-formed item", () => {
|
|
65
|
+
expect(ActionItemSchema.parse(makeItem())).toMatchObject({
|
|
66
|
+
kind: "companyMd.access_request_pending",
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("accepts a null unitPath — an org-wide item with no tree anchor", () => {
|
|
71
|
+
// Proves the shape admits targets that live outside the org tree at all
|
|
72
|
+
// (an execution has no owning unit). Those roll up to the root and nowhere
|
|
73
|
+
// below it; a required unitPath would have forced a fake anchor.
|
|
74
|
+
const parsed = ActionItemSchema.parse(
|
|
75
|
+
makeItem({
|
|
76
|
+
kind: "execution.confirmation_pending",
|
|
77
|
+
target: { type: "execution", id: "exec-1" },
|
|
78
|
+
unitPath: null,
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
expect(parsed.unitPath).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("rejects a kind outside the vocabulary", () => {
|
|
85
|
+
expect(() =>
|
|
86
|
+
ActionItemSchema.parse(makeItem({ kind: "org.invite" })),
|
|
87
|
+
).toThrow();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("rejects a target type outside the vocabulary", () => {
|
|
91
|
+
expect(() =>
|
|
92
|
+
ActionItemSchema.parse(makeItem({ target: { type: "chat", id: "c-1" } })),
|
|
93
|
+
).toThrow();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("requires an href — an item you cannot resolve is not an action item", () => {
|
|
97
|
+
const { href: _dropped, ...withoutHref } = makeItem();
|
|
98
|
+
expect(() => ActionItemSchema.parse(withoutHref)).toThrow();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("carries no field named `version`", () => {
|
|
102
|
+
// invalidateResource() probes a cached payload for `version` structurally
|
|
103
|
+
// and SKIPS the invalidation when the cached value is at least as new. A
|
|
104
|
+
// field of that name in this response would silently wedge every badge.
|
|
105
|
+
expect(Object.keys(ActionItemSchema.shape)).not.toContain("version");
|
|
106
|
+
expect(Object.keys(ActionItemListResponseSchema.shape)).not.toContain(
|
|
107
|
+
"version",
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("the actionItems resource key", () => {
|
|
113
|
+
const key: ResourceKey = { type: "actionItems", orgId: ORG_ID };
|
|
114
|
+
|
|
115
|
+
it("is org-scoped, so switching org cannot serve the previous org's bucket", () => {
|
|
116
|
+
expect(toQueryKey(key)).toEqual(["actionItems", ORG_ID]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("roundtrips through fromQueryKey", () => {
|
|
120
|
+
expect(fromQueryKey(toQueryKey(key))).toEqual(key);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("target types", () => {
|
|
125
|
+
it("covers every surface a badge can hang on", () => {
|
|
126
|
+
expect([...ACTION_ITEM_TARGET_TYPES].sort()).toEqual([
|
|
127
|
+
"company_md",
|
|
128
|
+
"execution",
|
|
129
|
+
"org",
|
|
130
|
+
"org_unit",
|
|
131
|
+
]);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* action-items/ — the vocabulary for things awaiting a user's decision.
|
|
3
|
+
*
|
|
4
|
+
* See ./README.md for the domain, and ADR-CONT-104 for why this is not
|
|
5
|
+
* `NotificationKind`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// =============================================================================
|
|
9
|
+
// Kinds — what sorts of decision can be outstanding
|
|
10
|
+
// =============================================================================
|
|
11
|
+
|
|
12
|
+
export { ACTION_ITEM_KINDS } from "./kinds";
|
|
13
|
+
export type { ActionItemKind } from "./kinds";
|
|
14
|
+
|
|
15
|
+
// =============================================================================
|
|
16
|
+
// Shapes — the published GET /api/me/action-items contract
|
|
17
|
+
// =============================================================================
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
ACTION_ITEM_TARGET_TYPES,
|
|
21
|
+
ActionItemKindSchema,
|
|
22
|
+
ActionItemListResponseSchema,
|
|
23
|
+
ActionItemSchema,
|
|
24
|
+
ActionItemTargetSchema,
|
|
25
|
+
ActionItemTargetTypeSchema,
|
|
26
|
+
} from "./schemas";
|
|
27
|
+
|
|
28
|
+
export type {
|
|
29
|
+
ActionItem,
|
|
30
|
+
ActionItemListResponse,
|
|
31
|
+
ActionItemTarget,
|
|
32
|
+
ActionItemTargetType,
|
|
33
|
+
} from "./schemas";
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What kinds of action item exist (ADR-CONT-104).
|
|
3
|
+
*
|
|
4
|
+
* An action item is a thing AWAITING A PARTICULAR USER'S DECISION. It is
|
|
5
|
+
* derived standing state, never a stored row: a provider answers "what does
|
|
6
|
+
* user U owe action on in org O?" from its domain's own pending rows, so an
|
|
7
|
+
* item resolved by someone else is simply absent from the next read.
|
|
8
|
+
*
|
|
9
|
+
* WHY THIS IS NOT `NotificationKind`:
|
|
10
|
+
*
|
|
11
|
+
* `NotificationKind` names EVENTS THAT GET SENT — `companyMd.access_requested`
|
|
12
|
+
* is the moment an email leaves the building. An action item names STANDING
|
|
13
|
+
* STATE — `companyMd.access_request_pending` is the fact that a decision is
|
|
14
|
+
* outstanding right now. The two are not in bijection and never will be:
|
|
15
|
+
*
|
|
16
|
+
* - `auth.otp` is a notification and can never be an action item.
|
|
17
|
+
* - `execution.confirmation_pending` is an action item with no notification.
|
|
18
|
+
* - One notification kind can open an item that many DIFFERENT users owe, and
|
|
19
|
+
* the first of them to act closes it for all of them. A sent notification has
|
|
20
|
+
* no such lifecycle — it is delivered once, to one address, and is then over.
|
|
21
|
+
*
|
|
22
|
+
* Collapsing the two unions would make one of those two meanings lie. Keep them
|
|
23
|
+
* separate; if a future kind needs both, it gets a member in each.
|
|
24
|
+
*
|
|
25
|
+
* Copy is likewise NOT shared with `NotificationDefinition.compose`. An email is
|
|
26
|
+
* prose carrying a brand and a year; an action-item row is a title, a detail
|
|
27
|
+
* line and the name of the thing. Bending the send-shaped render pipeline to
|
|
28
|
+
* also emit list rows would couple derived state to a delivery mechanism.
|
|
29
|
+
*
|
|
30
|
+
* INVARIANTS:
|
|
31
|
+
* - `{domain}.{type}` dot notation, matching `NotificationKind`'s convention.
|
|
32
|
+
* The `_pending` suffix is load-bearing: it is what makes the "standing state,
|
|
33
|
+
* not event" reading obvious at the call site.
|
|
34
|
+
* - These strings go on the wire and are used as the stable prefix of an item's
|
|
35
|
+
* id. Renaming one is a breaking change, not a tidy-up.
|
|
36
|
+
* - A kind earns membership by having a PROVIDER that can derive it and an
|
|
37
|
+
* `href` that RESOLVES it. A kind whose item cannot be acted on is not an
|
|
38
|
+
* action item — that is the informational feed, which this layer does not
|
|
39
|
+
* model (yet).
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
// =============================================================================
|
|
43
|
+
// ActionItemKind Union
|
|
44
|
+
// =============================================================================
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* ActionItemKind identifies what sort of decision is outstanding.
|
|
48
|
+
*
|
|
49
|
+
* New kinds MUST be added to:
|
|
50
|
+
* 1. This union
|
|
51
|
+
* 2. A provider in `backend/src/action-items/providers/`, which applies that
|
|
52
|
+
* domain's OWN authority gate
|
|
53
|
+
*
|
|
54
|
+
* `execution.confirmation_pending` earns its place by fixing a real hole rather
|
|
55
|
+
* than by symmetry: a chat execution in `pending_confirmation` is reachable
|
|
56
|
+
* ONLY from the chat stream it was proposed in, so closing the tab strands it
|
|
57
|
+
* until it expires. Its confirm/reject routes already exist
|
|
58
|
+
* (`POST /api/executions/{id}/confirm|reject`); what was missing was any way to
|
|
59
|
+
* find it. That is exactly what this layer is for.
|
|
60
|
+
*
|
|
61
|
+
* The candidates that did NOT earn a place, recorded so they are not
|
|
62
|
+
* re-litigated — every one fails a membership test above:
|
|
63
|
+
*
|
|
64
|
+
* - **A pending INVITE awaits the invitee**, who is not yet in the org. No
|
|
65
|
+
* viewer owes it a decision. The `+ N invite` counts in the people surfaces
|
|
66
|
+
* are VOLUME badges and stay where they are.
|
|
67
|
+
* - **An ownership TRANSFER is the recipient's decision** — but accepting is
|
|
68
|
+
* token-bearing (`token + session + email match + fresh login`) and only the
|
|
69
|
+
* token's hash is stored, so an in-app row could not resolve it. "Go check
|
|
70
|
+
* your email" is a dead affordance.
|
|
71
|
+
* - **`execution_approvals`** (the approval-policy chain) has no `org_id` of
|
|
72
|
+
* its own, names a ROLE rather than a user as approver, and has zero HTTP
|
|
73
|
+
* surface. Distinct from `execution.confirmation_pending` above.
|
|
74
|
+
* - **`orgReasoning.inference_review`** is semantically a clean fit and its
|
|
75
|
+
* decision route exists — with no caller and no review UI to link to.
|
|
76
|
+
*/
|
|
77
|
+
export const ACTION_ITEM_KINDS = [
|
|
78
|
+
"companyMd.access_request_pending",
|
|
79
|
+
"execution.confirmation_pending",
|
|
80
|
+
] as const;
|
|
81
|
+
|
|
82
|
+
export type ActionItemKind = (typeof ACTION_ITEM_KINDS)[number];
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action-item DTOs (ADR-CONT-104).
|
|
3
|
+
*
|
|
4
|
+
* The published shape of `GET /api/me/action-items` — everything awaiting the
|
|
5
|
+
* viewer's decision across every domain, in one read.
|
|
6
|
+
*
|
|
7
|
+
* Zod-canonical: schema is the source of truth, the type is inferred.
|
|
8
|
+
*/
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { ACTION_ITEM_KINDS } from "./kinds";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* What an item is ABOUT — the entity a surface badges against.
|
|
14
|
+
*
|
|
15
|
+
* A small union of its own rather than the app-local `SHAREABLE_ENTITY_TYPES`
|
|
16
|
+
* (which contracts does not publish) or `ResourceKey` (a cache vocabulary whose
|
|
17
|
+
* company.md variant carries the doc id under a field named `slug`). Promoting
|
|
18
|
+
* either is its own change; this stays sized to what badges exist.
|
|
19
|
+
*/
|
|
20
|
+
export const ACTION_ITEM_TARGET_TYPES = [
|
|
21
|
+
"company_md",
|
|
22
|
+
"org_unit",
|
|
23
|
+
"org",
|
|
24
|
+
// Not an ACL entity — an execution is addressed by id and has no owning unit,
|
|
25
|
+
// so its items anchor at `unitPath: null` and roll up only to the root. That
|
|
26
|
+
// asymmetry is deliberate and is what proves the roll-up handles targets with
|
|
27
|
+
// no place in the org tree.
|
|
28
|
+
"execution",
|
|
29
|
+
] as const;
|
|
30
|
+
export const ActionItemTargetTypeSchema = z.enum(ACTION_ITEM_TARGET_TYPES);
|
|
31
|
+
export type ActionItemTargetType = z.infer<typeof ActionItemTargetTypeSchema>;
|
|
32
|
+
|
|
33
|
+
export const ActionItemTargetSchema = z.object({
|
|
34
|
+
type: ActionItemTargetTypeSchema,
|
|
35
|
+
/** The entity's stable id. Docs are id-addressed (ADR-BE-315), never slug. */
|
|
36
|
+
id: z.string(),
|
|
37
|
+
});
|
|
38
|
+
export type ActionItemTarget = z.infer<typeof ActionItemTargetSchema>;
|
|
39
|
+
|
|
40
|
+
/** Zod mirror of the vocabulary. `./kinds` owns the list; this never restates it. */
|
|
41
|
+
export const ActionItemKindSchema = z.enum(ACTION_ITEM_KINDS);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* One outstanding decision.
|
|
45
|
+
*
|
|
46
|
+
* Derived, never stored — see `./kinds`. Two consequences worth stating at the
|
|
47
|
+
* shape rather than in prose elsewhere:
|
|
48
|
+
*
|
|
49
|
+
* - `id` is `${kind}:${sourceRowId}`, stable across reads because it is a
|
|
50
|
+
* function of the source row. It is NOT a row id of its own; there is no row.
|
|
51
|
+
* - `unitPath` is the ltree `path` of the item's anchor org unit, read live from
|
|
52
|
+
* the same column `OrgUnitSchema.path` carries. It is a ROLL-UP ANCHOR, not a
|
|
53
|
+
* materialised ancestor list: the client counts a subtree by prefix-matching
|
|
54
|
+
* (`p === path || p.startsWith(`${path}.`)`), and because both this and the
|
|
55
|
+
* org tree are read from the same live column, they cannot disagree. Moving a
|
|
56
|
+
* unit needs no backfill. `null` means org-wide — it rolls up to the root and
|
|
57
|
+
* nowhere below it.
|
|
58
|
+
*/
|
|
59
|
+
export const ActionItemSchema = z.object({
|
|
60
|
+
/** `${kind}:${sourceRowId}` — a function of the source row, not a row id. */
|
|
61
|
+
id: z.string(),
|
|
62
|
+
kind: ActionItemKindSchema,
|
|
63
|
+
target: ActionItemTargetSchema,
|
|
64
|
+
/** ltree anchor for subtree roll-up; `null` = org-wide. */
|
|
65
|
+
unitPath: z.string().nullable(),
|
|
66
|
+
/** One line naming the decision, e.g. "Maya Chen wants access". */
|
|
67
|
+
title: z.string(),
|
|
68
|
+
/** Optional second line — the requester's message, a reason, a deadline. */
|
|
69
|
+
detail: z.string().nullable(),
|
|
70
|
+
/** The thing it is about, as the user names it, e.g. "Sales.md". */
|
|
71
|
+
contextLabel: z.string(),
|
|
72
|
+
/**
|
|
73
|
+
* Where to go to RESOLVE it. An action item whose href only *shows* the thing
|
|
74
|
+
* is a notification, not an action item — see `./kinds`.
|
|
75
|
+
*/
|
|
76
|
+
href: z.string(),
|
|
77
|
+
createdAt: z.string(),
|
|
78
|
+
});
|
|
79
|
+
export type ActionItem = z.infer<typeof ActionItemSchema>;
|
|
80
|
+
|
|
81
|
+
/** `GET /api/me/action-items` — everything awaiting this viewer, newest first. */
|
|
82
|
+
export const ActionItemListResponseSchema = z.object({
|
|
83
|
+
items: z.array(ActionItemSchema),
|
|
84
|
+
});
|
|
85
|
+
export type ActionItemListResponse = z.infer<
|
|
86
|
+
typeof ActionItemListResponseSchema
|
|
87
|
+
>;
|
package/src/index.ts
CHANGED
|
@@ -240,6 +240,32 @@ export {
|
|
|
240
240
|
isValidNotificationKind,
|
|
241
241
|
} from "./notifications/index";
|
|
242
242
|
|
|
243
|
+
// Action-item domain types — things awaiting a user's DECISION, derived from
|
|
244
|
+
// each domain's own pending rows rather than stored. Deliberately a separate
|
|
245
|
+
// vocabulary from NotificationKind above: that one names events that get SENT,
|
|
246
|
+
// this one names standing state. See src/action-items/README.md.
|
|
247
|
+
// @see ADR-CONT-104
|
|
248
|
+
export {
|
|
249
|
+
ACTION_ITEM_KINDS,
|
|
250
|
+
ACTION_ITEM_TARGET_TYPES,
|
|
251
|
+
} from "./action-items/index";
|
|
252
|
+
|
|
253
|
+
export {
|
|
254
|
+
ActionItemKindSchema,
|
|
255
|
+
ActionItemListResponseSchema,
|
|
256
|
+
ActionItemSchema,
|
|
257
|
+
ActionItemTargetSchema,
|
|
258
|
+
ActionItemTargetTypeSchema,
|
|
259
|
+
} from "./action-items/index";
|
|
260
|
+
|
|
261
|
+
export type {
|
|
262
|
+
ActionItem,
|
|
263
|
+
ActionItemKind,
|
|
264
|
+
ActionItemListResponse,
|
|
265
|
+
ActionItemTarget,
|
|
266
|
+
ActionItemTargetType,
|
|
267
|
+
} from "./action-items/index";
|
|
268
|
+
|
|
243
269
|
// Chat domain types
|
|
244
270
|
// @see PRD-00142 for share chat design rationale
|
|
245
271
|
export type {
|
package/src/resource-keys.ts
CHANGED
|
@@ -55,6 +55,13 @@ export type ResourceKey =
|
|
|
55
55
|
// dashboard reads over Global-infra factory tables; tenant-less, scope 'system'.
|
|
56
56
|
| { type: "factoryFloor"; scope: "system" }
|
|
57
57
|
| { type: "factorySnapshot"; scope: "system" }
|
|
58
|
+
// Everything awaiting the viewer's decision, across every domain
|
|
59
|
+
// (ADR-CONT-104). ORG-scoped, not user-scoped, even though the content is
|
|
60
|
+
// per-viewer: the bucket differs per org, so a user-keyed entry would serve
|
|
61
|
+
// one org's items after switching to another. Same shape as `chats` and
|
|
62
|
+
// `timeline`, which are likewise viewer-filtered but org-partitioned — and it
|
|
63
|
+
// inherits `resolveScope`'s impersonation handling for free.
|
|
64
|
+
| { type: "actionItems"; orgId: string }
|
|
58
65
|
// User-scoped
|
|
59
66
|
| { type: "dismissedBanners"; userId: string }
|
|
60
67
|
| { type: "userOrgs"; userId: string }
|
|
@@ -98,6 +105,7 @@ const ORG_SCOPED_TYPES = [
|
|
|
98
105
|
"orgLevelConfig",
|
|
99
106
|
"peopleOrgChart",
|
|
100
107
|
"orgUnitOwners",
|
|
108
|
+
"actionItems",
|
|
101
109
|
] as const;
|
|
102
110
|
|
|
103
111
|
const USER_SCOPED_TYPES = [
|
|
@@ -181,6 +189,7 @@ export function toQueryKey(key: ResourceKey): readonly string[] {
|
|
|
181
189
|
case "orgLevelConfig":
|
|
182
190
|
case "peopleOrgChart":
|
|
183
191
|
case "orgUnitOwners":
|
|
192
|
+
case "actionItems":
|
|
184
193
|
return [key.type, key.orgId] as const;
|
|
185
194
|
|
|
186
195
|
// System-scoped (ADR-CONTRACTS-052) — tenant-less super-admin resources
|