@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.
- package/dist/templates/basic/package.json +5 -5
- package/dist/templates/code/package.json +18 -19
- package/dist/templates/code/public/sw.js +1 -0
- package/dist/templates/code/src/app/admin/notifications/_components/push-subscription-card.tsx +20 -0
- package/dist/templates/code/src/app/admin/notifications/_components/tables.tsx +8 -3
- package/dist/templates/code/src/app/admin/notifications/_components/toolbars.tsx +35 -10
- package/dist/templates/code/src/app/admin/notifications/_components/unread-badge.tsx +17 -0
- package/dist/templates/code/src/app/admin/notifications/_config/columns-data.tsx +52 -43
- package/dist/templates/code/src/app/admin/notifications/_config/commands.tsx +43 -7
- package/dist/templates/code/src/app/admin/notifications/_lib/listeners.server.ts +126 -52
- package/dist/templates/code/src/app/admin/notifications/_lib/router.ts +53 -53
- package/dist/templates/code/src/app/admin/notifications/_lib/schema.ts +57 -17
- package/dist/templates/code/src/app/admin/notifications/layout.tsx +10 -2
- package/dist/templates/code/src/app/admin/notifications/page.tsx +7 -10
- package/dist/templates/code/src/app/admin/projects/_lib/notifications.ts +53 -0
- package/dist/templates/code/src/app/admin/settings/layout.tsx +5 -0
- package/dist/templates/code/src/app/admin/settings/notifications/page.tsx +59 -0
- package/dist/templates/code/src/app/admin/tasks/_lib/notifications.ts +83 -0
- package/dist/templates/code/src/app/api/notifications/[id]/open/route.ts +42 -0
- package/dist/templates/code/src/app/globals.css +1 -0
- package/dist/templates/code/src/lib/config/paths.tsx +6 -0
- package/dist/templates/code/src/server/auth/init.ts +1 -0
- package/dist/templates/code/src/server/notifications/channels.ts +57 -0
- package/dist/templates/code/src/server/notifications/definitions.ts +20 -0
- package/dist/templates/code/src/server/notifications/init.ts +49 -0
- package/dist/templates/spark/package.json +13 -13
- package/package.json +1 -1
- package/dist/templates/code/src/app/admin/notifications/_components/command-toggle-push.tsx +0 -115
- package/dist/templates/code/src/app/admin/notifications/_lib/push.server.ts +0 -80
- 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.
|
|
29
|
-
"@tulip-systems/auth": "0.
|
|
30
|
-
"@tulip-systems/commands": "0.
|
|
31
|
-
"@tulip-systems/core": "0.
|
|
32
|
-
"@tulip-systems/ui": "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.
|
|
73
|
-
"@tulip-systems/app": "0.
|
|
74
|
-
"@tulip-systems/auth": "0.
|
|
75
|
-
"@tulip-systems/blocks": "0.
|
|
76
|
-
"@tulip-systems/commands": "0.
|
|
77
|
-
"@tulip-systems/core": "0.
|
|
78
|
-
"@tulip-systems/data-tables": "0.
|
|
79
|
-
"@tulip-systems/drive": "0.
|
|
80
|
-
"@tulip-systems/drive-google": "0.
|
|
81
|
-
"@tulip-systems/editor": "0.
|
|
82
|
-
"@tulip-systems/editor-storage": "0.
|
|
83
|
-
"@tulip-systems/events": "0.
|
|
84
|
-
"@tulip-systems/inline": "0.
|
|
85
|
-
"@tulip-systems/
|
|
86
|
-
"@tulip-systems/
|
|
87
|
-
"@tulip-systems/
|
|
88
|
-
"@tulip-systems/
|
|
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:",
|
package/dist/templates/code/src/app/admin/notifications/_components/push-subscription-card.tsx
ADDED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
11
|
+
const descriptors = resolveFilterDescriptors(notificationFilters);
|
|
12
|
+
|
|
13
|
+
export function NotificationTableToolbar() {
|
|
7
14
|
return (
|
|
8
15
|
<TableToolbar>
|
|
9
16
|
<TableToolbarList>
|
|
10
|
-
<
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
</div>
|
|
16
|
-
</TableToolbarList>
|
|
17
|
+
<FilterSearch placeholder="Search notifications…" filter={descriptors.search} />
|
|
18
|
+
|
|
19
|
+
<Suspense>
|
|
20
|
+
<NotificationTypeFilter />
|
|
21
|
+
</Suspense>
|
|
17
22
|
|
|
18
|
-
|
|
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 "
|
|
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="
|
|
14
|
-
cell: ({ row }) =>
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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: "
|
|
38
|
-
header: ({ column }) => <TableColumnHeader column={column} title="
|
|
57
|
+
accessorKey: "occurredAt",
|
|
58
|
+
header: ({ column }) => <TableColumnHeader column={column} title="Received" />,
|
|
39
59
|
cell: ({ row }) => (
|
|
40
|
-
<TableCell
|
|
41
|
-
{
|
|
42
|
-
|
|
43
|
-
|
|
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: {
|
|
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: {
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
};
|