@cosmicdrift/kumiko-samples 0.398.0 → 0.400.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/packages/bundled-features/package.json +6 -1
- package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-text.ts +4 -6
- package/packages/bundled-features/src/channel-in-app/changes.json +6 -0
- package/packages/bundled-features/src/channel-in-app/constants.ts +9 -9
- package/packages/bundled-features/src/channel-in-app/feature.ts +1 -1
- package/packages/bundled-features/src/channel-in-app/handlers/inbox.query.ts +31 -7
- package/packages/bundled-features/src/channel-in-app/handlers/unread-count.query.ts +6 -9
- package/packages/bundled-features/src/channel-in-app/in-app-channel.ts +25 -11
- package/packages/bundled-features/src/channel-in-app/in-app-target.ts +37 -0
- package/packages/bundled-features/src/channel-in-app/index.ts +1 -0
- package/packages/bundled-features/src/documents/aggregate-id.ts +20 -0
- package/packages/bundled-features/src/documents/capture.ts +125 -0
- package/packages/bundled-features/src/documents/constants.ts +31 -0
- package/packages/bundled-features/src/documents/ensure-link.ts +35 -0
- package/packages/bundled-features/src/documents/entity.ts +103 -0
- package/packages/bundled-features/src/documents/executor.ts +13 -0
- package/packages/bundled-features/src/documents/expiry.ts +32 -0
- package/packages/bundled-features/src/documents/feature.ts +197 -0
- package/packages/bundled-features/src/documents/handlers/import.write.ts +157 -0
- package/packages/bundled-features/src/documents/handlers/link.write.ts +103 -0
- package/packages/bundled-features/src/documents/i18n.ts +35 -0
- package/packages/bundled-features/src/documents/index.ts +22 -0
- package/packages/bundled-features/src/documents/pending-file-guard.ts +36 -0
- package/packages/bundled-features/src/documents/queries/expiry-overview.query.ts +46 -0
- package/packages/bundled-features/src/documents/queries/for-record.query.ts +74 -0
- package/packages/bundled-features/src/documents/queries/list-page.ts +51 -0
- package/packages/bundled-features/src/documents/queries/overview.query.ts +45 -0
- package/packages/bundled-features/src/documents/schemas.ts +66 -0
- package/packages/bundled-features/src/documents/screens.ts +86 -0
- package/packages/bundled-features/src/documents/tab.ts +46 -0
- package/packages/bundled-features/src/documents/target-write-check.ts +41 -0
- package/packages/bundled-features/src/documents/write-guard.ts +59 -0
- package/packages/bundled-features/src/metrics/changes.json +6 -0
- package/packages/bundled-features/src/metrics/compute.ts +19 -2
- package/packages/bundled-features/src/metrics/feature.ts +14 -1
- package/packages/bundled-features/src/metrics/types.ts +3 -1
- package/packages/bundled-features/src/metrics/validate.ts +3 -0
- package/packages/bundled-features/src/shared/purge-row-file-refs.ts +5 -4
- package/samples/apps/styleguide/src/app/seed.ts +102 -1
- package/samples/apps/styleguide/src/app/server.ts +2 -2
- package/samples/apps/styleguide/src/features/examples/feature.ts +97 -0
- package/samples/apps/styleguide/src/features/examples/i18n.ts +47 -0
- package/samples/apps/use-all-bundled/feature-manifest.json +31 -2
- package/samples/apps/use-all-bundled/kumiko/migrations/0058_documents.rebuild.json +4 -0
- package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +11 -2
- package/samples/apps/use-all-bundled/src/run-config.ts +7 -0
- package/samples/recipes/derived-fields-sql/README.md +27 -0
- package/samples/recipes/derived-fields-sql/package.json +11 -0
- package/samples/recipes/derived-fields-sql/src/feature.ts +98 -0
- package/samples/recipes/documents/package.json +12 -0
- package/samples/recipes/documents/src/feature.ts +66 -0
- package/samples/recipes/inspection-checklist/README.md +27 -0
- package/samples/recipes/inspection-checklist/package.json +12 -0
- package/samples/recipes/inspection-checklist/src/entities/inspection-plan.ts +24 -0
- package/samples/recipes/inspection-checklist/src/entities/inspection-protocol.ts +72 -0
- package/samples/recipes/inspection-checklist/src/feature.ts +33 -0
- package/samples/recipes/inspection-checklist/src/handlers/create-from-plan.write.ts +38 -0
- package/samples/recipes/inspection-checklist/src/screens/inspection-protocol-edit.screen.ts +27 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-samples",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.400.0",
|
|
4
4
|
"description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.400.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -116,6 +116,7 @@
|
|
|
116
116
|
"./shared/row-bound-grant": "./src/shared/row-bound-grant.ts",
|
|
117
117
|
"./shared/mail-template-body": "./src/shared/mail-template-body.ts",
|
|
118
118
|
"./delivery": "./src/delivery/index.ts",
|
|
119
|
+
"./documents": "./src/documents/index.ts",
|
|
119
120
|
"./delivery/web": "./src/delivery/web/index.ts",
|
|
120
121
|
"./channel-in-app": "./src/channel-in-app/index.ts",
|
|
121
122
|
"./channel-email": "./src/channel-email/index.ts",
|
|
@@ -570,6 +571,10 @@
|
|
|
570
571
|
"types": "./dist/delivery/index.d.ts",
|
|
571
572
|
"default": "./dist/delivery/index.js"
|
|
572
573
|
},
|
|
574
|
+
"./documents": {
|
|
575
|
+
"types": "./dist/documents/index.d.ts",
|
|
576
|
+
"default": "./dist/documents/index.js"
|
|
577
|
+
},
|
|
573
578
|
"./delivery/web": {
|
|
574
579
|
"types": "./dist/delivery/web/index.d.ts",
|
|
575
580
|
"default": "./dist/delivery/web/index.js"
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.399.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Shorter documents checkbox text in the consent box",
|
|
6
|
+
"detail": "With `consumerProtection.documentsAcknowledgement: true` the checkbox now reads \"I have read and agree to the Terms and the Refund Policy.\" (de: \"Ich habe die AGB und die Erstattungsrichtlinie gelesen und bin mit ihnen einverstanden.\"), or without a refund link only the terms. The privacy policy stays a link. The text is part of `consentTextVersion`, so apps with the option on get a new version hash; consents recorded earlier keep their stored version."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.398.0",
|
|
4
10
|
"type": "improvement",
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-text.ts
CHANGED
|
@@ -45,14 +45,12 @@ const DOCUMENTS_ACKNOWLEDGEMENT_TEXTS: Readonly<
|
|
|
45
45
|
> = {
|
|
46
46
|
de: {
|
|
47
47
|
withRefund:
|
|
48
|
-
"Ich habe die AGB
|
|
49
|
-
withoutRefund:
|
|
50
|
-
"Ich habe die AGB und die Datenschutzerklärung gelesen. Mit den AGB bin ich einverstanden.",
|
|
48
|
+
"Ich habe die AGB und die Erstattungsrichtlinie gelesen und bin mit ihnen einverstanden.",
|
|
49
|
+
withoutRefund: "Ich habe die AGB gelesen und bin mit ihnen einverstanden.",
|
|
51
50
|
},
|
|
52
51
|
en: {
|
|
53
|
-
withRefund:
|
|
54
|
-
|
|
55
|
-
withoutRefund: "I have read the Terms and the Privacy Policy, and I agree to the Terms.",
|
|
52
|
+
withRefund: "I have read and agree to the Terms and the Refund Policy.",
|
|
53
|
+
withoutRefund: "I have read and agree to the Terms.",
|
|
56
54
|
},
|
|
57
55
|
};
|
|
58
56
|
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.399.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Inbox cursor, total and navigation target for the shell bell",
|
|
6
|
+
"detail": "`channel-in-app:query:inbox` accepts a `cursor` and answers `{ rows, nextCursor, total }`; each row carries `target` (`{ screenId, entityId? }` or null) parsed from `data.target`. Build the target with `inAppTarget(screenId, entityId?)`; a malformed target is dropped and the notification is still delivered. The SSE frame `channel-in-app:event:delivered` now carries only `{ id, userId, notificationType }`, and a failing push no longer fails the delivery because the row is already stored. The shared contract lives in `@cosmicdrift/kumiko-types/in-app-notification-types`."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.354.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
import {
|
|
2
|
+
IN_APP_NOTIFICATION_FEATURE,
|
|
3
|
+
IN_APP_NOTIFICATION_QUERIES,
|
|
4
|
+
IN_APP_NOTIFICATION_WRITES,
|
|
5
|
+
} from "@cosmicdrift/kumiko-types/in-app-notification-types";
|
|
2
6
|
|
|
3
|
-
export const
|
|
4
|
-
markRead: "channel-in-app:write:mark-read",
|
|
5
|
-
markAllRead: "channel-in-app:write:mark-all-read",
|
|
6
|
-
} as const;
|
|
7
|
+
export const CHANNEL_IN_APP_FEATURE = IN_APP_NOTIFICATION_FEATURE;
|
|
7
8
|
|
|
8
|
-
export const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
} as const;
|
|
9
|
+
export const InAppHandlers = IN_APP_NOTIFICATION_WRITES;
|
|
10
|
+
|
|
11
|
+
export const InAppQueries = IN_APP_NOTIFICATION_QUERIES;
|
|
@@ -10,7 +10,7 @@ import { inAppMessagesTableMeta } from "./tables.ts";
|
|
|
10
10
|
export function createChannelInAppFeature(): FeatureDefinition {
|
|
11
11
|
return defineFeature("channel-in-app", (r) => {
|
|
12
12
|
r.describe(
|
|
13
|
-
"Persists notifications to an in-app inbox table so users can
|
|
13
|
+
"Persists notifications to an in-app inbox table so users can page through them via `queries.inbox` (cursor, total, parsed `target`) and track unread state with `handlers.markRead` / `handlers.markAllRead` and `queries.unreadCount`. A notification links to a screen when `data.target` is set with `inAppTarget(screenId, entityId?)`. The renderer shell shows a bell with a live unread badge whenever this feature is mounted. Requires `delivery`; no external service needed \u2014 messages are stored in the app's own database.",
|
|
14
14
|
);
|
|
15
15
|
r.uiHints({
|
|
16
16
|
displayLabel: "In-App Inbox",
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import * as z from "zod";
|
|
4
|
+
import { parseStoredNotificationData, targetOfNotificationData } from "../in-app-target.ts";
|
|
4
5
|
import { inAppMessagesTable } from "../tables.ts";
|
|
5
6
|
|
|
7
|
+
const SERIAL_ID_PATTERN = /^\d{1,15}$/;
|
|
8
|
+
|
|
6
9
|
export const inboxQuery = defineQueryHandler({
|
|
7
10
|
name: "inbox",
|
|
8
11
|
schema: z.object({
|
|
9
12
|
limit: z.number().min(1).max(100).default(50),
|
|
10
13
|
unreadOnly: z.boolean().default(false),
|
|
14
|
+
cursor: z.string().regex(SERIAL_ID_PATTERN).optional(),
|
|
11
15
|
}),
|
|
12
16
|
access: {
|
|
13
17
|
openToAll: {
|
|
@@ -17,14 +21,34 @@ export const inboxQuery = defineQueryHandler({
|
|
|
17
21
|
},
|
|
18
22
|
},
|
|
19
23
|
description:
|
|
20
|
-
"Lists the calling user's own in-app notification messages newest-first, optionally only the unread ones;
|
|
24
|
+
"Lists the calling user's own in-app notification messages newest-first with a parsed navigation target, optionally only the unread ones. Pass the returned nextCursor as cursor for the next page; total counts all matching messages, not just the page. Use it to show or inspect a user's notification inbox.",
|
|
21
25
|
handler: async (query, ctx) => {
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
const { limit, unreadOnly, cursor } = query.payload;
|
|
27
|
+
const ownMessages: Record<string, unknown> = { userId: query.user.id };
|
|
28
|
+
if (unreadOnly) ownMessages["isRead"] = false;
|
|
29
|
+
|
|
30
|
+
const pageWhere =
|
|
31
|
+
cursor === undefined ? ownMessages : { ...ownMessages, id: { lt: Number(cursor) } };
|
|
32
|
+
// One extra row tells whether another page exists without a second query.
|
|
33
|
+
const fetched = await selectMany(ctx.db, inAppMessagesTable, pageWhere, {
|
|
34
|
+
limit: limit + 1,
|
|
35
|
+
orderBy: { col: "id", direction: "desc" },
|
|
36
|
+
});
|
|
37
|
+
const page = fetched.slice(0, limit);
|
|
38
|
+
const lastRow = page[page.length - 1];
|
|
39
|
+
const nextCursor = fetched.length > limit && lastRow ? String(lastRow["id"]) : null;
|
|
40
|
+
|
|
41
|
+
const [totalRow] = await ctx.db.aggregate(
|
|
42
|
+
inAppMessagesTable,
|
|
43
|
+
{ measure: { fn: "count" } },
|
|
44
|
+
ownMessages,
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const rows = page.map((row) => {
|
|
48
|
+
const storedData = row["data"];
|
|
49
|
+
const data = parseStoredNotificationData(typeof storedData === "string" ? storedData : null);
|
|
50
|
+
return { ...row, target: targetOfNotificationData(data) };
|
|
27
51
|
});
|
|
28
|
-
return { rows };
|
|
52
|
+
return { rows, nextCursor, total: totalRow?.value ?? 0 };
|
|
29
53
|
},
|
|
30
54
|
});
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
2
1
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
2
|
import * as z from "zod";
|
|
4
3
|
import { inAppMessagesTable } from "../tables.ts";
|
|
@@ -16,13 +15,11 @@ export const unreadCountQuery = defineQueryHandler({
|
|
|
16
15
|
description:
|
|
17
16
|
"Counts the calling user's unread in-app notification messages; use it for an inbox badge or to check whether a user has pending notifications.",
|
|
18
17
|
handler: async (query, ctx) => {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
});
|
|
26
|
-
return { count: rows.length };
|
|
18
|
+
const [countRow] = await ctx.db.aggregate(
|
|
19
|
+
inAppMessagesTable,
|
|
20
|
+
{ measure: { fn: "count" } },
|
|
21
|
+
{ userId: query.user.id, isRead: false },
|
|
22
|
+
);
|
|
23
|
+
return { count: countRow?.value ?? 0 };
|
|
27
24
|
},
|
|
28
25
|
});
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { tenantChannel } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
3
|
+
import { IN_APP_NOTIFICATION_DELIVERED_EVENT } from "@cosmicdrift/kumiko-types/in-app-notification-types";
|
|
2
4
|
import type { DeliveryChannel } from "../delivery/index.ts";
|
|
5
|
+
import { targetOfNotificationData } from "./in-app-target.ts";
|
|
3
6
|
import { inAppMessagesTable } from "./tables.ts";
|
|
4
7
|
|
|
8
|
+
const log = createFallbackLogger("channel-in-app");
|
|
9
|
+
|
|
5
10
|
export const inAppChannel: DeliveryChannel = {
|
|
6
11
|
name: "inApp",
|
|
7
12
|
mode: "inline",
|
|
@@ -15,25 +20,34 @@ export const inAppChannel: DeliveryChannel = {
|
|
|
15
20
|
// address is the user-id string after the ES migration — keep it as-is.
|
|
16
21
|
const userId = address;
|
|
17
22
|
|
|
23
|
+
// A malformed target drops only the link, never the notification.
|
|
24
|
+
const target = targetOfNotificationData(message.data);
|
|
25
|
+
const storedData =
|
|
26
|
+
message.data === undefined
|
|
27
|
+
? null
|
|
28
|
+
: JSON.stringify({ ...message.data, target: target ?? undefined });
|
|
29
|
+
|
|
18
30
|
const row = await ctx.db.insertOne<{ id: string }>(inAppMessagesTable, {
|
|
19
31
|
userId,
|
|
20
32
|
notificationType: message.notificationType,
|
|
21
33
|
title: message.title,
|
|
22
34
|
body: message.body ?? null,
|
|
23
|
-
data:
|
|
35
|
+
data: storedData,
|
|
24
36
|
});
|
|
25
37
|
|
|
26
38
|
if (ctx.sseBroker) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
notificationType: message.notificationType,
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
// The row is stored, so a failing push (e.g. Redis down) must not turn the delivery into
|
|
40
|
+
// a failure that gets retried and stored twice. Clients fetch the inbox on reconnect.
|
|
41
|
+
try {
|
|
42
|
+
await ctx.sseBroker.pushToChannel(tenantChannel(ctx.tenantId), {
|
|
43
|
+
type: IN_APP_NOTIFICATION_DELIVERED_EVENT,
|
|
44
|
+
data: { id: row?.id, userId, notificationType: message.notificationType },
|
|
45
|
+
});
|
|
46
|
+
} catch (error) {
|
|
47
|
+
log.warn("in-app SSE push failed; the notification is stored", {
|
|
48
|
+
error: error instanceof Error ? error.message : String(error),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
37
51
|
}
|
|
38
52
|
|
|
39
53
|
return { status: "sent", address };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { InAppNotificationTarget } from "@cosmicdrift/kumiko-types/in-app-notification-types";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
|
|
4
|
+
const SCREEN_ID_PATTERN = /^[^\s:]+:screen:[^\s:]+$/;
|
|
5
|
+
|
|
6
|
+
const targetSchema = z.object({
|
|
7
|
+
screenId: z.string().regex(SCREEN_ID_PATTERN),
|
|
8
|
+
entityId: z.string().min(1).optional(),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export function inAppTarget(screenId: string, entityId?: string): InAppNotificationTarget {
|
|
12
|
+
return entityId === undefined ? { screenId } : { screenId, entityId };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Returns null for anything that is not a well-formed target, so a malformed `data.target`
|
|
16
|
+
// drops only the link and never the notification.
|
|
17
|
+
export function parseInAppTarget(value: unknown): InAppNotificationTarget | null {
|
|
18
|
+
const parsed = targetSchema.safeParse(value);
|
|
19
|
+
if (!parsed.success) return null;
|
|
20
|
+
const { screenId, entityId } = parsed.data;
|
|
21
|
+
return inAppTarget(screenId, entityId);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Reads `target` out of the stored notification data (a JSON string) or out of a raw data object.
|
|
25
|
+
export function targetOfNotificationData(data: unknown): InAppNotificationTarget | null {
|
|
26
|
+
if (typeof data !== "object" || data === null || !("target" in data)) return null;
|
|
27
|
+
return parseInAppTarget(data.target);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function parseStoredNotificationData(stored: string | null): unknown {
|
|
31
|
+
if (stored === null) return null;
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(stored);
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { CHANNEL_IN_APP_FEATURE, InAppHandlers, InAppQueries } from "./constants.ts";
|
|
2
2
|
export { createChannelInAppFeature } from "./feature.ts";
|
|
3
3
|
export { inAppChannel } from "./in-app-channel.ts";
|
|
4
|
+
export { inAppTarget } from "./in-app-target.ts";
|
|
4
5
|
export { inAppMessagesTable } from "./tables.ts";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { v5 as uuidv5 } from "uuid";
|
|
2
|
+
|
|
3
|
+
// Frozen: changing a namespace re-keys every derived stream and breaks replay.
|
|
4
|
+
const DOCUMENT_FOR_FILE_NAMESPACE = "6d2f7e41-3c58-4b0a-9e17-5a4c8d1b2f93";
|
|
5
|
+
const DOCUMENT_LINK_NAMESPACE = "b83a1c6e-9f20-4d57-a1e4-7c0d52f68b19";
|
|
6
|
+
|
|
7
|
+
// @wrapper-known uuid-domain
|
|
8
|
+
export function documentIdForFile(tenantId: string, fileRefId: string): string {
|
|
9
|
+
return uuidv5(`${tenantId}|${fileRefId}`, DOCUMENT_FOR_FILE_NAMESPACE);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// @wrapper-known uuid-domain
|
|
13
|
+
export function documentLinkAggregateId(
|
|
14
|
+
tenantId: string,
|
|
15
|
+
documentId: string,
|
|
16
|
+
entityType: string,
|
|
17
|
+
entityId: string,
|
|
18
|
+
): string {
|
|
19
|
+
return uuidv5(`${tenantId}|${documentId}|${entityType}|${entityId}`, DOCUMENT_LINK_NAMESPACE);
|
|
20
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
createSystemUser,
|
|
4
|
+
type FeatureRegistrar,
|
|
5
|
+
type SessionUser,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import type { BootCheckContext } from "@cosmicdrift/kumiko-types/feature";
|
|
8
|
+
import { documentIdForFile } from "./aggregate-id.ts";
|
|
9
|
+
import type { DOCUMENTS_FEATURE_NAME } from "./constants.ts";
|
|
10
|
+
import { ensureDocumentLink, type LinkTarget } from "./ensure-link.ts";
|
|
11
|
+
import type { DocumentExecutors } from "./executor.ts";
|
|
12
|
+
|
|
13
|
+
// Files saved into `entity.field` become documents of `category`, linked to the saved record
|
|
14
|
+
// itself (when it is a link target) and to `links(row)`.
|
|
15
|
+
// `links()` derives targets from the saved row, whose reference values the save handler has
|
|
16
|
+
// already checked, so its output is trusted app config and needs no write check of its own.
|
|
17
|
+
export type DocumentCaptureRule = {
|
|
18
|
+
readonly entity: string;
|
|
19
|
+
readonly field: string;
|
|
20
|
+
readonly category: string;
|
|
21
|
+
readonly title?: (row: Readonly<Record<string, unknown>>) => string;
|
|
22
|
+
readonly links?: (row: Readonly<Record<string, unknown>>) => readonly LinkTarget[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const MAX_TITLE_LENGTH = 200;
|
|
26
|
+
|
|
27
|
+
async function captureFile(
|
|
28
|
+
executors: DocumentExecutors,
|
|
29
|
+
rule: DocumentCaptureRule,
|
|
30
|
+
linkTargets: readonly string[],
|
|
31
|
+
saved: { readonly id: string; readonly data: Readonly<Record<string, unknown>> },
|
|
32
|
+
fileRefId: string,
|
|
33
|
+
user: SessionUser,
|
|
34
|
+
db: TenantDb,
|
|
35
|
+
): Promise<void> {
|
|
36
|
+
const id = documentIdForFile(user.tenantId, fileRefId);
|
|
37
|
+
// The title never defaults to the file name, which can carry personal data.
|
|
38
|
+
const title = (rule.title?.(saved.data) ?? rule.category).slice(0, MAX_TITLE_LENGTH);
|
|
39
|
+
const created = await executors.document.create(
|
|
40
|
+
{ id, title, category: rule.category, file: fileRefId },
|
|
41
|
+
user,
|
|
42
|
+
db,
|
|
43
|
+
);
|
|
44
|
+
// version_conflict: the stream exists already, so the file was captured before (and a document
|
|
45
|
+
// deleted since stays deleted).
|
|
46
|
+
if (!created.isSuccess && created.error.code !== "version_conflict") {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`documents capture failed for ${rule.entity}.${rule.field}: ${created.error.code}`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
// The gate hides rows from a caller without the registry; existence is the system user's question.
|
|
52
|
+
const live = await executors.document.detail({ id }, createSystemUser(user.tenantId), db);
|
|
53
|
+
// skip: the document was deleted after an earlier capture and stays deleted
|
|
54
|
+
if (live === null) return;
|
|
55
|
+
|
|
56
|
+
const targets: LinkTarget[] = [
|
|
57
|
+
{ entityType: rule.entity, entityId: saved.id },
|
|
58
|
+
// Trusted: derived from the saved row, see DocumentCaptureRule.links.
|
|
59
|
+
...(rule.links?.(saved.data) ?? []),
|
|
60
|
+
].filter((target) => linkTargets.includes(target.entityType));
|
|
61
|
+
for (const target of targets) {
|
|
62
|
+
const linked = await ensureDocumentLink(executors, user, db, id, target);
|
|
63
|
+
if (!linked.isSuccess) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`documents capture failed to link ${target.entityType}: ${linked.error.code}`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function registerCapture(
|
|
72
|
+
r: FeatureRegistrar<typeof DOCUMENTS_FEATURE_NAME>,
|
|
73
|
+
executors: DocumentExecutors,
|
|
74
|
+
rules: readonly DocumentCaptureRule[],
|
|
75
|
+
linkTargets: readonly string[],
|
|
76
|
+
): void {
|
|
77
|
+
for (const rule of rules) {
|
|
78
|
+
r.hook({
|
|
79
|
+
type: "postSave",
|
|
80
|
+
target: { allOf: rule.entity },
|
|
81
|
+
handler: async (result, ctx) => {
|
|
82
|
+
const value =
|
|
83
|
+
result.changes[rule.field] ?? (result.isNew ? result.data[rule.field] : undefined);
|
|
84
|
+
// skip: the file field was not part of this save
|
|
85
|
+
if (typeof value !== "string") return;
|
|
86
|
+
await captureFile(
|
|
87
|
+
executors,
|
|
88
|
+
rule,
|
|
89
|
+
linkTargets,
|
|
90
|
+
{ id: String(result.id), data: result.data },
|
|
91
|
+
value,
|
|
92
|
+
ctx.user,
|
|
93
|
+
ctx.db,
|
|
94
|
+
);
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function validateCaptureRules(
|
|
101
|
+
{ features }: BootCheckContext,
|
|
102
|
+
rules: readonly DocumentCaptureRule[],
|
|
103
|
+
categories: readonly string[],
|
|
104
|
+
linkTargets: readonly string[],
|
|
105
|
+
): void {
|
|
106
|
+
for (const rule of rules) {
|
|
107
|
+
const label = `documents capture ${rule.entity}.${rule.field}`;
|
|
108
|
+
if (!categories.includes(rule.category)) {
|
|
109
|
+
throw new Error(`${label}: category "${rule.category}" is not in the configured categories.`);
|
|
110
|
+
}
|
|
111
|
+
const entity = features.map((f) => f.entities?.[rule.entity]).find((e) => e !== undefined);
|
|
112
|
+
if (entity === undefined)
|
|
113
|
+
throw new Error(`${label}: entity "${rule.entity}" is not registered.`);
|
|
114
|
+
const field = entity.fields[rule.field];
|
|
115
|
+
if (field?.type !== "file" && field?.type !== "image") {
|
|
116
|
+
throw new Error(`${label}: the field must be a single file or image field.`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const unknownTarget = linkTargets.find(
|
|
120
|
+
(name) => !features.some((f) => f.entities?.[name] !== undefined),
|
|
121
|
+
);
|
|
122
|
+
if (unknownTarget !== undefined) {
|
|
123
|
+
throw new Error(`documents linkTargets: entity "${unknownTarget}" is not registered.`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { AccessRule } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
|
|
3
|
+
export const DOCUMENTS_FEATURE_NAME = "documents";
|
|
4
|
+
|
|
5
|
+
export const DOCUMENT_ENTITY = "filed-document";
|
|
6
|
+
export const DOCUMENT_LINK_ENTITY = "filed-document-link";
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_DOCUMENT_ACCESS: AccessRule = { roles: ["TenantAdmin", "TenantMember"] };
|
|
9
|
+
export const DEFAULT_ADMIN_ROLES: readonly string[] = ["TenantAdmin", "SystemAdmin"];
|
|
10
|
+
export const DEFAULT_EXPIRING_WITHIN_DAYS = 30;
|
|
11
|
+
|
|
12
|
+
export const DOCUMENTS_LIST_SCREEN_ID = "list";
|
|
13
|
+
|
|
14
|
+
export const DocumentsHandlers = {
|
|
15
|
+
link: "documents:write:link",
|
|
16
|
+
unlink: "documents:write:unlink",
|
|
17
|
+
import: "documents:write:import",
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
export const DocumentsQueries = {
|
|
21
|
+
list: "documents:query:filed-document:list",
|
|
22
|
+
detail: "documents:query:filed-document:detail",
|
|
23
|
+
forRecord: "documents:query:for-record",
|
|
24
|
+
expiryOverview: "documents:query:expiry-overview",
|
|
25
|
+
overview: "documents:query:overview",
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
export const EXPIRY_STATES = ["expired", "expiring", "valid", "none"] as const;
|
|
29
|
+
export type ExpiryState = (typeof EXPIRY_STATES)[number];
|
|
30
|
+
|
|
31
|
+
export const EXPIRING_FACET_VALUE = "expiring";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
4
|
+
import { documentLinkAggregateId } from "./aggregate-id.ts";
|
|
5
|
+
import type { DocumentExecutors } from "./executor.ts";
|
|
6
|
+
|
|
7
|
+
export type LinkTarget = { readonly entityType: string; readonly entityId: string };
|
|
8
|
+
|
|
9
|
+
// The link id is deterministic, so a removed link is restored rather than recreated and a
|
|
10
|
+
// concurrent first create converges on the row the winner wrote.
|
|
11
|
+
export async function ensureDocumentLink(
|
|
12
|
+
executors: DocumentExecutors,
|
|
13
|
+
user: SessionUser,
|
|
14
|
+
db: TenantDb,
|
|
15
|
+
documentId: string,
|
|
16
|
+
target: LinkTarget,
|
|
17
|
+
) {
|
|
18
|
+
const id = documentLinkAggregateId(user.tenantId, documentId, target.entityType, target.entityId);
|
|
19
|
+
const restored = await executors.link.restore({ id }, user, db);
|
|
20
|
+
if (restored.isSuccess) return restored;
|
|
21
|
+
const alreadyLive =
|
|
22
|
+
isPlainObject(restored.error.details) && restored.error.details["reason"] === "not_deleted";
|
|
23
|
+
if (alreadyLive) return { isSuccess: true as const, data: { id } };
|
|
24
|
+
if (restored.error.code !== "not_found") return restored;
|
|
25
|
+
|
|
26
|
+
const created = await executors.link.create(
|
|
27
|
+
{ id, documentId, entityType: target.entityType, entityId: target.entityId },
|
|
28
|
+
user,
|
|
29
|
+
db,
|
|
30
|
+
);
|
|
31
|
+
if (created.isSuccess || created.error.code === "version_conflict") {
|
|
32
|
+
return { isSuccess: true as const, data: { id } };
|
|
33
|
+
}
|
|
34
|
+
return created;
|
|
35
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { sql } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
createDateField,
|
|
4
|
+
createEntity,
|
|
5
|
+
createFileField,
|
|
6
|
+
createSelectField,
|
|
7
|
+
createTextField,
|
|
8
|
+
type EntityDefinition,
|
|
9
|
+
SYSTEM_ROLE,
|
|
10
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
11
|
+
import { DOCUMENT_LINK_ENTITY } from "./constants.ts";
|
|
12
|
+
|
|
13
|
+
export type DocumentEntityOptions = {
|
|
14
|
+
readonly categories: readonly [string, ...string[]];
|
|
15
|
+
readonly adminRoles: readonly string[];
|
|
16
|
+
readonly readAccess?: NonNullable<EntityDefinition["access"]>["read"];
|
|
17
|
+
readonly retention?: EntityDefinition["retention"];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Readable only through a link to a host the viewer can read, and only when the file's owner is
|
|
21
|
+
// readable too (see EntityDefinition.readGate); readAccess narrows further.
|
|
22
|
+
export function createDocumentEntity(options: DocumentEntityOptions) {
|
|
23
|
+
return createEntity({
|
|
24
|
+
table: "read_filed_documents",
|
|
25
|
+
softDelete: true,
|
|
26
|
+
description:
|
|
27
|
+
"A filed document: title, category, issue and expiry dates and one file. It is visible only through its links to other records and the owner of its file; the file may stay owned by the record it came from.",
|
|
28
|
+
// The system user passes the gate; the capture and import checks run as it and would
|
|
29
|
+
// otherwise miss a document that readAccess hides.
|
|
30
|
+
...(options.readAccess !== undefined && {
|
|
31
|
+
access: { read: { ...options.readAccess, [SYSTEM_ROLE]: "all" } },
|
|
32
|
+
}),
|
|
33
|
+
...(options.retention !== undefined && { retention: options.retention }),
|
|
34
|
+
readGate: {
|
|
35
|
+
anyLink: { entity: DOCUMENT_LINK_ENTITY, field: "documentId" },
|
|
36
|
+
fileOwner: { field: "file" },
|
|
37
|
+
linkExemptRoles: options.adminRoles,
|
|
38
|
+
},
|
|
39
|
+
fields: {
|
|
40
|
+
title: createTextField({
|
|
41
|
+
required: true,
|
|
42
|
+
maxLength: 200,
|
|
43
|
+
sortable: true,
|
|
44
|
+
searchable: true,
|
|
45
|
+
personal: false,
|
|
46
|
+
reason: "document_title",
|
|
47
|
+
}),
|
|
48
|
+
category: createSelectField({
|
|
49
|
+
options: options.categories,
|
|
50
|
+
required: true,
|
|
51
|
+
filterable: true,
|
|
52
|
+
sortable: true,
|
|
53
|
+
}),
|
|
54
|
+
issuedAt: createDateField({ sortable: true }),
|
|
55
|
+
validUntil: createDateField({ sortable: true, filterable: true }),
|
|
56
|
+
// Filed business documents belong to the tenant, not to one person.
|
|
57
|
+
file: createFileField({ required: true, personal: false, reason: "is_business_data" }),
|
|
58
|
+
},
|
|
59
|
+
// One live document per file; a deleted one releases the file.
|
|
60
|
+
indexes: [
|
|
61
|
+
{
|
|
62
|
+
columns: ["tenantId", "file"],
|
|
63
|
+
unique: true,
|
|
64
|
+
name: "read_filed_documents_file_unique",
|
|
65
|
+
where: sql`"is_deleted" = false`,
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createDocumentLinkEntity(linkTargets: readonly string[]) {
|
|
72
|
+
return createEntity({
|
|
73
|
+
table: "read_filed_document_links",
|
|
74
|
+
softDelete: true,
|
|
75
|
+
description:
|
|
76
|
+
"The join row recording that one document belongs to one record, addressed by documentId, entityType and entityId, with exactly one row per triple. A document is visible to whoever can read at least one of its linked records.",
|
|
77
|
+
parentRef: {
|
|
78
|
+
entityTypeField: "entityType",
|
|
79
|
+
entityIdField: "entityId",
|
|
80
|
+
allowedTypes: linkTargets,
|
|
81
|
+
},
|
|
82
|
+
fields: {
|
|
83
|
+
documentId: createTextField({
|
|
84
|
+
required: true,
|
|
85
|
+
maxLength: 64,
|
|
86
|
+
personal: false,
|
|
87
|
+
reason: "technical_reference",
|
|
88
|
+
}),
|
|
89
|
+
entityType: createTextField({
|
|
90
|
+
required: true,
|
|
91
|
+
maxLength: 64,
|
|
92
|
+
personal: false,
|
|
93
|
+
reason: "technical_reference",
|
|
94
|
+
}),
|
|
95
|
+
entityId: createTextField({
|
|
96
|
+
required: true,
|
|
97
|
+
maxLength: 128,
|
|
98
|
+
personal: false,
|
|
99
|
+
reason: "technical_reference",
|
|
100
|
+
}),
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createEntityExecutor, type EntityDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { DOCUMENT_ENTITY, DOCUMENT_LINK_ENTITY } from "./constants.ts";
|
|
3
|
+
|
|
4
|
+
export function createDocumentExecutors(
|
|
5
|
+
documentEntity: EntityDefinition,
|
|
6
|
+
linkEntity: EntityDefinition,
|
|
7
|
+
) {
|
|
8
|
+
return {
|
|
9
|
+
document: createEntityExecutor(DOCUMENT_ENTITY, documentEntity).executor,
|
|
10
|
+
link: createEntityExecutor(DOCUMENT_LINK_ENTITY, linkEntity).executor,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export type DocumentExecutors = ReturnType<typeof createDocumentExecutors>;
|