@tulip-systems/create-app 0.12.4 → 0.14.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 (143) 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 +80 -72
  6. package/dist/templates/code/public/sw.js +35 -0
  7. package/dist/templates/code/src/app/admin/contacts/_components/grid.tsx +1 -1
  8. package/dist/templates/code/src/app/admin/customers/_config/columns-data.tsx +1 -1
  9. package/dist/templates/code/src/app/admin/employees/_config/columns-data.tsx +1 -1
  10. package/dist/templates/code/src/app/admin/history/[id]/layout.tsx +33 -0
  11. package/dist/templates/code/src/app/admin/history/[id]/loading.tsx +11 -0
  12. package/dist/templates/code/src/app/admin/history/[id]/page.tsx +30 -0
  13. package/dist/templates/code/src/app/admin/history/_components/diff.client.tsx +377 -0
  14. package/dist/templates/code/src/app/admin/history/_components/field-comparison.tsx +153 -0
  15. package/dist/templates/code/src/app/admin/history/_components/tables.tsx +47 -0
  16. package/dist/templates/code/src/app/admin/history/_components/toolbars.tsx +54 -0
  17. package/dist/templates/code/src/app/admin/history/_config/columns-data.tsx +61 -0
  18. package/dist/templates/code/src/app/admin/history/_config/filters.ts +46 -0
  19. package/dist/templates/code/src/app/admin/history/_config/sorting.ts +30 -0
  20. package/dist/templates/code/src/app/admin/history/_lib/helpers.ts +98 -0
  21. package/dist/templates/code/src/app/admin/history/_lib/router.ts +100 -0
  22. package/dist/templates/code/src/app/admin/history/_lib/schema.ts +45 -0
  23. package/dist/templates/code/src/app/admin/history/_lib/service.server.ts +30 -0
  24. package/dist/templates/code/src/app/admin/history/_lib/validators.ts +118 -0
  25. package/dist/templates/code/src/app/admin/initiatives/_config/columns-data.tsx +1 -1
  26. package/dist/templates/code/src/app/admin/notifications/_components/command-toggle-push.tsx +115 -0
  27. package/dist/templates/code/src/app/admin/notifications/_components/tables.tsx +37 -0
  28. package/dist/templates/code/src/app/admin/notifications/_components/toolbars.tsx +21 -0
  29. package/dist/templates/code/src/app/admin/notifications/_config/columns-data.tsx +64 -0
  30. package/dist/templates/code/src/app/admin/notifications/_config/commands.tsx +40 -0
  31. package/dist/templates/code/src/app/admin/notifications/_lib/listeners.server.ts +69 -0
  32. package/dist/templates/code/src/app/admin/notifications/_lib/push.server.ts +80 -0
  33. package/dist/templates/code/src/app/admin/notifications/_lib/router.ts +82 -0
  34. package/dist/templates/code/src/app/admin/notifications/_lib/schema.ts +49 -0
  35. package/dist/templates/code/src/app/admin/notifications/_lib/validators.ts +19 -0
  36. package/dist/templates/code/src/app/admin/notifications/layout.tsx +17 -0
  37. package/dist/templates/code/src/app/admin/notifications/loading.tsx +9 -0
  38. package/dist/templates/code/src/app/admin/notifications/page.tsx +28 -0
  39. package/dist/templates/code/src/app/admin/projects/[id]/(default)/page.tsx +1 -1
  40. package/dist/templates/code/src/app/admin/projects/[id]/timeline/page.tsx +1 -1
  41. package/dist/templates/code/src/app/admin/projects/_config/columns-data.tsx +1 -1
  42. package/dist/templates/code/src/app/admin/projects/_lib/events.ts +64 -0
  43. package/dist/templates/code/src/app/admin/projects/_lib/listeners.server.ts +82 -0
  44. package/dist/templates/code/src/app/admin/projects/_lib/router.ts +145 -20
  45. package/dist/templates/code/src/app/admin/projects/timeline/page.tsx +1 -1
  46. package/dist/templates/code/src/app/admin/prospects/_config/columns-data.tsx +1 -1
  47. package/dist/templates/code/src/app/admin/queue/_components/create-command.tsx +97 -0
  48. package/dist/templates/code/src/app/admin/queue/_components/tables.tsx +37 -0
  49. package/dist/templates/code/src/app/admin/queue/_components/toolbars.tsx +16 -0
  50. package/dist/templates/code/src/app/admin/queue/_config/columns-data.tsx +44 -0
  51. package/dist/templates/code/src/app/admin/queue/_config/commands.tsx +39 -0
  52. package/dist/templates/code/src/app/admin/queue/_lib/queue.ts +27 -0
  53. package/dist/templates/code/src/app/admin/queue/_lib/router.ts +33 -0
  54. package/dist/templates/code/src/app/admin/queue/_lib/schema.ts +12 -0
  55. package/dist/templates/code/src/app/admin/queue/_lib/validators.ts +15 -0
  56. package/dist/templates/code/src/app/admin/quotations/_config/columns-data.tsx +1 -1
  57. package/dist/templates/code/src/app/admin/quotations/_config/commands.tsx +1 -1
  58. package/dist/templates/code/src/app/admin/settings/history/layout.tsx +6 -0
  59. package/dist/templates/code/src/app/admin/settings/history/loading.tsx +5 -0
  60. package/dist/templates/code/src/app/admin/settings/history/page.tsx +13 -0
  61. package/dist/templates/code/src/app/admin/settings/layout.tsx +11 -0
  62. package/dist/templates/code/src/app/admin/settings/queue/loading.tsx +9 -0
  63. package/dist/templates/code/src/app/admin/settings/queue/page.tsx +21 -0
  64. package/dist/templates/code/src/app/admin/tasks/_components/domains-tabs-grid.tsx +1 -1
  65. package/dist/templates/code/src/app/admin/tasks/_config/columns-data.tsx +1 -1
  66. package/dist/templates/code/src/app/admin/tasks/_config/columns-inline.tsx +1 -1
  67. package/dist/templates/code/src/app/admin/tasks/_lib/events.ts +40 -0
  68. package/dist/templates/code/src/app/admin/tasks/_lib/listeners.server.ts +46 -0
  69. package/dist/templates/code/src/app/admin/tasks/_lib/router.ts +222 -90
  70. package/dist/templates/code/src/app/admin/time/_config/columns-data.tsx +1 -1
  71. package/dist/templates/code/src/app/admin/time/_config/columns-inline.tsx +1 -1
  72. package/dist/templates/code/src/app/api/[transport]/route.ts +31 -21
  73. package/dist/templates/code/src/app/api/queue/dispatch/route.ts +4 -0
  74. package/dist/templates/code/src/app/auth/forget-password/page.tsx +1 -1
  75. package/dist/templates/code/src/app/auth/layout.tsx +1 -1
  76. package/dist/templates/code/src/app/auth/reset-password/page.tsx +1 -1
  77. package/dist/templates/code/src/app/layout.tsx +25 -13
  78. package/dist/templates/code/src/env.ts +12 -0
  79. package/dist/templates/code/src/lib/config/paths.tsx +8 -0
  80. package/dist/templates/code/src/server/auth/constants.ts +1 -1
  81. package/dist/templates/code/src/server/auth/init.ts +12 -5
  82. package/dist/templates/code/src/server/auth/permissions.ts +3 -0
  83. package/dist/templates/code/src/server/auth/schema.ts +100 -1
  84. package/dist/templates/code/src/server/db/schema.ts +3 -0
  85. package/dist/templates/code/src/server/events/handlers.ts +17 -0
  86. package/dist/templates/code/src/server/events/init.ts +18 -0
  87. package/dist/templates/code/src/server/events/listeners.ts +17 -0
  88. package/dist/templates/code/src/server/queue/handlers.ts +5 -0
  89. package/dist/templates/code/src/server/queue/init.ts +16 -0
  90. package/dist/templates/code/src/server/router/router.ts +13 -0
  91. package/dist/templates/spark/package.json +64 -65
  92. package/dist/templates/spark/src/app/admin/assignments/[id]/(default)/@dashboard/page.tsx +1 -1
  93. package/dist/templates/spark/src/app/admin/assignments/_config/columns-data.tsx +1 -1
  94. package/dist/templates/spark/src/app/admin/companies/_config/columns-data.tsx +1 -1
  95. package/dist/templates/spark/src/app/admin/contacts/_components/grid.tsx +1 -1
  96. package/dist/templates/spark/src/app/admin/customers/_config/columns-data.tsx +1 -1
  97. package/dist/templates/spark/src/app/admin/drive/_config/columns-data.tsx +1 -1
  98. package/dist/templates/spark/src/app/admin/emails/_components/send-email.tsx +1 -1
  99. package/dist/templates/spark/src/app/admin/emails/_config/columns-data.tsx +1 -1
  100. package/dist/templates/spark/src/app/admin/employees/_config/columns-data.tsx +1 -1
  101. package/dist/templates/spark/src/app/admin/executions/[id]/@dashboard/page.tsx +1 -1
  102. package/dist/templates/spark/src/app/admin/executions/_config/columns-data.tsx +1 -1
  103. package/dist/templates/spark/src/app/admin/products/[id]/(preview)/page.tsx +1 -1
  104. package/dist/templates/spark/src/app/admin/products/_config/columns-data.tsx +1 -1
  105. package/dist/templates/spark/src/app/admin/projects/[id]/(dashboard)/page.tsx +1 -1
  106. package/dist/templates/spark/src/app/admin/projects/[id]/messages/page.tsx +1 -1
  107. package/dist/templates/spark/src/app/admin/projects/[id]/statistics/page.tsx +1 -1
  108. package/dist/templates/spark/src/app/admin/projects/_config/columns-data.tsx +1 -1
  109. package/dist/templates/spark/src/app/admin/purchase-order-items/_config/columns-data.tsx +1 -1
  110. package/dist/templates/spark/src/app/admin/purchase-order-items/_config/columns-inline.tsx +1 -1
  111. package/dist/templates/spark/src/app/admin/purchase-order-items/_config/commands.tsx +1 -1
  112. package/dist/templates/spark/src/app/admin/purchase-orders/[id]/(default)/@dashboard/page.tsx +1 -1
  113. package/dist/templates/spark/src/app/admin/purchase-orders/_config/columns-data.tsx +1 -1
  114. package/dist/templates/spark/src/app/admin/quotation-items/_config/commands.tsx +1 -1
  115. package/dist/templates/spark/src/app/admin/quotations/[id]/(default)/@dashboard/page.tsx +1 -1
  116. package/dist/templates/spark/src/app/admin/quotations/_config/columns-data.tsx +1 -1
  117. package/dist/templates/spark/src/app/admin/run-tasks/_config/columns-data.tsx +1 -1
  118. package/dist/templates/spark/src/app/admin/runs/[id]/@dashboard/page.tsx +1 -1
  119. package/dist/templates/spark/src/app/admin/runs/_config/columns-data.tsx +1 -1
  120. package/dist/templates/spark/src/app/admin/sales-invoices/[id]/@dashboard/page.tsx +1 -1
  121. package/dist/templates/spark/src/app/admin/sales-invoices/_config/columns-data.tsx +1 -1
  122. package/dist/templates/spark/src/app/admin/sales-order-executions/_config/columns-data.tsx +1 -1
  123. package/dist/templates/spark/src/app/admin/sales-order-executions/_config/columns-inline.tsx +1 -1
  124. package/dist/templates/spark/src/app/admin/sales-order-executions/_config/commands.tsx +1 -1
  125. package/dist/templates/spark/src/app/admin/sales-order-items/_config/commands.tsx +1 -1
  126. package/dist/templates/spark/src/app/admin/sales-orders/[id]/(default)/@dashboard/page.tsx +1 -1
  127. package/dist/templates/spark/src/app/admin/sales-orders/[id]/(default)/layout.tsx +1 -1
  128. package/dist/templates/spark/src/app/admin/sales-orders/_config/columns-data.tsx +1 -1
  129. package/dist/templates/spark/src/app/admin/schedule/_components/schedule-card.tsx +1 -1
  130. package/dist/templates/spark/src/app/admin/schedule/_components/schedule-month-view.tsx +1 -1
  131. package/dist/templates/spark/src/app/admin/suppliers/_config/columns-data.tsx +1 -1
  132. package/dist/templates/spark/src/app/admin/tasks/_components/subproject-selector.tsx +1 -1
  133. package/dist/templates/spark/src/app/admin/tasks/_config/columns-data.tsx +1 -1
  134. package/dist/templates/spark/src/app/admin/tasks/_config/columns-inline.tsx +1 -1
  135. package/dist/templates/spark/src/app/admin/tasks/_config/commands.tsx +1 -1
  136. package/dist/templates/spark/src/app/admin/technical-sheets/[id]/page.tsx +1 -1
  137. package/dist/templates/spark/src/app/admin/technical-sheets/_config/columns-data.tsx +1 -1
  138. package/dist/templates/spark/src/app/admin/technical-sheets/_config/commands.tsx +1 -1
  139. package/dist/templates/spark/src/app/admin/templates/_config/columns-data.tsx +1 -1
  140. package/dist/templates/spark/src/app/auth/layout.tsx +1 -1
  141. package/dist/templates/spark/src/app/layout.tsx +25 -10
  142. package/dist/templates/spark/src/server/auth/init.ts +1 -0
  143. 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
+ };