@cosmicdrift/kumiko-samples 0.383.1 → 0.385.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.
Files changed (83) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +6 -1
  3. package/packages/bundled-features/src/audit/changes.json +6 -0
  4. package/packages/bundled-features/src/audit/event-classification.ts +83 -0
  5. package/packages/bundled-features/src/audit/feature.ts +3 -2
  6. package/packages/bundled-features/src/audit/handlers/audit-events.ts +63 -5
  7. package/packages/bundled-features/src/audit/handlers/details-all-tenants.query.ts +6 -5
  8. package/packages/bundled-features/src/audit/handlers/details.query.ts +12 -6
  9. package/packages/bundled-features/src/audit/handlers/list-all-tenants.query.ts +20 -12
  10. package/packages/bundled-features/src/audit/handlers/list.query.ts +14 -3
  11. package/packages/bundled-features/src/audit/screens.ts +32 -4
  12. package/packages/bundled-features/src/audit/web/audit-event-cell.tsx +22 -0
  13. package/packages/bundled-features/src/audit/web/audit-event-labels.ts +30 -0
  14. package/packages/bundled-features/src/audit/web/client-plugin.tsx +10 -1
  15. package/packages/bundled-features/src/audit/web/constants.ts +11 -0
  16. package/packages/bundled-features/src/audit/web/i18n.ts +48 -1
  17. package/packages/bundled-features/src/auth-email-password/changes.json +12 -0
  18. package/packages/bundled-features/src/auth-email-password/feature.ts +3 -2
  19. package/packages/bundled-features/src/auth-email-password/handlers/login-options.query.ts +19 -0
  20. package/packages/bundled-features/src/auth-email-password/index.ts +7 -1
  21. package/packages/bundled-features/src/auth-email-password/password-policy.ts +196 -2087
  22. package/packages/bundled-features/src/auth-email-password/web/auth-form-primitives.tsx +9 -12
  23. package/packages/bundled-features/src/auth-email-password/{auth-paths.ts → web/auth-paths.ts} +7 -4
  24. package/packages/bundled-features/src/auth-email-password/web/client-plugin.ts +7 -0
  25. package/packages/bundled-features/src/auth-email-password/web/constants.ts +1 -0
  26. package/packages/bundled-features/src/auth-email-password/web/default-auth-pages-gate.tsx +31 -0
  27. package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +68 -42
  28. package/packages/bundled-features/src/channel-in-app/handlers/unread-count.query.ts +1 -1
  29. package/packages/bundled-features/src/custom-fields/db/queries/projection.ts +1 -1
  30. package/packages/bundled-features/src/file-derivatives/feature.ts +1 -1
  31. package/packages/bundled-features/src/jobs/changes.json +6 -0
  32. package/packages/bundled-features/src/jobs/feature.ts +34 -127
  33. package/packages/bundled-features/src/jobs/handlers/consumer.write.ts +98 -0
  34. package/packages/bundled-features/src/jobs/handlers/consumers.query.ts +137 -0
  35. package/packages/bundled-features/src/jobs/screens.ts +262 -0
  36. package/packages/bundled-features/src/jobs/web/constants.ts +29 -0
  37. package/packages/bundled-features/src/jobs/web/i18n.ts +41 -0
  38. package/packages/bundled-features/src/ledger/web/index.ts +1 -1
  39. package/packages/bundled-features/src/notes-history/handlers/add-note.write.ts +1 -1
  40. package/packages/bundled-features/src/personal-access-tokens/changes.json +7 -0
  41. package/packages/bundled-features/src/personal-access-tokens/i18n.ts +4 -0
  42. package/packages/bundled-features/src/personal-access-tokens/index.ts +1 -0
  43. package/packages/bundled-features/src/secrets/i18n.ts +0 -3
  44. package/packages/bundled-features/src/sessions/changes.json +6 -0
  45. package/packages/bundled-features/src/sessions/constants.ts +3 -1
  46. package/packages/bundled-features/src/sessions/handlers/detail.query.ts +7 -3
  47. package/packages/bundled-features/src/sessions/handlers/list.query.ts +16 -10
  48. package/packages/bundled-features/src/sessions/handlers/mine.query.ts +15 -9
  49. package/packages/bundled-features/src/sessions/screens.ts +23 -8
  50. package/packages/bundled-features/src/sessions/web/client-plugin.tsx +17 -0
  51. package/packages/bundled-features/src/sessions/web/constants.ts +3 -0
  52. package/packages/bundled-features/src/sessions/web/default-translations.ts +8 -0
  53. package/packages/bundled-features/src/sessions/web/i18n.ts +5 -1
  54. package/packages/bundled-features/src/sessions/web/index.ts +1 -0
  55. package/packages/bundled-features/src/sessions/web/session-device-cell.tsx +19 -0
  56. package/packages/bundled-features/src/sessions/web/user-agent.ts +74 -0
  57. package/packages/bundled-features/src/shared/run-in-sub-transaction.ts +1 -1
  58. package/packages/bundled-features/src/template-resolver/changes.json +6 -0
  59. package/packages/bundled-features/src/template-resolver/feature.ts +2 -0
  60. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +34 -1
  61. package/packages/bundled-features/src/template-resolver/handlers/tenant-override-counts.query.ts +53 -0
  62. package/packages/bundled-features/src/template-resolver/handlers/tenant-override-selection.ts +51 -0
  63. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +7 -38
  64. package/packages/bundled-features/src/template-resolver/web/qualified-names.ts +1 -0
  65. package/packages/bundled-features/src/user-data-rights/changes.json +18 -0
  66. package/packages/bundled-features/src/user-data-rights/feature.ts +33 -33
  67. package/packages/bundled-features/src/user-data-rights/web/i18n.ts +4 -1
  68. package/samples/apps/showcase/src/features/items/schema/item.ts +8 -2
  69. package/samples/apps/ui-walkthrough/src/features/tasks/schema/task.ts +2 -2
  70. package/samples/apps/use-all-bundled/bin/main.ts +7 -1
  71. package/samples/apps/use-all-bundled/feature-manifest.json +19 -1
  72. package/samples/apps/use-all-bundled/kumiko/schema.ts +3 -1
  73. package/samples/apps/use-all-bundled/src/app/client.tsx +2 -0
  74. package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +4 -2
  75. package/samples/apps/use-all-bundled/src/app/invoice-reports-feature.ts +30 -0
  76. package/samples/apps/use-all-bundled/src/app/pat-scope-labels-feature.ts +21 -0
  77. package/samples/apps/use-all-bundled/src/app/seed.ts +51 -0
  78. package/samples/apps/use-all-bundled/src/run-config.ts +5 -0
  79. package/samples/apps/workspaces/src/features/demo/schema/order.ts +2 -2
  80. package/samples/recipes/custom-fields-basic/src/feature.ts +13 -3
  81. package/samples/recipes/designer-demo/src/feature.ts +6 -2
  82. package/samples/recipes/i18n/README.md +15 -0
  83. package/samples/recipes/i18n/src/feature.ts +4 -0
@@ -3,6 +3,7 @@ import { definePagedQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import * as z from "zod";
4
4
  import { decryptStoredPii } from "../../shared/index.ts";
5
5
  import { userSessionTable } from "../schema/user-session.ts";
6
+ import { parsedDeviceSchema, parseUserAgent } from "../web/user-agent.ts";
6
7
 
7
8
  // "My live sessions" — the backing data for a devices/sessions UI. Returns
8
9
  // ONLY the current user's own, currently-live sessions, ordered by most-
@@ -29,6 +30,7 @@ export const mineQuery = definePagedQueryHandler({
29
30
  expiresAt: z.unknown(),
30
31
  ip: z.string().nullable(),
31
32
  userAgent: z.string().nullable(),
33
+ device: parsedDeviceSchema,
32
34
  current: z.boolean(),
33
35
  }),
34
36
  ),
@@ -51,16 +53,20 @@ export const mineQuery = definePagedQueryHandler({
51
53
  );
52
54
  const currentSid = query.user.sid;
53
55
  const decryptedRows = await Promise.all(
54
- rows.map(async (r) => ({
55
- id: r.id,
56
- createdAt: r.createdAt,
57
- expiresAt: r.expiresAt,
58
- ip: r.ip ? await decryptStoredPii(r.ip, "ip", "sessions:mine") : r.ip,
59
- userAgent: r.userAgent
56
+ rows.map(async (r) => {
57
+ const userAgent = r.userAgent
60
58
  ? await decryptStoredPii(r.userAgent, "userAgent", "sessions:mine")
61
- : r.userAgent,
62
- current: currentSid === r.id,
63
- })),
59
+ : r.userAgent;
60
+ return {
61
+ id: r.id,
62
+ createdAt: r.createdAt,
63
+ expiresAt: r.expiresAt,
64
+ ip: r.ip ? await decryptStoredPii(r.ip, "ip", "sessions:mine") : r.ip,
65
+ userAgent,
66
+ device: parseUserAgent(userAgent),
67
+ current: currentSid === r.id,
68
+ };
69
+ }),
64
70
  );
65
71
  return { rows: decryptedRows, nextCursor: null };
66
72
  },
@@ -12,6 +12,9 @@ import {
12
12
  SessionHandlers,
13
13
  SessionQueries,
14
14
  } from "./constants.ts";
15
+ import { SESSION_DEVICE_CELL_COMPONENT } from "./web/constants.ts";
16
+
17
+ const DEVICE_RENDERER = { react: { __component: SESSION_DEVICE_CELL_COMPONENT } } as const;
15
18
 
16
19
  export const createSessionListScreen = (
17
20
  adminRoles: readonly string[] = access.admin,
@@ -35,6 +38,11 @@ export const createSessionListScreen = (
35
38
  refEntity: "user:user",
36
39
  refLabelField: "displayName",
37
40
  },
41
+ {
42
+ field: "device",
43
+ label: i18nKey("sessions.list.col.device"),
44
+ renderer: DEVICE_RENDERER,
45
+ },
38
46
  {
39
47
  field: "createdAt",
40
48
  label: i18nKey("sessions.list.col.createdAt"),
@@ -43,6 +51,7 @@ export const createSessionListScreen = (
43
51
  {
44
52
  field: "expiresAt",
45
53
  label: i18nKey("sessions.list.col.expiresAt"),
54
+ hideOnNarrow: true,
46
55
  renderer: { format: "timestamp" },
47
56
  },
48
57
  {
@@ -85,6 +94,7 @@ export const createSessionDetailScreen = (
85
94
  { field: "createdAt", renderer: { format: "timestamp" } },
86
95
  { field: "expiresAt", renderer: { format: "timestamp" } },
87
96
  { field: "revokedAt", renderer: { format: "timestamp" } },
97
+ { field: "device", renderer: DEVICE_RENDERER },
88
98
  "ip",
89
99
  "userAgent",
90
100
  ],
@@ -97,6 +107,7 @@ export const createSessionDetailScreen = (
97
107
  createdAt: i18nKey("sessions.detail.field.createdAt"),
98
108
  expiresAt: i18nKey("sessions.detail.field.expiresAt"),
99
109
  revokedAt: i18nKey("sessions.detail.field.revokedAt"),
110
+ device: i18nKey("sessions.list.col.device"),
100
111
  ip: i18nKey("sessions.detail.field.ip"),
101
112
  userAgent: i18nKey("sessions.detail.field.userAgent"),
102
113
  },
@@ -109,6 +120,17 @@ export const sessionMineScreen: ProjectionListScreenDefinition = {
109
120
  type: "projectionList",
110
121
  query: SessionQueries.mine,
111
122
  columns: [
123
+ { field: "device", label: i18nKey("sessions.mine.col.userAgent"), renderer: DEVICE_RENDERER },
124
+ { field: "ip", label: i18nKey("sessions.mine.col.ip") },
125
+ {
126
+ field: "current",
127
+ label: i18nKey("sessions.mine.col.current"),
128
+ renderer: {
129
+ format: "boolean",
130
+ trueLabel: i18nKey("sessions.mine.current.yes"),
131
+ falseLabel: i18nKey("sessions.mine.current.no"),
132
+ },
133
+ },
112
134
  {
113
135
  field: "createdAt",
114
136
  label: i18nKey("sessions.list.col.createdAt"),
@@ -117,15 +139,8 @@ export const sessionMineScreen: ProjectionListScreenDefinition = {
117
139
  {
118
140
  field: "expiresAt",
119
141
  label: i18nKey("sessions.list.col.expiresAt"),
120
- renderer: { format: "timestamp" },
121
- },
122
- { field: "ip", label: i18nKey("sessions.mine.col.ip") },
123
- { field: "userAgent", label: i18nKey("sessions.mine.col.userAgent") },
124
- {
125
- field: "current",
126
- label: i18nKey("sessions.mine.col.current"),
127
142
  hideOnNarrow: true,
128
- renderer: { format: "boolean" },
143
+ renderer: { format: "timestamp" },
129
144
  },
130
145
  ],
131
146
  rowActions: [
@@ -0,0 +1,17 @@
1
+ import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
2
+ import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer/web";
3
+ import { SESSION_DEVICE_CELL_COMPONENT, SESSIONS_FEATURE } from "./constants.ts";
4
+ import { defaultTranslations } from "./default-translations.ts";
5
+ import { SessionDeviceCell } from "./session-device-cell.tsx";
6
+
7
+ export type SessionsClientOptions = {
8
+ readonly translations?: TranslationsByLocale;
9
+ };
10
+
11
+ export function sessionsClient(options?: SessionsClientOptions): ClientFeatureDefinition {
12
+ return {
13
+ name: SESSIONS_FEATURE,
14
+ translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
15
+ columnRenderers: { [SESSION_DEVICE_CELL_COMPONENT]: SessionDeviceCell },
16
+ };
17
+ }
@@ -0,0 +1,3 @@
1
+ export const SESSIONS_FEATURE = "sessions" as const;
2
+ /** Column-renderer name shared by screens.ts and `sessionsClient()`. */
3
+ export const SESSION_DEVICE_CELL_COMPONENT = "session-device-cell" as const;
@@ -0,0 +1,8 @@
1
+ import {
2
+ type TranslationsByLocale,
3
+ translationsByLocaleFromKeys,
4
+ } from "@cosmicdrift/kumiko-renderer";
5
+ import { SESSIONS_I18N } from "./i18n.ts";
6
+
7
+ export const defaultTranslations: TranslationsByLocale =
8
+ translationsByLocaleFromKeys(SESSIONS_I18N);
@@ -22,7 +22,11 @@ export const SESSIONS_I18N: Readonly<Record<string, LocalizedString>> = {
22
22
  "screen:my-sessions.title": { en: "Your sessions" },
23
23
  "sessions.mine.col.ip": { en: "IP address" },
24
24
  "sessions.mine.col.userAgent": { en: "Device" },
25
- "sessions.mine.col.current": { en: "This device" },
25
+ "sessions.mine.col.current": { en: "Session" },
26
+ "sessions.mine.current.yes": { en: "This session" },
27
+ "sessions.mine.current.no": { en: "-" },
28
+ "sessions.list.col.device": { en: "Device" },
29
+ "sessions.device.unknown": { en: "Unknown device" },
26
30
  "sessions.mine.revoke": { en: "Sign out" },
27
31
  "sessions.mine.revoke.confirm": { en: "Sign this device out?" },
28
32
  "sessions.mine.revokeAllOthers": { en: "Sign out all other devices" },
@@ -0,0 +1 @@
1
+ export { type SessionsClientOptions, sessionsClient } from "./client-plugin.tsx";
@@ -0,0 +1,19 @@
1
+ // Column renderer for the parsed `device` of a session row, registered via `sessionsClient()`.
2
+
3
+ import { type ColumnRendererProps, useTranslation } from "@cosmicdrift/kumiko-renderer";
4
+ import type { ReactNode } from "react";
5
+ import { parsedDeviceSchema } from "./user-agent.ts";
6
+
7
+ export function SessionDeviceCell({ value }: ColumnRendererProps): ReactNode {
8
+ const t = useTranslation();
9
+ const parsed = parsedDeviceSchema.safeParse(value);
10
+ const device = parsed.success ? parsed.data : null;
11
+ if (device === null) return t("sessions.device.unknown");
12
+ const browser =
13
+ device.browser === null
14
+ ? null
15
+ : device.browserMajor === null
16
+ ? device.browser
17
+ : `${device.browser} ${device.browserMajor}`;
18
+ return [browser, device.os].filter((part) => part !== null).join(" · ");
19
+ }
@@ -0,0 +1,74 @@
1
+ import * as z from "zod";
2
+
3
+ export const USER_AGENT_BROWSERS = [
4
+ "Edge",
5
+ "Opera",
6
+ "Samsung Internet",
7
+ "Firefox",
8
+ "Chrome",
9
+ "Safari",
10
+ ] as const;
11
+ export const USER_AGENT_OPERATING_SYSTEMS = [
12
+ "Windows",
13
+ "iOS",
14
+ "Android",
15
+ "macOS",
16
+ "ChromeOS",
17
+ "Linux",
18
+ ] as const;
19
+
20
+ export type UserAgentBrowser = (typeof USER_AGENT_BROWSERS)[number];
21
+ export type UserAgentOperatingSystem = (typeof USER_AGENT_OPERATING_SYSTEMS)[number];
22
+
23
+ export const parsedDeviceSchema = z
24
+ .object({
25
+ browser: z.enum(USER_AGENT_BROWSERS).nullable(),
26
+ browserMajor: z.number().int().nullable(),
27
+ os: z.enum(USER_AGENT_OPERATING_SYSTEMS).nullable(),
28
+ })
29
+ .nullable();
30
+
31
+ export type ParsedDevice = z.infer<typeof parsedDeviceSchema>;
32
+
33
+ // Order matters: Chromium forks and iOS Chrome/Firefox also carry the engine tokens of the browsers they derive from.
34
+ const BROWSER_PATTERNS: readonly {
35
+ readonly browser: UserAgentBrowser;
36
+ readonly pattern: RegExp;
37
+ }[] = [
38
+ { browser: "Edge", pattern: /\bEdg(?:e|A|iOS)?\/(\d+)/ },
39
+ { browser: "Opera", pattern: /\bOPR\/(\d+)/ },
40
+ { browser: "Samsung Internet", pattern: /\bSamsungBrowser\/(\d+)/ },
41
+ { browser: "Firefox", pattern: /\b(?:Firefox|FxiOS)\/(\d+)/ },
42
+ { browser: "Chrome", pattern: /\b(?:Chrome|CriOS)\/(\d+)/ },
43
+ { browser: "Safari", pattern: /\bVersion\/(\d+)[\s\S]*\bSafari\// },
44
+ ];
45
+
46
+ // iPhone/iPad UAs say "like Mac OS X" and Android UAs say "Linux", so the more specific systems come first.
47
+ const OPERATING_SYSTEM_PATTERNS: readonly {
48
+ readonly os: UserAgentOperatingSystem;
49
+ readonly pattern: RegExp;
50
+ }[] = [
51
+ { os: "Windows", pattern: /\bWindows\b/ },
52
+ { os: "iOS", pattern: /\b(?:iPhone|iPad|iPod)\b/ },
53
+ { os: "Android", pattern: /\bAndroid\b/ },
54
+ { os: "macOS", pattern: /\bMacintosh\b/ },
55
+ { os: "ChromeOS", pattern: /\bCrOS\b/ },
56
+ { os: "Linux", pattern: /\bLinux\b/ },
57
+ ];
58
+
59
+ export function parseUserAgent(userAgent: string | null): ParsedDevice {
60
+ if (userAgent === null || userAgent === "") return null;
61
+ let browser: UserAgentBrowser | null = null;
62
+ let browserMajor: number | null = null;
63
+ for (const candidate of BROWSER_PATTERNS) {
64
+ const match = candidate.pattern.exec(userAgent);
65
+ if (match) {
66
+ browser = candidate.browser;
67
+ browserMajor = match[1] === undefined ? null : Number(match[1]);
68
+ break;
69
+ }
70
+ }
71
+ const os =
72
+ OPERATING_SYSTEM_PATTERNS.find((candidate) => candidate.pattern.test(userAgent))?.os ?? null;
73
+ return browser === null && os === null ? null : { browser, browserMajor, os };
74
+ }
@@ -1,7 +1,7 @@
1
1
  import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
2
2
 
3
3
  // Per-unit sub-transaction (one user, one purged row), nesting-aware across both db shapes:
4
- // - top-level connection (Bun.SQL / postgres-js Sql) → `.begin` (BEGIN)
4
+ // - top-level connection (postgres-js Sql) → `.begin` (BEGIN)
5
5
  // - TransactionSql (inside the dispatcher's outer tx, where every
6
6
  // writeHandler already runs) → `.savepoint` (SAVEPOINT)
7
7
  // A TransactionSql has no `.begin`, so the previous unconditional `.begin`
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.384.0",
4
+ "type": "improvement",
5
+ "title": "Template overview reports tenant overrides per template",
6
+ "detail": "template-resolver has a SystemAdmin query tenant-override-counts, and the overview rows for system templates carry overriddenBy { active, total } for SystemAdmins. The designer's overview reads it instead of issuing one tenant-overrides query per row. Reading it uses a declared unsafeRaw escape hatch because it counts across tenants."
7
+ },
2
8
  {
3
9
  "version": "0.379.0",
4
10
  "type": "improvement",
@@ -31,6 +31,7 @@ import { makeResourceUploadWrite } from "./handlers/resource-upload.write.ts";
31
31
  import { makeSendTestWrite } from "./handlers/send-test.write.ts";
32
32
  import { sendTestStatusQuery } from "./handlers/send-test-status.query.ts";
33
33
  import { makeSetWrite } from "./handlers/set.write.ts";
34
+ import { tenantOverrideCountsQuery } from "./handlers/tenant-override-counts.query.ts";
34
35
  import { tenantOverridesQuery } from "./handlers/tenant-overrides.query.ts";
35
36
  import {
36
37
  makeArchiveSystemWrite,
@@ -184,6 +185,7 @@ export function createTemplateResolverFeature(opts: TemplateResolverOptions = {}
184
185
  overview: r.queryHandler(makeOverviewQuery(policy)),
185
186
  layoutSource: r.queryHandler(layoutSourceQuery),
186
187
  tenantOverrides: r.queryHandler(tenantOverridesQuery),
188
+ tenantOverrideCounts: r.queryHandler(tenantOverrideCountsQuery),
187
189
  diffToSystem: r.queryHandler(diffToSystemQuery),
188
190
  validate: r.queryHandler(validateQuery),
189
191
  preview: r.queryHandler(previewQuery),
@@ -12,6 +12,7 @@ import {
12
12
  activeVersionOf,
13
13
  draftViewerTenantId,
14
14
  isHiddenSystemDraft,
15
+ isSystemAdmin,
15
16
  parseTemplateDraft,
16
17
  servedVersionOf,
17
18
  } from "../template-draft.ts";
@@ -23,7 +24,9 @@ import {
23
24
  FALLBACK_LOCALE,
24
25
  type TemplateStatus,
25
26
  } from "../web/constants.ts";
27
+ import { TemplateResolverQueries } from "../web/qualified-names.ts";
26
28
  import { storedRecord } from "./template-gates.ts";
29
+ import { tenantOverrideCountsResultSchema } from "./tenant-override-counts.query.ts";
27
30
 
28
31
  const OVERVIEW_KINDS = ["mail", "document-pdf", "mail-layout", "pdf-layout"] as const;
29
32
  type OverviewKind = (typeof OVERVIEW_KINDS)[number];
@@ -65,6 +68,8 @@ export type OverviewRow = {
65
68
  readonly modifiedAt: string;
66
69
  readonly usedBy: readonly OverviewUsage[];
67
70
  readonly usedByCount: number;
71
+ /** SystemAdmin only, system-origin rows: tenants overriding this template; total is null without the tenant feature. */
72
+ readonly overriddenBy?: { readonly active: number; readonly total: number | null };
68
73
  };
69
74
 
70
75
  export type OverviewResponse = {
@@ -308,6 +313,26 @@ function withUsage(
308
313
  return { ...draft, usedBy, usedByCount: usedBy.length };
309
314
  }
310
315
 
316
+ function withOverriddenBy(
317
+ rows: readonly OverviewRow[],
318
+ overrides: z.infer<typeof tenantOverrideCountsResultSchema>,
319
+ ): OverviewRow[] {
320
+ const activeByKey = new Map(
321
+ overrides.counts.map((entry) => [`${entry.kind}|${entry.slug}`, entry.activeOverrides]),
322
+ );
323
+ return rows.map((row) =>
324
+ row.origin === "system"
325
+ ? {
326
+ ...row,
327
+ overriddenBy: {
328
+ active: activeByKey.get(`${row.kind}|${row.slug}`) ?? 0,
329
+ total: overrides.totalTenants,
330
+ },
331
+ }
332
+ : row,
333
+ );
334
+ }
335
+
311
336
  function filterRows(
312
337
  rows: readonly OverviewRow[],
313
338
  search: string | undefined,
@@ -373,9 +398,17 @@ export function makeOverviewQuery(policy: TemplateEditPolicy) {
373
398
  deliveryPolicy,
374
399
  );
375
400
  const effectiveLayouts = effectiveMailLayoutSlugs(visibleRows, deliveryPolicy);
376
- const overviewRows = drafts.map((draft) =>
401
+ const rowsWithUsage = drafts.map((draft) =>
377
402
  withUsage(draft, layoutSlugByDocument, ctx.registry, effectiveLayouts),
378
403
  );
404
+ const overviewRows = isSystemAdmin(query.user)
405
+ ? withOverriddenBy(
406
+ rowsWithUsage,
407
+ tenantOverrideCountsResultSchema.parse(
408
+ await ctx.query(TemplateResolverQueries.tenantOverrideCounts, {}),
409
+ ),
410
+ )
411
+ : rowsWithUsage;
379
412
  const filtered = filterRows(
380
413
  overviewRows,
381
414
  payload.search,
@@ -0,0 +1,53 @@
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/db";
2
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import * as z from "zod";
4
+ import { type TemplateResourceRow, templateResourcesTable } from "../table.ts";
5
+ import {
6
+ loadEnabledTenantIds,
7
+ pickBestRowPerTenant,
8
+ TENANT_OVERRIDES_REASON,
9
+ } from "./tenant-override-selection.ts";
10
+
11
+ export const tenantOverrideCountsResultSchema = z.object({
12
+ totalTenants: z.number().nullable(),
13
+ counts: z.array(z.object({ slug: z.string(), kind: z.string(), activeOverrides: z.number() })),
14
+ });
15
+
16
+ export const tenantOverrideCountsQuery = defineQueryHandler({
17
+ name: "tenant-override-counts",
18
+ schema: z.object({}),
19
+ access: { roles: ["SystemAdmin"] },
20
+ description:
21
+ "Counts, per template slug and kind, the enabled tenants that override it with an active version, next to the number of enabled tenants; one query for the whole template overview instead of one tenant-overrides call per row.",
22
+ escapeHatch: { grants: ["unsafeRaw"], reason: TENANT_OVERRIDES_REASON },
23
+ handler: async (_query, ctx) => {
24
+ const found = await selectMany<TemplateResourceRow>(
25
+ ctx.db.unsafeRaw(),
26
+ templateResourcesTable,
27
+ {
28
+ scope: "tenant",
29
+ },
30
+ );
31
+ const enabledTenantIds = await loadEnabledTenantIds(ctx);
32
+
33
+ const rowsBySlugAndKind = new Map<
34
+ string,
35
+ { slug: string; kind: string; rows: TemplateResourceRow[] }
36
+ >();
37
+ for (const row of found) {
38
+ const key = `${row.kind}|${row.slug}`;
39
+ const group = rowsBySlugAndKind.get(key) ?? { slug: row.slug, kind: row.kind, rows: [] };
40
+ group.rows.push(row);
41
+ rowsBySlugAndKind.set(key, group);
42
+ }
43
+ const counts = [...rowsBySlugAndKind.values()].map(({ slug, kind, rows }) => ({
44
+ slug,
45
+ kind,
46
+ activeOverrides: [...pickBestRowPerTenant(rows, enabledTenantIds).values()].filter(
47
+ (row) => row.status === "active",
48
+ ).length,
49
+ }));
50
+
51
+ return { totalTenants: enabledTenantIds === null ? null : enabledTenantIds.size, counts };
52
+ },
53
+ });
@@ -0,0 +1,51 @@
1
+ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
2
+ import * as z from "zod";
3
+ import { TenantQueries } from "../../tenant/index.ts";
4
+ import type { TemplateResourceRow } from "../table.ts";
5
+
6
+ export const TENANT_OVERRIDES_REASON =
7
+ "SystemAdmin template overview lists which tenants override one system template; it reads tenant-scope template rows of every tenant (and the system row for the optional per-tenant diff), which the tenant-scoped db cannot reach";
8
+
9
+ const tenantIdsSchema = z.array(z.string());
10
+
11
+ type TenantIdQueryContext = {
12
+ readonly registry: { getQueryHandler(name: string): unknown };
13
+ readonly query: (name: string, payload: Record<string, never>) => Promise<unknown>;
14
+ };
15
+
16
+ /** Null when the tenant feature is not installed: every tenant row then counts. */
17
+ export async function loadEnabledTenantIds(
18
+ ctx: TenantIdQueryContext,
19
+ ): Promise<ReadonlySet<string> | null> {
20
+ if (ctx.registry.getQueryHandler(TenantQueries.activeTenantIds) === undefined) return null;
21
+ return new Set(tenantIdsSchema.parse(await ctx.query(TenantQueries.activeTenantIds, {})));
22
+ }
23
+
24
+ // One row per tenant: its active override wins over a draft, then the newest version.
25
+ function isBetterRow(candidate: TemplateResourceRow, current: TemplateResourceRow): boolean {
26
+ if ((candidate.status === "active") !== (current.status === "active")) {
27
+ return candidate.status === "active";
28
+ }
29
+ return candidate.version > current.version;
30
+ }
31
+
32
+ function isCountedTenantRow(
33
+ row: TemplateResourceRow,
34
+ enabledTenantIds: ReadonlySet<string> | null,
35
+ ): boolean {
36
+ if (row.tenantId === SYSTEM_TENANT_ID || row.status === "archived") return false;
37
+ return enabledTenantIds === null || enabledTenantIds.has(row.tenantId);
38
+ }
39
+
40
+ export function pickBestRowPerTenant(
41
+ found: readonly TemplateResourceRow[],
42
+ enabledTenantIds: ReadonlySet<string> | null,
43
+ ): Map<string, TemplateResourceRow> {
44
+ const bestByTenant = new Map<string, TemplateResourceRow>();
45
+ for (const row of found) {
46
+ if (!isCountedTenantRow(row, enabledTenantIds)) continue;
47
+ const current = bestByTenant.get(row.tenantId);
48
+ if (current === undefined || isBetterRow(row, current)) bestByTenant.set(row.tenantId, row);
49
+ }
50
+ return bestByTenant;
51
+ }
@@ -6,11 +6,12 @@ import { type TemplateResourceRow, templateResourcesTable } from "../table.ts";
6
6
  import { servedVersionOf } from "../template-draft.ts";
7
7
  import { buildOverrideDiff } from "./override-diff.ts";
8
8
  import { kindSchema, localeSchema, templateSlugSchema } from "./shared.ts";
9
+ import {
10
+ loadEnabledTenantIds,
11
+ pickBestRowPerTenant,
12
+ TENANT_OVERRIDES_REASON,
13
+ } from "./tenant-override-selection.ts";
9
14
 
10
- const TENANT_OVERRIDES_REASON =
11
- "SystemAdmin template overview lists which tenants override one system template; it reads tenant-scope template rows of every tenant (and the system row for the optional per-tenant diff), which the tenant-scoped db cannot reach";
12
-
13
- const tenantIdsSchema = z.array(z.string());
14
15
  const directorySchema = z.object({
15
16
  rows: z.array(z.object({ id: z.string(), label: z.string() })),
16
17
  });
@@ -21,39 +22,10 @@ function labelsOf(raw: unknown): ReadonlyMap<string, string> {
21
22
  return new Map(directorySchema.parse(raw).rows.map((row) => [row.id, row.label]));
22
23
  }
23
24
 
24
- // One row per tenant: its active override wins over a draft, then the newest version.
25
- function isBetterRow(candidate: TemplateResourceRow, current: TemplateResourceRow): boolean {
26
- if ((candidate.status === "active") !== (current.status === "active")) {
27
- return candidate.status === "active";
28
- }
29
- return candidate.version > current.version;
30
- }
31
-
32
25
  function statusOf(row: TemplateResourceRow): OverrideStatus {
33
26
  return row.status === "active" ? "active" : "draft";
34
27
  }
35
28
 
36
- function isCountedTenantRow(
37
- row: TemplateResourceRow,
38
- enabledTenantIds: ReadonlySet<string> | null,
39
- ): boolean {
40
- if (row.tenantId === SYSTEM_TENANT_ID || row.status === "archived") return false;
41
- return enabledTenantIds === null || enabledTenantIds.has(row.tenantId);
42
- }
43
-
44
- function pickBestRowPerTenant(
45
- found: readonly TemplateResourceRow[],
46
- enabledTenantIds: ReadonlySet<string> | null,
47
- ): Map<string, TemplateResourceRow> {
48
- const bestByTenant = new Map<string, TemplateResourceRow>();
49
- for (const row of found) {
50
- if (!isCountedTenantRow(row, enabledTenantIds)) continue;
51
- const current = bestByTenant.get(row.tenantId);
52
- if (current === undefined || isBetterRow(row, current)) bestByTenant.set(row.tenantId, row);
53
- }
54
- return bestByTenant;
55
- }
56
-
57
29
  function toOverrideRow(
58
30
  row: TemplateResourceRow,
59
31
  tenantLabels: ReadonlyMap<string, string>,
@@ -95,11 +67,8 @@ export const tenantOverridesQuery = defineQueryHandler({
95
67
  ...(locale === undefined ? {} : { locale }),
96
68
  });
97
69
 
98
- const hasTenantFeature =
99
- ctx.registry.getQueryHandler(TenantQueries.activeTenantIds) !== undefined;
100
- const enabledTenantIds = hasTenantFeature
101
- ? new Set(tenantIdsSchema.parse(await ctx.query(TenantQueries.activeTenantIds, {})))
102
- : null;
70
+ const enabledTenantIds = await loadEnabledTenantIds(ctx);
71
+ const hasTenantFeature = enabledTenantIds !== null;
103
72
 
104
73
  const bestByTenant = pickBestRowPerTenant(found, enabledTenantIds);
105
74
  const best = [...bestByTenant.values()];
@@ -29,6 +29,7 @@ export const TemplateResolverQueries = {
29
29
  overview: "template-resolver:query:overview",
30
30
  layoutSource: "template-resolver:query:layout-source",
31
31
  tenantOverrides: "template-resolver:query:tenant-overrides",
32
+ tenantOverrideCounts: "template-resolver:query:tenant-override-counts",
32
33
  diffToSystem: "template-resolver:query:diff-to-system",
33
34
  validate: "template-resolver:query:validate",
34
35
  preview: "template-resolver:query:preview",
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.384.0",
4
+ "type": "fix",
5
+ "title": "privacy center boot test fixture declares a field stance",
6
+ "detail": "Test-only change. Two changes merged back to back on main: one added the privacy center deletion test with plain text fixture fields, the other made a personal-data stance mandatory on entity text fields. The fixture fields now state allowPlaintext, so the framework type check passes again."
7
+ },
8
+ {
9
+ "version": "0.384.0",
10
+ "type": "fix",
11
+ "title": "Privacy center keeps the deletion section and speaks to the user",
12
+ "detail": "The deletion section only held a conditional field and the request-deletion and cancel-deletion actions, so the renderer dropped it as empty. It now stays whenever it has a visible action. The subtitle is written for the person on the page and names Art. 20, 18 and (when deletion is configured) 17."
13
+ },
14
+ {
15
+ "version": "0.384.0",
16
+ "type": "improvement",
17
+ "title": "Privacy center subtitle no longer mentions deletion when it is hidden",
18
+ "detail": "With privacyCenterShowDeletion false the screen description uses the new key userDataRights.privacyCenter.subtitleWithoutDeletion (English, German, French, Spanish and Italian provided) instead of the text that names Art. 17."
19
+ },
2
20
  {
3
21
  "version": "0.379.0",
4
22
  "type": "improvement",