@tulip-systems/create-app 0.12.3 → 0.13.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 (190) hide show
  1. package/dist/index.mjs +2866 -2949
  2. package/dist/index.mjs.map +1 -1
  3. package/dist/templates/basic/package.json +37 -38
  4. package/dist/templates/basic/src/app/layout.tsx +20 -10
  5. package/dist/templates/code/package.json +81 -71
  6. package/dist/templates/code/public/fonts/neue-haas-grotesk/neue-haas-grotesk-black-italic.otf +0 -0
  7. package/dist/templates/code/public/fonts/neue-haas-grotesk/neue-haas-grotesk-black.otf +0 -0
  8. package/dist/templates/code/public/fonts/neue-haas-grotesk/neue-haas-grotesk-bold-italic.otf +0 -0
  9. package/dist/templates/code/public/fonts/neue-haas-grotesk/neue-haas-grotesk-bold.otf +0 -0
  10. package/dist/templates/code/public/fonts/neue-haas-grotesk/neue-haas-grotesk-extra-light-italic.otf +0 -0
  11. package/dist/templates/code/public/fonts/neue-haas-grotesk/neue-haas-grotesk-extra-light.otf +0 -0
  12. package/dist/templates/code/public/fonts/neue-haas-grotesk/neue-haas-grotesk-light-italic.otf +0 -0
  13. package/dist/templates/code/public/fonts/neue-haas-grotesk/neue-haas-grotesk-light.otf +0 -0
  14. package/dist/templates/code/public/fonts/neue-haas-grotesk/neue-haas-grotesk-medium-italic.otf +0 -0
  15. package/dist/templates/code/public/fonts/neue-haas-grotesk/neue-haas-grotesk-medium.otf +0 -0
  16. package/dist/templates/code/public/fonts/neue-haas-grotesk/neue-haas-grotesk-regular-italic.otf +0 -0
  17. package/dist/templates/code/public/fonts/neue-haas-grotesk/neue-haas-grotesk-regular.otf +0 -0
  18. package/dist/templates/code/public/fonts/neue-haas-grotesk/neue-haas-grotesk-thin-italic.otf +0 -0
  19. package/dist/templates/code/public/fonts/neue-haas-grotesk/neue-haas-grotesk-thin.otf +0 -0
  20. package/dist/templates/code/public/fonts/neue-haas-grotesk/neue-haas-grotesk-ultra-thin-italic.otf +0 -0
  21. package/dist/templates/code/public/fonts/neue-haas-grotesk/neue-haas-grotesk-ultra-thin.otf +0 -0
  22. package/dist/templates/code/public/images/preview/hero-home.webp +0 -0
  23. package/dist/templates/code/public/sw.js +35 -0
  24. package/dist/templates/code/src/app/admin/contacts/_components/grid.tsx +1 -1
  25. package/dist/templates/code/src/app/admin/customers/_config/columns-data.tsx +1 -1
  26. package/dist/templates/code/src/app/admin/employees/_config/columns-data.tsx +1 -1
  27. package/dist/templates/code/src/app/admin/history/[id]/layout.tsx +33 -0
  28. package/dist/templates/code/src/app/admin/history/[id]/loading.tsx +11 -0
  29. package/dist/templates/code/src/app/admin/history/[id]/page.tsx +30 -0
  30. package/dist/templates/code/src/app/admin/history/_components/diff.client.tsx +377 -0
  31. package/dist/templates/code/src/app/admin/history/_components/field-comparison.tsx +153 -0
  32. package/dist/templates/code/src/app/admin/history/_components/tables.tsx +47 -0
  33. package/dist/templates/code/src/app/admin/history/_components/toolbars.tsx +54 -0
  34. package/dist/templates/code/src/app/admin/history/_config/columns-data.tsx +61 -0
  35. package/dist/templates/code/src/app/admin/history/_config/filters.ts +46 -0
  36. package/dist/templates/code/src/app/admin/history/_config/sorting.ts +30 -0
  37. package/dist/templates/code/src/app/admin/history/_lib/helpers.ts +98 -0
  38. package/dist/templates/code/src/app/admin/history/_lib/router.ts +100 -0
  39. package/dist/templates/code/src/app/admin/history/_lib/schema.ts +45 -0
  40. package/dist/templates/code/src/app/admin/history/_lib/service.server.ts +30 -0
  41. package/dist/templates/code/src/app/admin/history/_lib/validators.ts +118 -0
  42. package/dist/templates/code/src/app/admin/initiatives/_config/columns-data.tsx +1 -1
  43. package/dist/templates/code/src/app/admin/notifications/_components/command-toggle-push.tsx +115 -0
  44. package/dist/templates/code/src/app/admin/notifications/_components/tables.tsx +37 -0
  45. package/dist/templates/code/src/app/admin/notifications/_components/toolbars.tsx +21 -0
  46. package/dist/templates/code/src/app/admin/notifications/_config/columns-data.tsx +64 -0
  47. package/dist/templates/code/src/app/admin/notifications/_config/commands.tsx +40 -0
  48. package/dist/templates/code/src/app/admin/notifications/_lib/listeners.server.ts +69 -0
  49. package/dist/templates/code/src/app/admin/notifications/_lib/push.server.ts +80 -0
  50. package/dist/templates/code/src/app/admin/notifications/_lib/router.ts +82 -0
  51. package/dist/templates/code/src/app/admin/notifications/_lib/schema.ts +49 -0
  52. package/dist/templates/code/src/app/admin/notifications/_lib/validators.ts +19 -0
  53. package/dist/templates/code/src/app/admin/notifications/layout.tsx +17 -0
  54. package/dist/templates/code/src/app/admin/notifications/loading.tsx +9 -0
  55. package/dist/templates/code/src/app/admin/notifications/page.tsx +28 -0
  56. package/dist/templates/code/src/app/admin/projects/[id]/(default)/page.tsx +1 -1
  57. package/dist/templates/code/src/app/admin/projects/[id]/timeline/page.tsx +1 -1
  58. package/dist/templates/code/src/app/admin/projects/_config/columns-data.tsx +1 -1
  59. package/dist/templates/code/src/app/admin/projects/_lib/events.ts +64 -0
  60. package/dist/templates/code/src/app/admin/projects/_lib/listeners.server.ts +82 -0
  61. package/dist/templates/code/src/app/admin/projects/_lib/router.ts +145 -20
  62. package/dist/templates/code/src/app/admin/projects/timeline/page.tsx +1 -1
  63. package/dist/templates/code/src/app/admin/prospects/_config/columns-data.tsx +1 -1
  64. package/dist/templates/code/src/app/admin/queue/_components/create-command.tsx +97 -0
  65. package/dist/templates/code/src/app/admin/queue/_components/tables.tsx +37 -0
  66. package/dist/templates/code/src/app/admin/queue/_components/toolbars.tsx +16 -0
  67. package/dist/templates/code/src/app/admin/queue/_config/columns-data.tsx +44 -0
  68. package/dist/templates/code/src/app/admin/queue/_config/commands.tsx +39 -0
  69. package/dist/templates/code/src/app/admin/queue/_lib/queue.ts +27 -0
  70. package/dist/templates/code/src/app/admin/queue/_lib/router.ts +33 -0
  71. package/dist/templates/code/src/app/admin/queue/_lib/schema.ts +12 -0
  72. package/dist/templates/code/src/app/admin/queue/_lib/validators.ts +15 -0
  73. package/dist/templates/code/src/app/admin/quotations/(list)/layout.tsx +24 -0
  74. package/dist/templates/code/src/app/admin/quotations/(list)/page.tsx +23 -0
  75. package/dist/templates/code/src/app/admin/quotations/[id]/(dashboard)/layout.tsx +40 -0
  76. package/dist/templates/code/src/app/admin/quotations/[id]/(dashboard)/page.tsx +56 -0
  77. package/dist/templates/code/src/app/admin/quotations/[id]/preview/page.tsx +17 -0
  78. package/dist/templates/code/src/app/admin/quotations/_blocks/commands.tsx +24 -0
  79. package/dist/templates/code/src/app/admin/quotations/_blocks/hero/config.ts +14 -0
  80. package/dist/templates/code/src/app/admin/quotations/_blocks/hero/create.tsx +152 -0
  81. package/dist/templates/code/src/app/admin/quotations/_blocks/hero/inline.tsx +90 -0
  82. package/dist/templates/code/src/app/admin/quotations/_blocks/hero/preview.tsx +90 -0
  83. package/dist/templates/code/src/app/admin/quotations/_blocks/registry.ts +13 -0
  84. package/dist/templates/code/src/app/admin/quotations/_blocks/render.tsx +190 -0
  85. package/dist/templates/code/src/app/admin/quotations/_blocks/styles.ts +24 -0
  86. package/dist/templates/code/src/app/admin/quotations/_blocks/summary/config.ts +13 -0
  87. package/dist/templates/code/src/app/admin/quotations/_blocks/summary/create.tsx +175 -0
  88. package/dist/templates/code/src/app/admin/quotations/_blocks/summary/inline.tsx +93 -0
  89. package/dist/templates/code/src/app/admin/quotations/_blocks/summary/preview.tsx +25 -0
  90. package/dist/templates/code/src/app/admin/quotations/_blocks/totals/config.ts +18 -0
  91. package/dist/templates/code/src/app/admin/quotations/_blocks/totals/create.tsx +177 -0
  92. package/dist/templates/code/src/app/admin/quotations/_blocks/totals/inline.tsx +96 -0
  93. package/dist/templates/code/src/app/admin/quotations/_blocks/totals/preview.tsx +32 -0
  94. package/dist/templates/code/src/app/admin/quotations/_components/create-command.tsx +97 -0
  95. package/dist/templates/code/src/app/admin/quotations/_components/tables.tsx +68 -0
  96. package/dist/templates/code/src/app/admin/quotations/_components/toolbars.tsx +18 -0
  97. package/dist/templates/code/src/app/admin/quotations/_config/columns-data.tsx +35 -0
  98. package/dist/templates/code/src/app/admin/quotations/_config/commands.tsx +71 -0
  99. package/dist/templates/code/src/app/admin/quotations/_config/filters.ts +22 -0
  100. package/dist/templates/code/src/app/admin/quotations/_config/sorting.ts +23 -0
  101. package/dist/templates/code/src/app/admin/quotations/_lib/router.ts +260 -0
  102. package/dist/templates/code/src/app/admin/quotations/_lib/schema.ts +42 -0
  103. package/dist/templates/code/src/app/admin/quotations/_lib/validators.ts +20 -0
  104. package/dist/templates/code/src/app/admin/settings/history/layout.tsx +6 -0
  105. package/dist/templates/code/src/app/admin/settings/history/loading.tsx +5 -0
  106. package/dist/templates/code/src/app/admin/settings/history/page.tsx +13 -0
  107. package/dist/templates/code/src/app/admin/settings/layout.tsx +11 -0
  108. package/dist/templates/code/src/app/admin/settings/queue/loading.tsx +9 -0
  109. package/dist/templates/code/src/app/admin/settings/queue/page.tsx +21 -0
  110. package/dist/templates/code/src/app/admin/tasks/_components/domains-tabs-grid.tsx +1 -1
  111. package/dist/templates/code/src/app/admin/tasks/_config/columns-data.tsx +1 -1
  112. package/dist/templates/code/src/app/admin/tasks/_config/columns-inline.tsx +1 -1
  113. package/dist/templates/code/src/app/admin/tasks/_lib/events.ts +40 -0
  114. package/dist/templates/code/src/app/admin/tasks/_lib/listeners.server.ts +46 -0
  115. package/dist/templates/code/src/app/admin/tasks/_lib/router.ts +222 -90
  116. package/dist/templates/code/src/app/admin/time/_config/columns-data.tsx +1 -1
  117. package/dist/templates/code/src/app/admin/time/_config/columns-inline.tsx +1 -1
  118. package/dist/templates/code/src/app/api/[transport]/route.ts +31 -21
  119. package/dist/templates/code/src/app/api/queue/dispatch/route.ts +4 -0
  120. package/dist/templates/code/src/app/auth/forget-password/page.tsx +1 -1
  121. package/dist/templates/code/src/app/auth/layout.tsx +1 -1
  122. package/dist/templates/code/src/app/auth/reset-password/page.tsx +1 -1
  123. package/dist/templates/code/src/app/layout.tsx +25 -13
  124. package/dist/templates/code/src/env.ts +12 -0
  125. package/dist/templates/code/src/lib/config/paths.tsx +16 -0
  126. package/dist/templates/code/src/lib/utils/fonts.ts +102 -0
  127. package/dist/templates/code/src/server/auth/constants.ts +1 -1
  128. package/dist/templates/code/src/server/auth/init.ts +12 -5
  129. package/dist/templates/code/src/server/auth/permissions.ts +7 -0
  130. package/dist/templates/code/src/server/auth/schema.ts +100 -1
  131. package/dist/templates/code/src/server/db/schema.ts +4 -0
  132. package/dist/templates/code/src/server/events/handlers.ts +17 -0
  133. package/dist/templates/code/src/server/events/init.ts +18 -0
  134. package/dist/templates/code/src/server/events/listeners.ts +17 -0
  135. package/dist/templates/code/src/server/queue/handlers.ts +5 -0
  136. package/dist/templates/code/src/server/queue/init.ts +16 -0
  137. package/dist/templates/code/src/server/router/router.ts +23 -0
  138. package/dist/templates/spark/package.json +64 -65
  139. package/dist/templates/spark/src/app/admin/assignments/[id]/(default)/@dashboard/page.tsx +1 -1
  140. package/dist/templates/spark/src/app/admin/assignments/_config/columns-data.tsx +1 -1
  141. package/dist/templates/spark/src/app/admin/companies/_config/columns-data.tsx +1 -1
  142. package/dist/templates/spark/src/app/admin/contacts/_components/grid.tsx +1 -1
  143. package/dist/templates/spark/src/app/admin/customers/_config/columns-data.tsx +1 -1
  144. package/dist/templates/spark/src/app/admin/drive/_config/columns-data.tsx +1 -1
  145. package/dist/templates/spark/src/app/admin/emails/_components/send-email.tsx +1 -1
  146. package/dist/templates/spark/src/app/admin/emails/_config/columns-data.tsx +1 -1
  147. package/dist/templates/spark/src/app/admin/employees/_config/columns-data.tsx +1 -1
  148. package/dist/templates/spark/src/app/admin/executions/[id]/@dashboard/page.tsx +1 -1
  149. package/dist/templates/spark/src/app/admin/executions/_config/columns-data.tsx +1 -1
  150. package/dist/templates/spark/src/app/admin/products/[id]/(preview)/page.tsx +1 -1
  151. package/dist/templates/spark/src/app/admin/products/_config/columns-data.tsx +1 -1
  152. package/dist/templates/spark/src/app/admin/projects/[id]/(dashboard)/page.tsx +1 -1
  153. package/dist/templates/spark/src/app/admin/projects/[id]/messages/page.tsx +1 -1
  154. package/dist/templates/spark/src/app/admin/projects/[id]/statistics/page.tsx +1 -1
  155. package/dist/templates/spark/src/app/admin/projects/_config/columns-data.tsx +1 -1
  156. package/dist/templates/spark/src/app/admin/purchase-order-items/_config/columns-data.tsx +1 -1
  157. package/dist/templates/spark/src/app/admin/purchase-order-items/_config/columns-inline.tsx +1 -1
  158. package/dist/templates/spark/src/app/admin/purchase-order-items/_config/commands.tsx +1 -1
  159. package/dist/templates/spark/src/app/admin/purchase-orders/[id]/(default)/@dashboard/page.tsx +1 -1
  160. package/dist/templates/spark/src/app/admin/purchase-orders/_config/columns-data.tsx +1 -1
  161. package/dist/templates/spark/src/app/admin/quotation-items/_config/commands.tsx +1 -1
  162. package/dist/templates/spark/src/app/admin/quotations/[id]/(default)/@dashboard/page.tsx +1 -1
  163. package/dist/templates/spark/src/app/admin/quotations/_config/columns-data.tsx +1 -1
  164. package/dist/templates/spark/src/app/admin/run-tasks/_config/columns-data.tsx +1 -1
  165. package/dist/templates/spark/src/app/admin/runs/[id]/@dashboard/page.tsx +1 -1
  166. package/dist/templates/spark/src/app/admin/runs/_config/columns-data.tsx +1 -1
  167. package/dist/templates/spark/src/app/admin/sales-invoices/[id]/@dashboard/page.tsx +1 -1
  168. package/dist/templates/spark/src/app/admin/sales-invoices/_config/columns-data.tsx +1 -1
  169. package/dist/templates/spark/src/app/admin/sales-order-executions/_config/columns-data.tsx +1 -1
  170. package/dist/templates/spark/src/app/admin/sales-order-executions/_config/columns-inline.tsx +1 -1
  171. package/dist/templates/spark/src/app/admin/sales-order-executions/_config/commands.tsx +1 -1
  172. package/dist/templates/spark/src/app/admin/sales-order-items/_config/commands.tsx +1 -1
  173. package/dist/templates/spark/src/app/admin/sales-orders/[id]/(default)/@dashboard/page.tsx +1 -1
  174. package/dist/templates/spark/src/app/admin/sales-orders/[id]/(default)/layout.tsx +1 -1
  175. package/dist/templates/spark/src/app/admin/sales-orders/_config/columns-data.tsx +1 -1
  176. package/dist/templates/spark/src/app/admin/schedule/_components/schedule-card.tsx +1 -1
  177. package/dist/templates/spark/src/app/admin/schedule/_components/schedule-month-view.tsx +1 -1
  178. package/dist/templates/spark/src/app/admin/suppliers/_config/columns-data.tsx +1 -1
  179. package/dist/templates/spark/src/app/admin/tasks/_components/subproject-selector.tsx +1 -1
  180. package/dist/templates/spark/src/app/admin/tasks/_config/columns-data.tsx +1 -1
  181. package/dist/templates/spark/src/app/admin/tasks/_config/columns-inline.tsx +1 -1
  182. package/dist/templates/spark/src/app/admin/tasks/_config/commands.tsx +1 -1
  183. package/dist/templates/spark/src/app/admin/technical-sheets/[id]/page.tsx +1 -1
  184. package/dist/templates/spark/src/app/admin/technical-sheets/_config/columns-data.tsx +1 -1
  185. package/dist/templates/spark/src/app/admin/technical-sheets/_config/commands.tsx +1 -1
  186. package/dist/templates/spark/src/app/admin/templates/_config/columns-data.tsx +1 -1
  187. package/dist/templates/spark/src/app/auth/layout.tsx +1 -1
  188. package/dist/templates/spark/src/app/layout.tsx +25 -10
  189. package/dist/templates/spark/src/server/auth/init.ts +1 -0
  190. package/package.json +6 -6
@@ -0,0 +1,46 @@
1
+ import {
2
+ arrayFilter,
3
+ createFilteringParser,
4
+ defineFilters,
5
+ resolveFiltersSchema,
6
+ stringFilter,
7
+ } from "@tulip-systems/query";
8
+ import { createDatabaseFiltersCompiler } from "@tulip-systems/query/drizzle";
9
+
10
+ /**
11
+ * Filters use stored facts rather than module-specific definitions.
12
+ */
13
+ export const historyFilters = defineFilters({
14
+ resourceType: arrayFilter(stringFilter).build((value) =>
15
+ value?.length ? { field: "resourceType", operator: "in", value } : null,
16
+ ),
17
+ action: arrayFilter(stringFilter).build((value) =>
18
+ value?.length ? { field: "action", operator: "in", value } : null,
19
+ ),
20
+ actorId: arrayFilter(stringFilter).build((value) =>
21
+ value?.length ? { field: "actorId", operator: "in", value } : null,
22
+ ),
23
+ search: stringFilter.build((value) => {
24
+ const search = value?.trim();
25
+ if (!search) return null;
26
+ return {
27
+ or: [
28
+ { field: "resourceLabel", operator: "ilike", value: `%${search}%` },
29
+ { field: "resourceType", operator: "ilike", value: `%${search}%` },
30
+ { field: "action", operator: "ilike", value: `%${search}%` },
31
+ { field: "actorName", operator: "ilike", value: `%${search}%` },
32
+ ],
33
+ };
34
+ }),
35
+ });
36
+
37
+ /**
38
+ * Validators
39
+ */
40
+ export const historyFiltersSchema = resolveFiltersSchema(historyFilters);
41
+
42
+ /**
43
+ * Parsers
44
+ */
45
+ export const historyFiltersParser = createFilteringParser(historyFilters);
46
+ export const historyDatabaseFiltersCompiler = createDatabaseFiltersCompiler(historyFilters);
@@ -0,0 +1,30 @@
1
+ import {
2
+ createSortingParser,
3
+ defineSorting,
4
+ resolveSortingSchema,
5
+ sortOption,
6
+ } from "@tulip-systems/query";
7
+ import { createDatabaseSortingCompiler } from "@tulip-systems/query/drizzle";
8
+
9
+ /**
10
+ * Sorting
11
+ */
12
+ export const historyTableSorts = defineSorting({
13
+ occurredAt: sortOption((direction) => ({ field: "occurredAt", direction })),
14
+ actorName: sortOption((direction) => ({ field: "actorName", direction })),
15
+ resourceLabel: sortOption((direction) => ({ field: "resourceLabel", direction })),
16
+ action: sortOption((direction) => ({ field: "action", direction })),
17
+ });
18
+
19
+ /**
20
+ * Validators
21
+ */
22
+ export const historyTableSortsSchema = resolveSortingSchema(historyTableSorts);
23
+
24
+ /**
25
+ * Parsers
26
+ */
27
+ export const historyTableSortsParser = createSortingParser(historyTableSorts, {
28
+ fallback: [{ key: "occurredAt", direction: "desc" }],
29
+ });
30
+ export const historyTableDatabaseSortsCompiler = createDatabaseSortingCompiler(historyTableSorts);
@@ -0,0 +1,98 @@
1
+ import equal from "fast-deep-equal";
2
+ import { applyPatch, createPatch, Pointer } from "rfc6902";
3
+ import {
4
+ type HistoryChange,
5
+ type HistoryChangesInput,
6
+ type HistoryDiffInput,
7
+ type HistoryFieldChanges,
8
+ historyFieldChangeSchema,
9
+ historyFieldChangesSchema,
10
+ type JSONValue,
11
+ } from "./validators";
12
+
13
+ /**
14
+ * Compare selected fields from complete before/after snapshots.
15
+ * Only changed values are included, in field-configuration order.
16
+ * Values must be JSON; top-level Dates become ISO strings. Use null for absent values.
17
+ * Unselected fields are never read or recorded.
18
+ */
19
+ export function createHistoryChanges<T extends object>({
20
+ before,
21
+ after,
22
+ fields,
23
+ }: HistoryChangesInput<T>): HistoryFieldChanges {
24
+ const changes = fields.flatMap(({ field, label, format }) => {
25
+ const previous = before[field];
26
+ const next = after[field];
27
+ const previousValue = historyFieldChangeSchema.shape.before.parse(
28
+ previous instanceof Date ? previous.toISOString() : previous,
29
+ );
30
+
31
+ const nextValue = historyFieldChangeSchema.shape.after.parse(
32
+ next instanceof Date ? next.toISOString() : next,
33
+ );
34
+
35
+ if (equal(previousValue, nextValue)) return [];
36
+
37
+ return [
38
+ {
39
+ field,
40
+ label,
41
+ before: previousValue,
42
+ after: nextValue,
43
+ beforeLabel: format?.(before),
44
+ afterLabel: format?.(after),
45
+ },
46
+ ];
47
+ });
48
+
49
+ return historyFieldChangesSchema.parse(changes);
50
+ }
51
+
52
+ /**
53
+ * Generate JSON Patch with library comparison; changed arrays remain whole-value replacements.
54
+ */
55
+ export function createHistoryDiff(before: JSONValue, after: JSONValue): HistoryDiffInput {
56
+ // Whole-array replacements leave independent paths, so sorting is safe and deterministic.
57
+ const changes: HistoryChange[] = (
58
+ before === null && after !== null
59
+ ? [{ op: "add" as const, path: "", value: after }]
60
+ : (createPatch(before, after, (previous, next, pointer) => {
61
+ if (Array.isArray(previous) || Array.isArray(next)) {
62
+ return equal(previous, next)
63
+ ? []
64
+ : [{ op: "replace", path: pointer.toString(), value: next }];
65
+ }
66
+ }) as HistoryChange[])
67
+ ).toSorted((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
68
+
69
+ if (!equal(applyHistoryChanges(before, changes), after)) {
70
+ throw new Error("History patch does not reconstruct the after state.");
71
+ }
72
+
73
+ return { snapshot: before, changes, dataVersion: 1 };
74
+ }
75
+
76
+ /**
77
+ * Apply JSON Patch to a copy. Handle root replacement because rfc6902 mutates objects in place.
78
+ */
79
+ export function applyHistoryChanges(snapshot: JSONValue, changes: HistoryChange[]): JSONValue {
80
+ let result = structuredClone(snapshot);
81
+ for (const change of changes) {
82
+ if (change.path === "") {
83
+ result = change.op === "remove" ? null : structuredClone(change.value);
84
+ } else {
85
+ // rfc6902 skips these tokens; reject them rather than apply a different path silently.
86
+ if (
87
+ Pointer.fromJSON(change.path).tokens.some((key) =>
88
+ ["__proto__", "constructor", "prototype"].includes(key),
89
+ )
90
+ ) {
91
+ throw new Error("History patch contains an unsupported property path.");
92
+ }
93
+ const [error] = applyPatch(result, [change]);
94
+ if (error) throw error;
95
+ }
96
+ }
97
+ return result;
98
+ }
@@ -0,0 +1,100 @@
1
+ import "server-cli-only";
2
+ import { offsetPaginationResponse, offsetPaginationSchema } from "@tulip-systems/query";
3
+ import { asc, count, desc, eq } from "drizzle-orm";
4
+ import { z } from "zod";
5
+ import { permissionMiddleware } from "@/server/router/middleware";
6
+ import { protectedProcedure } from "@/server/router/procedures";
7
+ import {
8
+ historyDatabaseFiltersCompiler,
9
+ historyFiltersParser,
10
+ historyFiltersSchema,
11
+ } from "../_config/filters";
12
+ import {
13
+ historyTableDatabaseSortsCompiler,
14
+ historyTableSortsParser,
15
+ historyTableSortsSchema,
16
+ } from "../_config/sorting";
17
+ import { historyDiffs, historyEntries } from "./schema";
18
+
19
+ export const historyRouter = {
20
+ getById: protectedProcedure
21
+ .use(permissionMiddleware({ history: ["view"] }))
22
+ .input(z.object({ id: z.uuid() }))
23
+ .handler(async ({ context, input }) => {
24
+ const [result] = await context.db
25
+ .select({ entry: historyEntries, diff: historyDiffs })
26
+ .from(historyEntries)
27
+ .leftJoin(historyDiffs, eq(historyDiffs.historyEntryId, historyEntries.id))
28
+ .where(eq(historyEntries.id, input.id));
29
+ return result ?? null;
30
+ }),
31
+ list: protectedProcedure
32
+ .use(permissionMiddleware({ history: ["view"] }))
33
+ .input(
34
+ z.object({
35
+ pagination: offsetPaginationSchema,
36
+ filters: historyFiltersSchema,
37
+ sorts: historyTableSortsSchema,
38
+ }),
39
+ )
40
+ .handler(async ({ context, input }) => {
41
+ const parsedSorts = historyTableSortsParser.parse(input.sorts);
42
+ const orderBy = historyTableDatabaseSortsCompiler(historyEntries).parse(parsedSorts);
43
+ const parsedFilters = historyFiltersParser.parse(input.filters);
44
+ const where = historyDatabaseFiltersCompiler(historyEntries).parse(parsedFilters);
45
+
46
+ const [data, [aggregate]] = await Promise.all([
47
+ context.db
48
+ .select()
49
+ .from(historyEntries)
50
+ .where(where)
51
+ .orderBy(...orderBy, desc(historyEntries.id))
52
+ .limit(input.pagination.limit)
53
+ .offset(input.pagination.page * input.pagination.limit),
54
+ context.db.select({ total: count() }).from(historyEntries).where(where),
55
+ ]);
56
+ return offsetPaginationResponse({
57
+ data,
58
+ pagination: input.pagination,
59
+ total: aggregate?.total ?? 0,
60
+ });
61
+ }),
62
+ filterResourceTypes: protectedProcedure
63
+ .use(permissionMiddleware({ history: ["view"] }))
64
+ .handler(({ context }) =>
65
+ context.db
66
+ .selectDistinct({
67
+ value: historyEntries.resourceType,
68
+ label: historyEntries.resourceType,
69
+ })
70
+ .from(historyEntries)
71
+ .orderBy(asc(historyEntries.resourceType)),
72
+ ),
73
+ filterActions: protectedProcedure
74
+ .use(permissionMiddleware({ history: ["view"] }))
75
+ .handler(({ context }) =>
76
+ context.db
77
+ .selectDistinct({
78
+ value: historyEntries.action,
79
+ label: historyEntries.action,
80
+ })
81
+ .from(historyEntries)
82
+ .orderBy(asc(historyEntries.action)),
83
+ ),
84
+ filterActors: protectedProcedure
85
+ .use(permissionMiddleware({ history: ["view"] }))
86
+ .handler(async ({ context }) => {
87
+ // Each account is shown once, using its latest recorded name snapshot.
88
+ const actors = await context.db
89
+ .selectDistinctOn([historyEntries.actorId], {
90
+ value: historyEntries.actorId,
91
+ label: historyEntries.actorName,
92
+ })
93
+ .from(historyEntries)
94
+ .orderBy(historyEntries.actorId, desc(historyEntries.occurredAt), desc(historyEntries.id));
95
+
96
+ return actors.toSorted(
97
+ (a, b) => a.label.localeCompare(b.label) || a.value.localeCompare(b.value),
98
+ );
99
+ }),
100
+ };
@@ -0,0 +1,45 @@
1
+ import { index, pgTable } from "drizzle-orm/pg-core";
2
+ import { baseColumns } from "@/server/db/helpers";
3
+ import type { HistoryChange, HistoryContext, JSONValue } from "./validators";
4
+
5
+ /**
6
+ * History entries table schema.
7
+ */
8
+ export const historyEntries = pgTable(
9
+ "history_entries",
10
+ (t) => ({
11
+ ...baseColumns,
12
+ eventId: t.uuid().notNull().unique("history_entries_event_id_unique"),
13
+ eventName: t.text().notNull(),
14
+ eventVersion: t.integer().notNull(),
15
+ actorId: t.uuid().notNull(),
16
+ actorName: t.text().notNull(),
17
+ occurredAt: t.timestamp({ withTimezone: true, precision: 3 }).notNull(),
18
+ resourceType: t.text().notNull(),
19
+ resourceId: t.text(),
20
+ resourceLabel: t.text(),
21
+ context: t.jsonb().$type<HistoryContext>().default([]).notNull(),
22
+ action: t.text().notNull(),
23
+ }),
24
+ (t) => [
25
+ index("history_entries_time_idx").on(t.occurredAt.desc(), t.id.desc()),
26
+ index("history_entries_resource_time_idx").on(t.resourceType, t.occurredAt.desc(), t.id.desc()),
27
+ index("history_entries_actor_time_idx").on(t.actorId, t.occurredAt.desc(), t.id.desc()),
28
+ index("history_entries_action_time_idx").on(t.action, t.occurredAt.desc(), t.id.desc()),
29
+ ],
30
+ );
31
+
32
+ /**
33
+ * History diffs table schema.
34
+ */
35
+ export const historyDiffs = pgTable("history_diffs", (t) => ({
36
+ ...baseColumns,
37
+ historyEntryId: t
38
+ .uuid()
39
+ .notNull()
40
+ .unique()
41
+ .references(() => historyEntries.id, { onDelete: "cascade" }),
42
+ snapshot: t.jsonb().$type<JSONValue>().notNull(),
43
+ changes: t.jsonb().$type<HistoryChange[]>().notNull(),
44
+ dataVersion: t.integer().default(1).notNull(),
45
+ }));
@@ -0,0 +1,30 @@
1
+ import "server-cli-only";
2
+ import { sql } from "drizzle-orm";
3
+ import { db } from "@/server/db/init";
4
+ import { applyHistoryChanges } from "./helpers";
5
+ import { historyDiffs, historyEntries } from "./schema";
6
+ import { type HistoryEntryInput, historyEntryInputSchema } from "./validators";
7
+
8
+ /**
9
+ * Store an entry and optional diff atomically; duplicate deliveries are ignored and errors propagate.
10
+ */
11
+ export async function createHistoryEntry(input: HistoryEntryInput): Promise<void> {
12
+ const { diff, ...entry } = historyEntryInputSchema.parse(input);
13
+ if (diff) applyHistoryChanges(diff.snapshot, diff.changes);
14
+
15
+ await db.transaction(async (tx) => {
16
+ const [created] = await tx
17
+ .insert(historyEntries)
18
+ .values(entry)
19
+ .onConflictDoNothing({ target: historyEntries.eventId })
20
+ .returning({ id: historyEntries.id });
21
+
22
+ if (created && diff) {
23
+ await tx.insert(historyDiffs).values({
24
+ ...diff,
25
+ historyEntryId: created.id,
26
+ snapshot: diff.snapshot === null ? sql`'null'::jsonb` : diff.snapshot,
27
+ });
28
+ }
29
+ });
30
+ }
@@ -0,0 +1,118 @@
1
+ import { z } from "zod";
2
+ import type { historyDiffs, historyEntries } from "./schema";
3
+
4
+ /**
5
+ * Validate JSON without transforming object keys in complete row snapshots.
6
+ */
7
+ export type JSONValue = z.infer<ReturnType<typeof z.json>>;
8
+ export const historyJSONSchema = z.custom<JSONValue>((value) => z.json().safeParse(value).success);
9
+
10
+ /**
11
+ * History entry type
12
+ */
13
+ export type HistoryEntry = typeof historyEntries.$inferSelect;
14
+
15
+ /**
16
+ * Account identity captured before a tracked mutation.
17
+ */
18
+ export const historyActorSchema = z.object({
19
+ id: z.uuid(),
20
+ name: z.string().trim().min(1),
21
+ });
22
+ export type HistoryActor = z.infer<typeof historyActorSchema>;
23
+
24
+ /**
25
+ * Actor metadata shared by domain events and tracked mutations.
26
+ */
27
+ export const eventHistoryMetadata = { actor: historyActorSchema };
28
+
29
+ /**
30
+ * Historical ancestors, ordered from broadest to nearest by the owning module.
31
+ */
32
+ export const historyContextSchema = z.array(
33
+ z.object({
34
+ type: z.string().trim().min(1),
35
+ id: z.string().trim().min(1),
36
+ label: z.string().trim().min(1),
37
+ }),
38
+ );
39
+ export type HistoryContext = z.infer<typeof historyContextSchema>;
40
+
41
+ /**
42
+ * Field changes preserve raw JSON values and optional display-name snapshots.
43
+ * Null means no value; dates must be supplied as ISO strings.
44
+ */
45
+ export const historyFieldChangeSchema = z.object({
46
+ field: z.string().trim().min(1),
47
+ label: z.string().trim().min(1),
48
+ before: historyJSONSchema,
49
+ after: historyJSONSchema,
50
+ beforeLabel: z.string().optional(),
51
+ afterLabel: z.string().optional(),
52
+ });
53
+ export const historyFieldChangesSchema = z
54
+ .array(historyFieldChangeSchema)
55
+ .refine((changes) => new Set(changes.map((change) => change.field)).size === changes.length, {
56
+ message: "Each field must appear only once in an entry.",
57
+ });
58
+ export type HistoryFieldChange = z.infer<typeof historyFieldChangeSchema>;
59
+ export type HistoryFieldChanges = z.infer<typeof historyFieldChangesSchema>;
60
+
61
+ /**
62
+ * Explicitly selected snapshot fields. Formatters supply labels without changing stored values.
63
+ */
64
+ export type HistoryChangesInput<T extends object> = {
65
+ before: T;
66
+ after: T;
67
+ fields: {
68
+ field: Extract<keyof T, string>;
69
+ label: string;
70
+ format?: (snapshot: T) => string | undefined;
71
+ }[];
72
+ };
73
+
74
+ /**
75
+ * Versioned snapshot diffs. Paths use JSON Pointer; arrays are replaced as whole values.
76
+ */
77
+ const historyPathSchema = z.string().regex(/^(?:\/(?:[^~]|~[01])*)*$/);
78
+ export const historyChangeSchema = z.discriminatedUnion("op", [
79
+ z.object({ op: z.literal("add"), path: historyPathSchema, value: historyJSONSchema }).strict(),
80
+ z.object({ op: z.literal("remove"), path: historyPathSchema }).strict(),
81
+ z
82
+ .object({ op: z.literal("replace"), path: historyPathSchema, value: historyJSONSchema })
83
+ .strict(),
84
+ ]);
85
+ export const historyChangesSchema = z.array(historyChangeSchema);
86
+ export type HistoryChange = z.infer<typeof historyChangeSchema>;
87
+
88
+ /**
89
+ * History diff input schema.
90
+ */
91
+ export const historyDiffInputSchema = z.object({
92
+ snapshot: historyJSONSchema,
93
+ changes: historyChangesSchema,
94
+ dataVersion: z.literal(1).default(1),
95
+ });
96
+
97
+ export type HistoryDiffInput = z.input<typeof historyDiffInputSchema>;
98
+ export type HistoryDiff = typeof historyDiffs.$inferSelect;
99
+
100
+ /**
101
+ * Generic entry input. Modules supply resource snapshots and ordered context.
102
+ */
103
+ export const historyEntryInputSchema = z.object({
104
+ eventId: z.uuid(),
105
+ eventName: z.string().min(1),
106
+ eventVersion: z.number().int().positive(),
107
+ actorId: historyActorSchema.shape.id,
108
+ actorName: historyActorSchema.shape.name,
109
+ occurredAt: z.coerce.date(),
110
+ resourceType: z.string().trim().min(1),
111
+ resourceId: z.string().trim().min(1),
112
+ resourceLabel: z.string().trim().min(1),
113
+ context: historyContextSchema.default([]),
114
+ action: z.string().trim().min(1),
115
+ diff: historyDiffInputSchema.optional(),
116
+ });
117
+
118
+ export type HistoryEntryInput = z.input<typeof historyEntryInputSchema>;
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { formatCurrency, formatDate } from "@tulip-systems/core";
4
+ import { Link } from "@tulip-systems/core/navigation";
4
5
  import { type TableColumnDef, TableColumnHeader } from "@tulip-systems/data-tables";
5
6
  import {
6
7
  createTableSelectCell,
@@ -9,7 +10,6 @@ import {
9
10
  TableStatusCell,
10
11
  TableTextCell,
11
12
  } from "@tulip-systems/data-tables/client";
12
- import Link from "next/link";
13
13
  import type { Initiative } from "../_lib/validators";
14
14
  import { InitiativeStatusField } from "./status";
15
15
 
@@ -0,0 +1,115 @@
1
+ "use client";
2
+
3
+ import { useSuspenseQuery } from "@tanstack/react-query";
4
+ import { CommandClick, CommandLabel } from "@tulip-systems/commands/client";
5
+ import { toast } from "@tulip-systems/ui/sonner";
6
+ import { BellIcon, BellOffIcon } from "lucide-react";
7
+ import { orpc } from "@/server/router/client";
8
+
9
+ const notificationPushSubscriptionQueryKey = ["notifications", "push-subscription"] as const;
10
+
11
+ export function CommandTogglePush() {
12
+ const { data: config } = useSuspenseQuery(orpc.notifications.getPushConfig.queryOptions());
13
+ const { data } = useSuspenseQuery({
14
+ queryKey: notificationPushSubscriptionQueryKey,
15
+ queryFn: async () => {
16
+ const supported =
17
+ typeof window !== "undefined" &&
18
+ "serviceWorker" in navigator &&
19
+ "PushManager" in window &&
20
+ "Notification" in window;
21
+
22
+ if (!supported) return { supported, enabled: false };
23
+
24
+ const registration = await navigator.serviceWorker.getRegistration("/sw.js");
25
+ const subscription = await registration?.pushManager.getSubscription();
26
+
27
+ return { supported, enabled: Boolean(subscription) };
28
+ },
29
+ refetchOnMount: "always",
30
+ staleTime: 0,
31
+ });
32
+
33
+ return (
34
+ <CommandClick
35
+ label={data.enabled ? "Disable push" : "Enable push"}
36
+ disabled={!data.supported}
37
+ variables={{ ...data, config }}
38
+ mutation={{
39
+ mutationFn: async (variables) => {
40
+ if (variables.enabled) {
41
+ const registration = await navigator.serviceWorker.getRegistration("/sw.js");
42
+ const subscription = await registration?.pushManager.getSubscription();
43
+
44
+ if (subscription) {
45
+ await orpc.notifications.unsubscribePush.call({ endpoint: subscription.endpoint });
46
+ await subscription.unsubscribe();
47
+ }
48
+
49
+ return false;
50
+ }
51
+
52
+ if (!variables.supported) throw new Error("This browser does not support Web Push");
53
+ if (!variables.config.configured || !variables.config.publicKey) {
54
+ throw new Error("Web Push is not configured for this environment");
55
+ }
56
+
57
+ if (Notification.permission === "denied") {
58
+ throw new Error("Notifications are blocked for this site");
59
+ }
60
+
61
+ const [registration, permission] = await Promise.all([
62
+ navigator.serviceWorker.register("/sw.js"),
63
+ Notification.requestPermission(),
64
+ ]);
65
+
66
+ if (permission !== "granted") {
67
+ throw new Error("Notification permission was not granted");
68
+ }
69
+
70
+ const publicKey = variables.config.publicKey;
71
+ const padding = "=".repeat((4 - (publicKey.length % 4)) % 4);
72
+ const base64 = `${publicKey}${padding}`.replace(/-/g, "+").replace(/_/g, "/");
73
+ const applicationServerKey = Uint8Array.from(window.atob(base64), (char) =>
74
+ char.charCodeAt(0),
75
+ );
76
+ const subscription = await registration.pushManager.subscribe({
77
+ userVisibleOnly: true,
78
+ applicationServerKey,
79
+ });
80
+ const subscriptionJson = subscription.toJSON();
81
+
82
+ if (!subscriptionJson.keys?.p256dh || !subscriptionJson.keys.auth) {
83
+ await subscription.unsubscribe();
84
+ throw new Error("Browser returned an invalid push subscription");
85
+ }
86
+
87
+ await orpc.notifications.subscribePush.call({
88
+ endpoint: subscription.endpoint,
89
+ expirationTime: subscription.expirationTime,
90
+ keys: {
91
+ p256dh: subscriptionJson.keys.p256dh,
92
+ auth: subscriptionJson.keys.auth,
93
+ },
94
+ });
95
+
96
+ return true;
97
+ },
98
+ meta: { invalidates: [notificationPushSubscriptionQueryKey] },
99
+ onSuccess: (enabled) => {
100
+ toast.success(
101
+ enabled ? "Browser notifications enabled" : "Browser notifications disabled",
102
+ );
103
+ },
104
+ onError: (error) => {
105
+ toast.error(
106
+ error instanceof Error ? error.message : "Could not update push notifications",
107
+ );
108
+ },
109
+ }}
110
+ >
111
+ {data.enabled ? <BellOffIcon className="size-4" /> : <BellIcon className="size-4" />}
112
+ <CommandLabel />
113
+ </CommandClick>
114
+ );
115
+ }
@@ -0,0 +1,37 @@
1
+ "use client";
2
+
3
+ import { useSuspenseQuery } from "@tanstack/react-query";
4
+ import type { CommandFor } from "@tulip-systems/commands";
5
+ import type { TableColumnDef } from "@tulip-systems/data-tables";
6
+ import {
7
+ createTableConfig,
8
+ TableConfigProvider,
9
+ useLocalStrategy,
10
+ } from "@tulip-systems/data-tables/client";
11
+ import type { PropsWithChildren } from "react";
12
+ import { orpc } from "@/server/router/client";
13
+ import type { Notification } from "../_lib/validators";
14
+
15
+ type NotificationTableProviderProps = PropsWithChildren<{
16
+ columns: TableColumnDef<Notification>[];
17
+ commands?: CommandFor<Notification[]>[];
18
+ }>;
19
+
20
+ export function NotificationTableProvider({
21
+ columns,
22
+ commands,
23
+ children,
24
+ }: NotificationTableProviderProps) {
25
+ const response = useSuspenseQuery(orpc.notifications.list.queryOptions());
26
+ const strategy = useLocalStrategy({ total: response.data.length });
27
+
28
+ const config = createTableConfig<Notification>({
29
+ queryData: response.data,
30
+ getRowId: (row) => row.id,
31
+ columns,
32
+ strategy,
33
+ commands,
34
+ });
35
+
36
+ return <TableConfigProvider config={config}>{children}</TableConfigProvider>;
37
+ }
@@ -0,0 +1,21 @@
1
+ "use client";
2
+
3
+ import { TableToolbar, TableToolbarList } from "@tulip-systems/data-tables";
4
+ import type { PropsWithChildren } from "react";
5
+
6
+ export function NotificationTableToolbar({ children }: PropsWithChildren) {
7
+ return (
8
+ <TableToolbar>
9
+ <TableToolbarList>
10
+ <div>
11
+ <h1 className="font-semibold text-lg tracking-tight">Notifications</h1>
12
+ <p className="text-muted-foreground text-sm">
13
+ Created by event listeners running through the queue.
14
+ </p>
15
+ </div>
16
+ </TableToolbarList>
17
+
18
+ <TableToolbarList>{children}</TableToolbarList>
19
+ </TableToolbar>
20
+ );
21
+ }