@tulip-systems/create-app 0.14.0 → 0.15.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 (30) hide show
  1. package/dist/templates/basic/package.json +5 -5
  2. package/dist/templates/code/package.json +18 -19
  3. package/dist/templates/code/public/sw.js +1 -0
  4. package/dist/templates/code/src/app/admin/notifications/_components/push-subscription-card.tsx +20 -0
  5. package/dist/templates/code/src/app/admin/notifications/_components/tables.tsx +8 -3
  6. package/dist/templates/code/src/app/admin/notifications/_components/toolbars.tsx +35 -10
  7. package/dist/templates/code/src/app/admin/notifications/_components/unread-badge.tsx +17 -0
  8. package/dist/templates/code/src/app/admin/notifications/_config/columns-data.tsx +52 -43
  9. package/dist/templates/code/src/app/admin/notifications/_config/commands.tsx +43 -7
  10. package/dist/templates/code/src/app/admin/notifications/_lib/listeners.server.ts +126 -52
  11. package/dist/templates/code/src/app/admin/notifications/_lib/router.ts +53 -53
  12. package/dist/templates/code/src/app/admin/notifications/_lib/schema.ts +57 -17
  13. package/dist/templates/code/src/app/admin/notifications/layout.tsx +10 -2
  14. package/dist/templates/code/src/app/admin/notifications/page.tsx +7 -10
  15. package/dist/templates/code/src/app/admin/projects/_lib/notifications.ts +53 -0
  16. package/dist/templates/code/src/app/admin/settings/layout.tsx +5 -0
  17. package/dist/templates/code/src/app/admin/settings/notifications/page.tsx +59 -0
  18. package/dist/templates/code/src/app/admin/tasks/_lib/notifications.ts +83 -0
  19. package/dist/templates/code/src/app/api/notifications/[id]/open/route.ts +42 -0
  20. package/dist/templates/code/src/app/globals.css +1 -0
  21. package/dist/templates/code/src/lib/config/paths.tsx +6 -0
  22. package/dist/templates/code/src/server/auth/init.ts +1 -0
  23. package/dist/templates/code/src/server/notifications/channels.ts +57 -0
  24. package/dist/templates/code/src/server/notifications/definitions.ts +20 -0
  25. package/dist/templates/code/src/server/notifications/init.ts +49 -0
  26. package/dist/templates/spark/package.json +13 -13
  27. package/package.json +1 -1
  28. package/dist/templates/code/src/app/admin/notifications/_components/command-toggle-push.tsx +0 -115
  29. package/dist/templates/code/src/app/admin/notifications/_lib/push.server.ts +0 -80
  30. package/dist/templates/code/src/app/admin/notifications/_lib/validators.ts +0 -19
@@ -25,11 +25,11 @@
25
25
  "@orpc/tanstack-query": "catalog:",
26
26
  "@tailwindcss/typography": "catalog:",
27
27
  "@tanstack/react-query": "catalog:",
28
- "@tulip-systems/app": "0.14.0",
29
- "@tulip-systems/auth": "0.14.0",
30
- "@tulip-systems/commands": "0.14.0",
31
- "@tulip-systems/core": "0.14.0",
32
- "@tulip-systems/ui": "0.14.0",
28
+ "@tulip-systems/app": "0.15.0",
29
+ "@tulip-systems/auth": "0.15.0",
30
+ "@tulip-systems/commands": "0.15.0",
31
+ "@tulip-systems/core": "0.15.0",
32
+ "@tulip-systems/ui": "0.15.0",
33
33
  "@vercel/functions": "^3.9.0",
34
34
  "better-auth": "catalog:",
35
35
  "class-variance-authority": "catalog:",
@@ -69,23 +69,24 @@
69
69
  "@tiptap/extension-text-style": "catalog:",
70
70
  "@tiptap/extension-underline": "catalog:",
71
71
  "@tiptap/extensions": "catalog:",
72
- "@tulip-systems/ai": "0.14.0",
73
- "@tulip-systems/app": "0.14.0",
74
- "@tulip-systems/auth": "0.14.0",
75
- "@tulip-systems/blocks": "0.14.0",
76
- "@tulip-systems/commands": "0.14.0",
77
- "@tulip-systems/core": "0.14.0",
78
- "@tulip-systems/data-tables": "0.14.0",
79
- "@tulip-systems/drive": "0.14.0",
80
- "@tulip-systems/drive-google": "0.14.0",
81
- "@tulip-systems/editor": "0.14.0",
82
- "@tulip-systems/editor-storage": "0.14.0",
83
- "@tulip-systems/events": "0.14.0",
84
- "@tulip-systems/inline": "0.14.0",
85
- "@tulip-systems/query": "0.14.0",
86
- "@tulip-systems/queue": "0.14.0",
87
- "@tulip-systems/storage": "0.14.0",
88
- "@tulip-systems/ui": "0.14.0",
72
+ "@tulip-systems/ai": "0.15.0",
73
+ "@tulip-systems/app": "0.15.0",
74
+ "@tulip-systems/auth": "0.15.0",
75
+ "@tulip-systems/blocks": "0.15.0",
76
+ "@tulip-systems/commands": "0.15.0",
77
+ "@tulip-systems/core": "0.15.0",
78
+ "@tulip-systems/data-tables": "0.15.0",
79
+ "@tulip-systems/drive": "0.15.0",
80
+ "@tulip-systems/drive-google": "0.15.0",
81
+ "@tulip-systems/editor": "0.15.0",
82
+ "@tulip-systems/editor-storage": "0.15.0",
83
+ "@tulip-systems/events": "0.15.0",
84
+ "@tulip-systems/inline": "0.15.0",
85
+ "@tulip-systems/notifications": "0.13.0",
86
+ "@tulip-systems/query": "0.15.0",
87
+ "@tulip-systems/queue": "0.15.0",
88
+ "@tulip-systems/storage": "0.15.0",
89
+ "@tulip-systems/ui": "0.15.0",
89
90
  "@uidotdev/usehooks": "^2.4.1",
90
91
  "@upstash/qstash": "^2.11.3",
91
92
  "@vercel/functions": "^3.9.0",
@@ -121,7 +122,6 @@
121
122
  "sharp": "^0.35.3",
122
123
  "usehooks-ts": "^3.1.1",
123
124
  "uuid": "^14.0.1",
124
- "web-push": "^3.6.7",
125
125
  "xlsx": "^0.18.5",
126
126
  "zod": "catalog:",
127
127
  "zustand": "^5.0.14"
@@ -134,7 +134,6 @@
134
134
  "@types/pg": "catalog:",
135
135
  "@types/react": "catalog:",
136
136
  "@types/react-dom": "catalog:",
137
- "@types/web-push": "^3.6.4",
138
137
  "babel-plugin-react-compiler": "^1.0.0",
139
138
  "dotenv-cli": "catalog:",
140
139
  "drizzle-kit": "catalog:",
@@ -5,6 +5,7 @@ self.addEventListener("push", (event) => {
5
5
  body: data.body,
6
6
  icon: "/icons/icon-x192.png",
7
7
  badge: "/icons/icon-x192.png",
8
+ tag: data.tag,
8
9
  data: {
9
10
  url: data.url ?? "/admin/notifications",
10
11
  notificationId: data.notificationId,
@@ -0,0 +1,20 @@
1
+ "use client";
2
+
3
+ import { useSuspenseQuery } from "@tanstack/react-query";
4
+ import { PushSubscriptionCard } from "@tulip-systems/notifications/channels/web-push/client";
5
+ import { orpc } from "@/server/router/client";
6
+
7
+ /**
8
+ * Stores this browser's push subscription for the current user through the notifications router.
9
+ */
10
+ export function PushSubscription() {
11
+ const { data: config } = useSuspenseQuery(orpc.notifications.getPushConfig.queryOptions());
12
+
13
+ return (
14
+ <PushSubscriptionCard
15
+ publicKey={config.publicKey}
16
+ onSubscribe={(subscription) => orpc.notifications.subscribePush.call(subscription)}
17
+ onUnsubscribe={(endpoint) => orpc.notifications.unsubscribePush.call({ endpoint })}
18
+ />
19
+ );
20
+ }
@@ -8,9 +8,10 @@ import {
8
8
  TableConfigProvider,
9
9
  useLocalStrategy,
10
10
  } from "@tulip-systems/data-tables/client";
11
- import type { PropsWithChildren } from "react";
11
+ import { type Notification, notificationFilters } from "@tulip-systems/notifications";
12
+ import { useFilters } from "@tulip-systems/query/client";
13
+ import { type PropsWithChildren, useDeferredValue } from "react";
12
14
  import { orpc } from "@/server/router/client";
13
- import type { Notification } from "../_lib/validators";
14
15
 
15
16
  type NotificationTableProviderProps = PropsWithChildren<{
16
17
  columns: TableColumnDef<Notification>[];
@@ -22,7 +23,10 @@ export function NotificationTableProvider({
22
23
  commands,
23
24
  children,
24
25
  }: NotificationTableProviderProps) {
25
- const response = useSuspenseQuery(orpc.notifications.list.queryOptions());
26
+ const filtering = useFilters(notificationFilters);
27
+ const input = useDeferredValue({ filters: filtering.input });
28
+
29
+ const response = useSuspenseQuery(orpc.notifications.list.queryOptions({ input }));
26
30
  const strategy = useLocalStrategy({ total: response.data.length });
27
31
 
28
32
  const config = createTableConfig<Notification>({
@@ -30,6 +34,7 @@ export function NotificationTableProvider({
30
34
  getRowId: (row) => row.id,
31
35
  columns,
32
36
  strategy,
37
+ filtering,
33
38
  commands,
34
39
  });
35
40
 
@@ -1,21 +1,46 @@
1
1
  "use client";
2
2
 
3
+ import { useSuspenseQuery } from "@tanstack/react-query";
3
4
  import { TableToolbar, TableToolbarList } from "@tulip-systems/data-tables";
4
- import type { PropsWithChildren } from "react";
5
+ import { notificationFilters } from "@tulip-systems/notifications";
6
+ import { resolveFilterDescriptors } from "@tulip-systems/query";
7
+ import { FilterCombobox, FilterSearch, FilterToggle } from "@tulip-systems/query/client";
8
+ import { Suspense } from "react";
9
+ import { orpc } from "@/server/router/client";
5
10
 
6
- export function NotificationTableToolbar({ children }: PropsWithChildren) {
11
+ const descriptors = resolveFilterDescriptors(notificationFilters);
12
+
13
+ export function NotificationTableToolbar() {
7
14
  return (
8
15
  <TableToolbar>
9
16
  <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
+ <FilterSearch placeholder="Search notifications…" filter={descriptors.search} />
18
+
19
+ <Suspense>
20
+ <NotificationTypeFilter />
21
+ </Suspense>
17
22
 
18
- <TableToolbarList>{children}</TableToolbarList>
23
+ <FilterToggle title="Unread" filter={descriptors.isUnread} />
24
+ </TableToolbarList>
19
25
  </TableToolbar>
20
26
  );
21
27
  }
28
+
29
+ /**
30
+ * Only offers the notification types the user has the permission for, which the preferences
31
+ * already list.
32
+ */
33
+ function NotificationTypeFilter() {
34
+ const { data } = useSuspenseQuery(orpc.notifications.getPreferences.queryOptions());
35
+
36
+ return (
37
+ <FilterCombobox
38
+ title="Type"
39
+ filter={descriptors.types}
40
+ options={data.notifications.map((notification) => ({
41
+ label: notification.label,
42
+ value: notification.type,
43
+ }))}
44
+ />
45
+ );
46
+ }
@@ -0,0 +1,17 @@
1
+ "use client";
2
+
3
+ import { useSuspenseQuery } from "@tanstack/react-query";
4
+ import { NotificationSidebarBadge } from "@tulip-systems/notifications/client";
5
+ import { orpc } from "@/server/router/client";
6
+
7
+ /**
8
+ * Unread notification count next to the Notifications link in the sidebar. Refetches every minute
9
+ * and when the window regains focus, so new notifications show up without a reload.
10
+ */
11
+ export function NotificationUnreadBadge() {
12
+ const { data: count } = useSuspenseQuery(
13
+ orpc.notifications.unreadCount.queryOptions({ refetchInterval: 60_000 }),
14
+ );
15
+
16
+ return <NotificationSidebarBadge count={count} />;
17
+ }
@@ -1,64 +1,73 @@
1
1
  "use client";
2
2
 
3
+ import { useMutation } from "@tanstack/react-query";
3
4
  import { formatDateTime } from "@tulip-systems/core";
4
5
  import { Link } from "@tulip-systems/core/navigation";
5
6
  import { TableCell, type TableColumnDef, TableColumnHeader } from "@tulip-systems/data-tables";
6
7
  import { createTableSelectCell } from "@tulip-systems/data-tables/client";
7
- import type { Notification } from "../_lib/validators";
8
+ import type { Notification } from "@tulip-systems/notifications";
9
+ import { cn } from "@tulip-systems/ui/cn";
10
+ import { formatDistanceToNow } from "date-fns";
11
+ import { orpc } from "@/server/router/client";
8
12
 
9
13
  export const notificationColumns: TableColumnDef<Notification>[] = [
10
14
  createTableSelectCell(),
11
15
  {
12
16
  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>,
17
+ header: ({ column }) => <TableColumnHeader column={column} title="Notification" />,
18
+ cell: ({ row }) => {
19
+ const markAsRead = useMutation(
20
+ orpc.notifications.markAsRead.mutationOptions({
21
+ meta: {
22
+ invalidates: [orpc.notifications.list.key(), orpc.notifications.unreadCount.key()],
23
+ },
24
+ }),
25
+ );
26
+
27
+ const isUnread = !row.original.readAt;
28
+
29
+ return (
30
+ <TableCell className={cn("w-full", !isUnread && "text-muted-foreground/70")}>
31
+ {isUnread && <span className="sr-only">Unread</span>}
32
+
33
+ <div className="min-w-0">
34
+ <div className={cn("truncate", isUnread && "font-bold")}>
35
+ {row.original.href ? (
36
+ <Link
37
+ href={row.original.href}
38
+ className="hover:underline"
39
+ onClick={() => {
40
+ if (isUnread) markAsRead.mutate({ ids: [row.original.id] });
41
+ }}
42
+ >
43
+ {row.original.title}
44
+ </Link>
45
+ ) : (
46
+ row.original.title
47
+ )}
48
+ </div>
49
+ <div className="max-w-[640px] truncate text-sm">{row.original.body}</div>
50
+ </div>
51
+ </TableCell>
52
+ );
53
+ },
34
54
  enableSorting: false,
35
55
  },
36
56
  {
37
- accessorKey: "sourceHref",
38
- header: ({ column }) => <TableColumnHeader column={column} title="Source" />,
57
+ accessorKey: "occurredAt",
58
+ header: ({ column }) => <TableColumnHeader column={column} title="Received" />,
39
59
  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
60
+ <TableCell
61
+ className={cn(
62
+ "text-sm",
63
+ row.original.readAt ? "text-muted-foreground/70" : "font-medium text-foreground",
47
64
  )}
65
+ title={formatDateTime(row.original.occurredAt)}
66
+ suppressHydrationWarning
67
+ >
68
+ {formatDistanceToNow(row.original.occurredAt, { addSuffix: true })}
48
69
  </TableCell>
49
70
  ),
50
71
  enableSorting: false,
51
72
  },
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
73
  ];
@@ -2,13 +2,10 @@
2
2
 
3
3
  import { commandBuilder } from "@tulip-systems/commands";
4
4
  import { CommandClick, CommandLabel, DeleteCommand } from "@tulip-systems/commands/client";
5
+ import type { Notification } from "@tulip-systems/notifications";
5
6
  import { toast } from "@tulip-systems/ui/sonner";
6
- import { CheckIcon } from "lucide-react";
7
+ import { CheckCheckIcon, CheckIcon, CircleDotIcon } from "lucide-react";
7
8
  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
9
 
13
10
  export const notificationMarkAsReadCommand = commandBuilder
14
11
  .$type<Notification[]>()
@@ -18,7 +15,9 @@ export const notificationMarkAsReadCommand = commandBuilder
18
15
  label="Mark as read"
19
16
  variables={{ ids: data.map((item) => item.id) }}
20
17
  mutation={orpc.notifications.markAsRead.mutationOptions({
21
- meta: { invalidates: [orpc.notifications.list.key()] },
18
+ meta: {
19
+ invalidates: [orpc.notifications.list.key(), orpc.notifications.unreadCount.key()],
20
+ },
22
21
  onSuccess: () => toast.success("Notifications marked as read"),
23
22
  })}
24
23
  >
@@ -27,13 +26,50 @@ export const notificationMarkAsReadCommand = commandBuilder
27
26
  </CommandClick>
28
27
  ));
29
28
 
29
+ export const notificationMarkAsUnreadCommand = commandBuilder
30
+ .$type<Notification[]>()
31
+ .visibleWhen(({ data }) => data.some((item) => item.readAt))
32
+ .render(({ data }) => (
33
+ <CommandClick
34
+ label="Mark as unread"
35
+ variables={{ ids: data.map((item) => item.id) }}
36
+ mutation={orpc.notifications.markAsUnread.mutationOptions({
37
+ meta: {
38
+ invalidates: [orpc.notifications.list.key(), orpc.notifications.unreadCount.key()],
39
+ },
40
+ onSuccess: () => toast.success("Notifications marked as unread"),
41
+ })}
42
+ >
43
+ <CircleDotIcon className="size-4" />
44
+ <CommandLabel />
45
+ </CommandClick>
46
+ ));
47
+
48
+ export const notificationMarkAllAsReadCommand = commandBuilder.render(() => (
49
+ <CommandClick
50
+ label="Mark all as read"
51
+ variables={undefined}
52
+ mutation={orpc.notifications.markAllAsRead.mutationOptions({
53
+ meta: {
54
+ invalidates: [orpc.notifications.list.key(), orpc.notifications.unreadCount.key()],
55
+ },
56
+ onSuccess: () => toast.success("All notifications marked as read"),
57
+ })}
58
+ >
59
+ <CheckCheckIcon className="size-4" />
60
+ <CommandLabel />
61
+ </CommandClick>
62
+ ));
63
+
30
64
  export const notificationDeleteCommand = commandBuilder
31
65
  .$type<Notification[]>()
32
66
  .render(({ data }) => (
33
67
  <DeleteCommand
34
68
  variables={{ ids: data.map((item) => item.id) }}
35
69
  mutation={orpc.notifications.delete.mutationOptions({
36
- meta: { invalidates: [orpc.notifications.list.key()] },
70
+ meta: {
71
+ invalidates: [orpc.notifications.list.key(), orpc.notifications.unreadCount.key()],
72
+ },
37
73
  onSuccess: () => toast.success("Notifications deleted"),
38
74
  })}
39
75
  />
@@ -1,69 +1,143 @@
1
1
  import "server-cli-only";
2
2
 
3
+ import { findStatus } from "@tulip-systems/ui/status";
4
+ import { eq, inArray } from "drizzle-orm";
5
+ import { employees } from "@/app/admin/employees/_lib/schema";
3
6
  import { projectCreatedEvent, projectUpdatedEvent } from "@/app/admin/projects/_lib/events";
4
- import { taskCreatedEvent } from "@/app/admin/tasks/_lib/events";
7
+ import {
8
+ projectCreatedNotification,
9
+ projectStatusChangedNotification,
10
+ } from "@/app/admin/projects/_lib/notifications";
11
+ import { taskStatusConfig } from "@/app/admin/tasks/_config/status";
12
+ import { taskCreatedEvent, taskUpdatedEvent } from "@/app/admin/tasks/_lib/events";
13
+ import {
14
+ taskAssignedNotification,
15
+ taskCreatedNotification,
16
+ taskStatusChangedNotification,
17
+ } from "@/app/admin/tasks/_lib/notifications";
18
+ import { users } from "@/server/auth/schema";
5
19
  import { db } from "@/server/db/init";
6
- import { sendPushNotification } from "./push.server";
7
- import { notifications } from "./schema";
20
+ import { notifications } from "@/server/notifications/init";
8
21
 
22
+ /**
23
+ * Project notifications go to every user except the actor, because the demo has no project
24
+ * membership. Task notifications go to the people involved in the task. Task snapshots store
25
+ * employee ids, which are resolved to the employees' user ids.
26
+ */
9
27
  export const notificationEventListeners = {
10
28
  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();
29
+ const recipients = await db.select({ id: users.id }).from(users);
26
30
 
27
- if (notification) await sendPushNotification(notification);
31
+ await notifications.send(projectCreatedNotification, {
32
+ to: recipients.map((recipient) => recipient.id),
33
+ exclude: [payload.actor.id],
34
+ data: { projectId: payload.projectId, title: payload.title, status: payload.status },
35
+ event,
36
+ });
28
37
  }),
29
38
  projectStatusChanged: projectUpdatedEvent.listen(async ({ payload, event }) => {
30
39
  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
40
 
48
- if (notification) await sendPushNotification(notification);
41
+ const recipients = await db.select({ id: users.id }).from(users);
42
+
43
+ await notifications.send(projectStatusChangedNotification, {
44
+ to: recipients.map((recipient) => recipient.id),
45
+ exclude: [payload.actor.id],
46
+ data: {
47
+ projectId: payload.projectId,
48
+ title: payload.title,
49
+ previousStatus: String(payload.before.status),
50
+ nextStatus: String(payload.after.status),
51
+ },
52
+ event,
53
+ });
49
54
  }),
50
55
  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();
56
+ const recipients = await db.select({ id: users.id }).from(users);
57
+
58
+ await notifications.send(taskCreatedNotification, {
59
+ to: recipients.map((recipient) => recipient.id),
60
+ exclude: [payload.actor.id],
61
+ data: {
62
+ taskId: payload.taskId,
63
+ code: payload.code,
64
+ title: payload.title,
65
+ projectTitle: payload.projectTitle,
66
+ },
67
+ event,
68
+ });
69
+ }),
70
+ taskAssignedOnCreate: taskCreatedEvent.listen(async ({ payload, event }) => {
71
+ const assigneeId = payload.after.assignee_id;
72
+ if (typeof assigneeId !== "string") return;
73
+
74
+ const recipients = await db
75
+ .select({ id: employees.userId })
76
+ .from(employees)
77
+ .where(eq(employees.id, assigneeId));
78
+
79
+ await notifications.send(taskAssignedNotification, {
80
+ to: recipients.map((recipient) => recipient.id),
81
+ exclude: [payload.actor.id],
82
+ data: {
83
+ taskId: payload.taskId,
84
+ code: payload.code,
85
+ title: payload.title,
86
+ projectTitle: payload.projectTitle,
87
+ assignedBy: payload.actor.name,
88
+ },
89
+ event,
90
+ });
91
+ }),
92
+ taskReassigned: taskUpdatedEvent.listen(async ({ payload, event }) => {
93
+ const assigneeId = payload.after.assignee_id;
94
+ if (typeof assigneeId !== "string" || assigneeId === payload.before.assignee_id) return;
95
+
96
+ const recipients = await db
97
+ .select({ id: employees.userId })
98
+ .from(employees)
99
+ .where(eq(employees.id, assigneeId));
100
+
101
+ await notifications.send(taskAssignedNotification, {
102
+ to: recipients.map((recipient) => recipient.id),
103
+ exclude: [payload.actor.id],
104
+ data: {
105
+ taskId: payload.taskId,
106
+ code: payload.code,
107
+ title: payload.title,
108
+ projectTitle: payload.projectTitle,
109
+ assignedBy: payload.actor.name,
110
+ },
111
+ event,
112
+ });
113
+ }),
114
+ taskStatusChanged: taskUpdatedEvent.listen(async ({ payload, event }) => {
115
+ const previousStatus = String(payload.before.status);
116
+ const nextStatus = String(payload.after.status);
117
+ if (previousStatus === nextStatus) return;
118
+
119
+ const employeeIds = [payload.after.assignee_id, payload.after.creator_id].filter(
120
+ (id): id is string => typeof id === "string",
121
+ );
122
+ if (employeeIds.length === 0) return;
123
+
124
+ const recipients = await db
125
+ .select({ id: employees.userId })
126
+ .from(employees)
127
+ .where(inArray(employees.id, employeeIds));
66
128
 
67
- if (notification) await sendPushNotification(notification);
129
+ await notifications.send(taskStatusChangedNotification, {
130
+ to: recipients.map((recipient) => recipient.id),
131
+ exclude: [payload.actor.id],
132
+ data: {
133
+ taskId: payload.taskId,
134
+ code: payload.code,
135
+ title: payload.title,
136
+ previousStatus: findStatus(taskStatusConfig, previousStatus)?.label ?? previousStatus,
137
+ nextStatus: findStatus(taskStatusConfig, nextStatus)?.label ?? nextStatus,
138
+ changedBy: payload.actor.name,
139
+ },
140
+ event,
141
+ });
68
142
  }),
69
143
  };