@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,64 @@
1
+ "use client";
2
+
3
+ import { formatDateTime } from "@tulip-systems/core";
4
+ import { Link } from "@tulip-systems/core/navigation";
5
+ import { TableCell, type TableColumnDef, TableColumnHeader } from "@tulip-systems/data-tables";
6
+ import { createTableSelectCell } from "@tulip-systems/data-tables/client";
7
+ import type { Notification } from "../_lib/validators";
8
+
9
+ export const notificationColumns: TableColumnDef<Notification>[] = [
10
+ createTableSelectCell(),
11
+ {
12
+ accessorKey: "title",
13
+ header: ({ column }) => <TableColumnHeader column={column} title="Title" />,
14
+ cell: ({ row }) => (
15
+ <TableCell>
16
+ <div className="font-medium">{row.original.title}</div>
17
+ <div className="max-w-[520px] truncate text-muted-foreground text-xs">
18
+ {row.original.body}
19
+ </div>
20
+ </TableCell>
21
+ ),
22
+ enableSorting: false,
23
+ },
24
+ {
25
+ accessorKey: "eventName",
26
+ header: ({ column }) => <TableColumnHeader column={column} title="Event" />,
27
+ cell: ({ row }) => <TableCell>{row.original.eventName}</TableCell>,
28
+ enableSorting: false,
29
+ },
30
+ {
31
+ accessorKey: "listenerKey",
32
+ header: ({ column }) => <TableColumnHeader column={column} title="Listener" />,
33
+ cell: ({ row }) => <TableCell>{row.original.listenerKey}</TableCell>,
34
+ enableSorting: false,
35
+ },
36
+ {
37
+ accessorKey: "sourceHref",
38
+ header: ({ column }) => <TableColumnHeader column={column} title="Source" />,
39
+ cell: ({ row }) => (
40
+ <TableCell>
41
+ {row.original.sourceHref ? (
42
+ <Link href={row.original.sourceHref} className="hover:underline">
43
+ {row.original.source}
44
+ </Link>
45
+ ) : (
46
+ row.original.source
47
+ )}
48
+ </TableCell>
49
+ ),
50
+ enableSorting: false,
51
+ },
52
+ {
53
+ accessorKey: "occurredAt",
54
+ header: ({ column }) => <TableColumnHeader column={column} title="Occurred" />,
55
+ cell: ({ row }) => <TableCell>{formatDateTime(row.original.occurredAt)}</TableCell>,
56
+ enableSorting: false,
57
+ },
58
+ {
59
+ accessorKey: "readAt",
60
+ header: ({ column }) => <TableColumnHeader column={column} title="Status" />,
61
+ cell: ({ row }) => <TableCell>{row.original.readAt ? "Read" : "Unread"}</TableCell>,
62
+ enableSorting: false,
63
+ },
64
+ ];
@@ -0,0 +1,40 @@
1
+ "use client";
2
+
3
+ import { commandBuilder } from "@tulip-systems/commands";
4
+ import { CommandClick, CommandLabel, DeleteCommand } from "@tulip-systems/commands/client";
5
+ import { toast } from "@tulip-systems/ui/sonner";
6
+ import { CheckIcon } from "lucide-react";
7
+ import { orpc } from "@/server/router/client";
8
+ import { CommandTogglePush } from "../_components/command-toggle-push";
9
+ import type { Notification } from "../_lib/validators";
10
+
11
+ export const notificationTogglePushCommand = commandBuilder.render(() => <CommandTogglePush />);
12
+
13
+ export const notificationMarkAsReadCommand = commandBuilder
14
+ .$type<Notification[]>()
15
+ .visibleWhen(({ data }) => data.some((item) => !item.readAt))
16
+ .render(({ data }) => (
17
+ <CommandClick
18
+ label="Mark as read"
19
+ variables={{ ids: data.map((item) => item.id) }}
20
+ mutation={orpc.notifications.markAsRead.mutationOptions({
21
+ meta: { invalidates: [orpc.notifications.list.key()] },
22
+ onSuccess: () => toast.success("Notifications marked as read"),
23
+ })}
24
+ >
25
+ <CheckIcon className="size-4" />
26
+ <CommandLabel />
27
+ </CommandClick>
28
+ ));
29
+
30
+ export const notificationDeleteCommand = commandBuilder
31
+ .$type<Notification[]>()
32
+ .render(({ data }) => (
33
+ <DeleteCommand
34
+ variables={{ ids: data.map((item) => item.id) }}
35
+ mutation={orpc.notifications.delete.mutationOptions({
36
+ meta: { invalidates: [orpc.notifications.list.key()] },
37
+ onSuccess: () => toast.success("Notifications deleted"),
38
+ })}
39
+ />
40
+ ));
@@ -0,0 +1,69 @@
1
+ import "server-cli-only";
2
+
3
+ import { projectCreatedEvent, projectUpdatedEvent } from "@/app/admin/projects/_lib/events";
4
+ import { taskCreatedEvent } from "@/app/admin/tasks/_lib/events";
5
+ import { db } from "@/server/db/init";
6
+ import { sendPushNotification } from "./push.server";
7
+ import { notifications } from "./schema";
8
+
9
+ export const notificationEventListeners = {
10
+ projectCreated: projectCreatedEvent.listen(async ({ payload, event }) => {
11
+ const [notification] = await db
12
+ .insert(notifications)
13
+ .values({
14
+ title: "Project created",
15
+ body: `${payload.title} was created with status ${payload.status}.`,
16
+ eventId: event.id,
17
+ eventName: event.name,
18
+ eventVersion: event.version,
19
+ listenerKey: "notifications:projectCreated",
20
+ source: "project",
21
+ sourceId: payload.projectId,
22
+ sourceHref: `/admin/projects/${payload.projectId}`,
23
+ occurredAt: new Date(event.occurredAt),
24
+ })
25
+ .returning();
26
+
27
+ if (notification) await sendPushNotification(notification);
28
+ }),
29
+ projectStatusChanged: projectUpdatedEvent.listen(async ({ payload, event }) => {
30
+ if (payload.before.status === payload.after.status) return;
31
+
32
+ const [notification] = await db
33
+ .insert(notifications)
34
+ .values({
35
+ title: "Project status changed",
36
+ body: `${payload.title} moved from ${payload.before.status} to ${payload.after.status}.`,
37
+ eventId: event.id,
38
+ eventName: event.name,
39
+ eventVersion: event.version,
40
+ listenerKey: "notifications:projectStatusChanged",
41
+ source: "project",
42
+ sourceId: payload.projectId,
43
+ sourceHref: `/admin/projects/${payload.projectId}`,
44
+ occurredAt: new Date(event.occurredAt),
45
+ })
46
+ .returning();
47
+
48
+ if (notification) await sendPushNotification(notification);
49
+ }),
50
+ taskCreated: taskCreatedEvent.listen(async ({ payload, event }) => {
51
+ const [notification] = await db
52
+ .insert(notifications)
53
+ .values({
54
+ title: "Task created",
55
+ body: `${payload.code} ${payload.title} was added to ${payload.projectTitle}.`,
56
+ eventId: event.id,
57
+ eventName: event.name,
58
+ eventVersion: event.version,
59
+ listenerKey: "notifications:taskCreated",
60
+ source: "task",
61
+ sourceId: payload.taskId,
62
+ sourceHref: `/admin/tasks/${payload.taskId}`,
63
+ occurredAt: new Date(event.occurredAt),
64
+ })
65
+ .returning();
66
+
67
+ if (notification) await sendPushNotification(notification);
68
+ }),
69
+ };
@@ -0,0 +1,80 @@
1
+ import "server-cli-only";
2
+
3
+ import { eq } from "drizzle-orm";
4
+ import webpush from "web-push";
5
+ import { env } from "@/env";
6
+ import { db } from "@/server/db/init";
7
+ import { notificationPushSubscriptions } from "./schema";
8
+ import type { Notification } from "./validators";
9
+
10
+ const publicKey = env.NEXT_PUBLIC_WEB_PUSH_PUBLIC_KEY;
11
+ const privateKey = env.WEB_PUSH_PRIVATE_KEY;
12
+ const subject = env.WEB_PUSH_SUBJECT;
13
+
14
+ const isWebPushConfigured = Boolean(publicKey && privateKey && subject);
15
+
16
+ if (publicKey && privateKey && subject) {
17
+ webpush.setVapidDetails(subject, publicKey, privateKey);
18
+ }
19
+
20
+ /**
21
+ * Returns the browser-facing Web Push configuration.
22
+ *
23
+ * The client uses this to know whether push is available in the current
24
+ * environment and which public VAPID key to pass to the browser subscription.
25
+ */
26
+ export function getWebPushConfig() {
27
+ return {
28
+ publicKey: publicKey ?? null,
29
+ configured: isWebPushConfigured,
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Sends a notification row to every stored browser push subscription.
35
+ *
36
+ * This is a best-effort side effect: if VAPID keys are not configured, it no-ops
37
+ * so database notifications keep working. Expired browser subscriptions are
38
+ * deleted when the push service reports them as gone.
39
+ */
40
+ export async function sendPushNotification(notification: Notification) {
41
+ if (!isWebPushConfigured) return;
42
+
43
+ const subscriptions = await db.query.notificationPushSubscriptions.findMany();
44
+ if (subscriptions.length === 0) return;
45
+
46
+ const payload = JSON.stringify({
47
+ title: notification.title,
48
+ body: notification.body,
49
+ url: notification.sourceHref ?? "/admin/notifications",
50
+ notificationId: notification.id,
51
+ });
52
+
53
+ await Promise.all(
54
+ subscriptions.map(async (subscription) => {
55
+ try {
56
+ await webpush.sendNotification(
57
+ {
58
+ endpoint: subscription.endpoint,
59
+ keys: {
60
+ p256dh: subscription.p256dh,
61
+ auth: subscription.auth,
62
+ },
63
+ },
64
+ payload,
65
+ );
66
+ } catch (error) {
67
+ const statusCode = (error as { statusCode?: number }).statusCode;
68
+
69
+ if (statusCode === 404 || statusCode === 410) {
70
+ await db
71
+ .delete(notificationPushSubscriptions)
72
+ .where(eq(notificationPushSubscriptions.endpoint, subscription.endpoint));
73
+ return;
74
+ }
75
+
76
+ console.error("[notifications] Failed to send Web Push notification", error);
77
+ }
78
+ }),
79
+ );
80
+ }
@@ -0,0 +1,82 @@
1
+ import "server-cli-only";
2
+
3
+ import { ORPCError } from "@orpc/server";
4
+ import { and, desc, eq, inArray } from "drizzle-orm";
5
+ import { z } from "zod";
6
+ import { protectedProcedure } from "@/server/router/procedures";
7
+ import { getWebPushConfig } from "./push.server";
8
+ import { notificationPushSubscriptions, notifications } from "./schema";
9
+ import { pushSubscriptionSchema } from "./validators";
10
+
11
+ export const notificationsRouter = {
12
+ list: protectedProcedure.handler(async ({ context }) =>
13
+ context.db.query.notifications.findMany({
14
+ orderBy: [desc(notifications.createdAt)],
15
+ limit: 100,
16
+ }),
17
+ ),
18
+ getPushConfig: protectedProcedure.handler(() => getWebPushConfig()),
19
+ subscribePush: protectedProcedure
20
+ .input(pushSubscriptionSchema)
21
+ .handler(async ({ context, input }) => {
22
+ const config = getWebPushConfig();
23
+
24
+ if (!config.configured) {
25
+ throw new ORPCError("BAD_REQUEST", {
26
+ message: "Web Push is not configured for this environment",
27
+ });
28
+ }
29
+
30
+ const now = new Date();
31
+ const expiresAt = input.expirationTime ? new Date(input.expirationTime) : null;
32
+
33
+ await context.db
34
+ .insert(notificationPushSubscriptions)
35
+ .values({
36
+ userId: context.user.id,
37
+ endpoint: input.endpoint,
38
+ p256dh: input.keys.p256dh,
39
+ auth: input.keys.auth,
40
+ userAgent: context.headers.get("user-agent"),
41
+ lastSeenAt: now,
42
+ expiresAt,
43
+ })
44
+ .onConflictDoUpdate({
45
+ target: notificationPushSubscriptions.endpoint,
46
+ set: {
47
+ userId: context.user.id,
48
+ p256dh: input.keys.p256dh,
49
+ auth: input.keys.auth,
50
+ userAgent: context.headers.get("user-agent"),
51
+ lastSeenAt: now,
52
+ expiresAt,
53
+ updatedAt: now,
54
+ },
55
+ });
56
+ }),
57
+ unsubscribePush: protectedProcedure
58
+ .input(z.object({ endpoint: z.url() }))
59
+ .handler(async ({ context, input }) => {
60
+ await context.db
61
+ .delete(notificationPushSubscriptions)
62
+ .where(
63
+ and(
64
+ eq(notificationPushSubscriptions.endpoint, input.endpoint),
65
+ eq(notificationPushSubscriptions.userId, context.user.id),
66
+ ),
67
+ );
68
+ }),
69
+ markAsRead: protectedProcedure
70
+ .input(z.object({ ids: z.array(z.string()) }))
71
+ .handler(async ({ context, input }) => {
72
+ await context.db
73
+ .update(notifications)
74
+ .set({ readAt: new Date() })
75
+ .where(inArray(notifications.id, input.ids));
76
+ }),
77
+ delete: protectedProcedure
78
+ .input(z.object({ ids: z.array(z.string()) }))
79
+ .handler(async ({ context, input }) => {
80
+ await context.db.delete(notifications).where(inArray(notifications.id, input.ids));
81
+ }),
82
+ };
@@ -0,0 +1,49 @@
1
+ import { relations } from "drizzle-orm";
2
+ import { pgTable } from "drizzle-orm/pg-core";
3
+ import { users } from "@/server/auth/schema";
4
+ import { baseColumns } from "@/server/db/helpers";
5
+
6
+ /**
7
+ * Notifications
8
+ */
9
+ export const notifications = pgTable("notifications", (t) => ({
10
+ ...baseColumns,
11
+ title: t.varchar().notNull(),
12
+ body: t.text().notNull(),
13
+ eventId: t.varchar().notNull(),
14
+ eventName: t.varchar().notNull(),
15
+ eventVersion: t.integer().notNull(),
16
+ listenerKey: t.varchar().notNull(),
17
+ source: t.varchar().notNull(),
18
+ sourceId: t.uuid().notNull(),
19
+ sourceHref: t.varchar(),
20
+ occurredAt: t.timestamp().notNull(),
21
+ readAt: t.timestamp(),
22
+ }));
23
+
24
+ /**
25
+ * Notification Push Subscriptions
26
+ */
27
+ export const notificationPushSubscriptions = pgTable("notification_push_subscriptions", (t) => ({
28
+ ...baseColumns,
29
+ userId: t
30
+ .uuid()
31
+ .notNull()
32
+ .references(() => users.id, { onDelete: "cascade" }),
33
+ endpoint: t.text().notNull().unique(),
34
+ p256dh: t.text().notNull(),
35
+ auth: t.text().notNull(),
36
+ userAgent: t.text(),
37
+ lastSeenAt: t.timestamp().defaultNow().notNull(),
38
+ expiresAt: t.timestamp(),
39
+ }));
40
+
41
+ export const notificationPushSubscriptionsRelations = relations(
42
+ notificationPushSubscriptions,
43
+ ({ one }) => ({
44
+ user: one(users, {
45
+ fields: [notificationPushSubscriptions.userId],
46
+ references: [users.id],
47
+ }),
48
+ }),
49
+ );
@@ -0,0 +1,19 @@
1
+ import { z } from "zod";
2
+ import type { notifications } from "./schema";
3
+
4
+ /**
5
+ * Notification
6
+ */
7
+ export type Notification = typeof notifications.$inferSelect;
8
+
9
+ /**
10
+ * Push Subscription Schema
11
+ */
12
+ export const pushSubscriptionSchema = z.object({
13
+ endpoint: z.url(),
14
+ expirationTime: z.number().nullable().optional(),
15
+ keys: z.object({
16
+ p256dh: z.string().min(1),
17
+ auth: z.string().min(1),
18
+ }),
19
+ });
@@ -0,0 +1,17 @@
1
+ import { Header, Topbar, TopbarTitle } from "@tulip-systems/app/components/client";
2
+ import { TableLayout } from "@tulip-systems/data-tables";
3
+ import type { PropsWithChildren } from "react";
4
+
5
+ export default function Layout({ children }: PropsWithChildren) {
6
+ return (
7
+ <>
8
+ <Header>
9
+ <Topbar>
10
+ <TopbarTitle breadcrumbs={[{ label: "Notifications" }]} />
11
+ </Topbar>
12
+ </Header>
13
+
14
+ <TableLayout className="p-content">{children}</TableLayout>
15
+ </>
16
+ );
17
+ }
@@ -0,0 +1,9 @@
1
+ import { TableSkeleton } from "@tulip-systems/data-tables";
2
+
3
+ export default function Loading() {
4
+ return (
5
+ <div className="p-content">
6
+ <TableSkeleton />
7
+ </div>
8
+ );
9
+ }
@@ -0,0 +1,28 @@
1
+ import { InlineCommandMenu, InlineCommandMenuLoading } from "@tulip-systems/commands/client";
2
+ import { DataTable } from "@tulip-systems/data-tables/client";
3
+ import { Suspense } from "react";
4
+ import { NotificationTableProvider } from "./_components/tables";
5
+ import { NotificationTableToolbar } from "./_components/toolbars";
6
+ import { notificationColumns } from "./_config/columns-data";
7
+ import {
8
+ notificationDeleteCommand,
9
+ notificationMarkAsReadCommand,
10
+ notificationTogglePushCommand,
11
+ } from "./_config/commands";
12
+
13
+ export default function Page() {
14
+ return (
15
+ <NotificationTableProvider
16
+ columns={notificationColumns}
17
+ commands={[notificationMarkAsReadCommand, notificationDeleteCommand]}
18
+ >
19
+ <NotificationTableToolbar>
20
+ <Suspense fallback={<InlineCommandMenuLoading />}>
21
+ <InlineCommandMenu data={null} commands={[notificationTogglePushCommand]} />
22
+ </Suspense>
23
+ </NotificationTableToolbar>
24
+
25
+ <DataTable />
26
+ </NotificationTableProvider>
27
+ );
28
+ }
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useSuspenseQueries, useSuspenseQuery } from "@tanstack/react-query";
4
+ import { Link } from "@tulip-systems/core/navigation";
4
5
  import { EditorContent, EditorMenuFixed } from "@tulip-systems/editor/client";
5
6
  import {
6
7
  InlineComboboxDropdown,
@@ -20,7 +21,6 @@ import {
20
21
  DataStackTitle,
21
22
  DataStackValue,
22
23
  } from "@tulip-systems/ui/data-stack";
23
- import Link from "next/link";
24
24
  import { notFound } from "next/navigation";
25
25
  import { use } from "react";
26
26
  import { z } from "zod";
@@ -2,11 +2,11 @@
2
2
 
3
3
  import { Temporal } from "@js-temporal/polyfill";
4
4
  import { useSuspenseQuery } from "@tanstack/react-query";
5
+ import { Link } from "@tulip-systems/core/navigation";
5
6
  import { Button } from "@tulip-systems/ui/button";
6
7
  import { Select, SelectContent, SelectItem, SelectTrigger } from "@tulip-systems/ui/select";
7
8
  import { findStatus } from "@tulip-systems/ui/status";
8
9
  import { MinusIcon, PlusIcon } from "lucide-react";
9
- import Link from "next/link";
10
10
  import { parseAsStringEnum, useQueryState } from "nuqs";
11
11
  import { use, useState } from "react";
12
12
  import { initiativeStatusConfig } from "@/app/admin/initiatives/_config/status";
@@ -1,12 +1,12 @@
1
1
  "use client";
2
2
 
3
+ import { Link } from "@tulip-systems/core/navigation";
3
4
  import { type TableColumnDef, TableColumnHeader } from "@tulip-systems/data-tables";
4
5
  import {
5
6
  createTableSelectCell,
6
7
  TableStatusCell,
7
8
  TableTextCell,
8
9
  } from "@tulip-systems/data-tables/client";
9
- import Link from "next/link";
10
10
  import type { Customer } from "../../customers/_lib/validators";
11
11
  import type { Project } from "../_lib/validators";
12
12
  import { ProjectStatusField } from "./status";
@@ -0,0 +1,64 @@
1
+ import { defineEvent } from "@tulip-systems/events";
2
+ import { z } from "zod";
3
+ import { eventHistoryMetadata, historyJSONSchema } from "@/app/admin/history/_lib/validators";
4
+ import { projectStatusSchema } from "./validators";
5
+
6
+ /**
7
+ * Published after a project is created. Carries the actor, project identity, initial status and complete inserted row snapshot.
8
+ */
9
+ export const projectCreatedEvent = defineEvent("project.created", {
10
+ version: 2,
11
+ schema: z.object({
12
+ ...eventHistoryMetadata,
13
+ projectId: z.uuid(),
14
+ key: z.string(),
15
+ title: z.string(),
16
+ status: projectStatusSchema,
17
+ after: z.record(z.string(), historyJSONSchema),
18
+ }),
19
+ });
20
+
21
+ /**
22
+ * Published after a project update. Carries actor, identity and complete before/after row snapshots.
23
+ */
24
+ export const projectUpdatedEvent = defineEvent("project.updated", {
25
+ version: 1,
26
+ schema: z.object({
27
+ ...eventHistoryMetadata,
28
+ projectId: z.uuid(),
29
+ key: z.string(),
30
+ title: z.string(),
31
+ before: z.record(z.string(), historyJSONSchema),
32
+ after: z.record(z.string(), historyJSONSchema),
33
+ }),
34
+ });
35
+
36
+ /**
37
+ * Published for each active project that is archived. Carries actor, identity and complete before/after row snapshots.
38
+ */
39
+ export const projectArchivedEvent = defineEvent("project.archived", {
40
+ version: 2,
41
+ schema: z.object({
42
+ ...eventHistoryMetadata,
43
+ projectId: z.uuid(),
44
+ key: z.string(),
45
+ title: z.string(),
46
+ before: z.record(z.string(), historyJSONSchema),
47
+ after: z.record(z.string(), historyJSONSchema),
48
+ }),
49
+ });
50
+
51
+ /**
52
+ * Published for each archived project that is restored. Carries actor, identity and complete before/after row snapshots.
53
+ */
54
+ export const projectRestoredEvent = defineEvent("project.restored", {
55
+ version: 2,
56
+ schema: z.object({
57
+ ...eventHistoryMetadata,
58
+ projectId: z.uuid(),
59
+ key: z.string(),
60
+ title: z.string(),
61
+ before: z.record(z.string(), historyJSONSchema),
62
+ after: z.record(z.string(), historyJSONSchema),
63
+ }),
64
+ });
@@ -0,0 +1,82 @@
1
+ import "server-cli-only";
2
+ import { createHistoryDiff } from "@/app/admin/history/_lib/helpers";
3
+ import { createHistoryEntry } from "@/app/admin/history/_lib/service.server";
4
+ import {
5
+ projectArchivedEvent,
6
+ projectCreatedEvent,
7
+ projectRestoredEvent,
8
+ projectUpdatedEvent,
9
+ } from "./events";
10
+
11
+ /**
12
+ * Projects supply historical resource labels for overview entries.
13
+ */
14
+ export const projectHistoryEventListeners = {
15
+ projectCreated: projectCreatedEvent.listen(async ({ event, payload: p }) => {
16
+ await createHistoryEntry({
17
+ eventId: event.id,
18
+ eventName: event.name,
19
+ eventVersion: event.version,
20
+ occurredAt: event.occurredAt,
21
+ actorId: p.actor.id,
22
+ actorName: p.actor.name,
23
+ resourceType: "projects",
24
+ resourceId: p.projectId,
25
+ resourceLabel: `${p.key} · ${p.title}`,
26
+ context: [],
27
+ action: "created",
28
+ diff: createHistoryDiff(null, p.after),
29
+ });
30
+ }),
31
+ projectUpdated: projectUpdatedEvent.listen(async ({ event, payload: p }) => {
32
+ const diff = createHistoryDiff(p.before, p.after);
33
+ if (!diff.changes.some((change) => change.path !== "/updated_at")) return;
34
+
35
+ await createHistoryEntry({
36
+ eventId: event.id,
37
+ eventName: event.name,
38
+ eventVersion: event.version,
39
+ occurredAt: event.occurredAt,
40
+ actorId: p.actor.id,
41
+ actorName: p.actor.name,
42
+ resourceType: "projects",
43
+ resourceId: p.projectId,
44
+ resourceLabel: `${p.key} · ${p.title}`,
45
+ context: [],
46
+ action: "updated",
47
+ diff,
48
+ });
49
+ }),
50
+ projectArchived: projectArchivedEvent.listen(async ({ event, payload: p }) => {
51
+ await createHistoryEntry({
52
+ eventId: event.id,
53
+ eventName: event.name,
54
+ eventVersion: event.version,
55
+ occurredAt: event.occurredAt,
56
+ actorId: p.actor.id,
57
+ actorName: p.actor.name,
58
+ resourceType: "projects",
59
+ resourceId: p.projectId,
60
+ resourceLabel: `${p.key} · ${p.title}`,
61
+ context: [],
62
+ action: "archived",
63
+ diff: createHistoryDiff(p.before, p.after),
64
+ });
65
+ }),
66
+ projectRestored: projectRestoredEvent.listen(async ({ event, payload: p }) => {
67
+ await createHistoryEntry({
68
+ eventId: event.id,
69
+ eventName: event.name,
70
+ eventVersion: event.version,
71
+ occurredAt: event.occurredAt,
72
+ actorId: p.actor.id,
73
+ actorName: p.actor.name,
74
+ resourceType: "projects",
75
+ resourceId: p.projectId,
76
+ resourceLabel: `${p.key} · ${p.title}`,
77
+ context: [],
78
+ action: "restored",
79
+ diff: createHistoryDiff(p.before, p.after),
80
+ });
81
+ }),
82
+ };