@tulip-systems/create-app 0.13.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
|
@@ -1,82 +1,82 @@
|
|
|
1
1
|
import "server-cli-only";
|
|
2
2
|
|
|
3
3
|
import { ORPCError } from "@orpc/server";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
notificationFiltersSchema,
|
|
6
|
+
notificationPreferencesSchema,
|
|
7
|
+
} from "@tulip-systems/notifications";
|
|
8
|
+
import { webPushSubscriptionSchema } from "@tulip-systems/notifications/channels/web-push";
|
|
5
9
|
import { z } from "zod";
|
|
10
|
+
import { notificationWebPushChannel } from "@/server/notifications/channels";
|
|
11
|
+
import { notifications } from "@/server/notifications/init";
|
|
6
12
|
import { protectedProcedure } from "@/server/router/procedures";
|
|
7
|
-
import { getWebPushConfig } from "./push.server";
|
|
8
|
-
import { notificationPushSubscriptions, notifications } from "./schema";
|
|
9
|
-
import { pushSubscriptionSchema } from "./validators";
|
|
10
13
|
|
|
11
14
|
export const notificationsRouter = {
|
|
12
|
-
list: protectedProcedure
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
limit: 100,
|
|
16
|
-
|
|
15
|
+
list: protectedProcedure
|
|
16
|
+
.input(z.object({ filters: notificationFiltersSchema }))
|
|
17
|
+
.handler(({ context, input }) =>
|
|
18
|
+
notifications.list({ recipientId: context.user.id, filters: input.filters, limit: 100 }),
|
|
19
|
+
),
|
|
20
|
+
unreadCount: protectedProcedure.handler(({ context }) =>
|
|
21
|
+
notifications.count({ recipientId: context.user.id, filters: { isUnread: true } }),
|
|
22
|
+
),
|
|
23
|
+
getPreferences: protectedProcedure.handler(({ context }) =>
|
|
24
|
+
notifications.getPreferences({ recipientId: context.user.id }),
|
|
17
25
|
),
|
|
18
|
-
|
|
26
|
+
updatePreferences: protectedProcedure
|
|
27
|
+
.input(z.object({ preferences: notificationPreferencesSchema }))
|
|
28
|
+
.handler(({ context, input }) =>
|
|
29
|
+
notifications.updatePreferences({
|
|
30
|
+
recipientId: context.user.id,
|
|
31
|
+
preferences: input.preferences,
|
|
32
|
+
}),
|
|
33
|
+
),
|
|
34
|
+
getPushConfig: protectedProcedure.handler(() => ({
|
|
35
|
+
publicKey: notificationWebPushChannel.publicKey,
|
|
36
|
+
})),
|
|
19
37
|
subscribePush: protectedProcedure
|
|
20
|
-
.input(
|
|
38
|
+
.input(webPushSubscriptionSchema)
|
|
21
39
|
.handler(async ({ context, input }) => {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (!config.configured) {
|
|
40
|
+
if (!notificationWebPushChannel.publicKey) {
|
|
25
41
|
throw new ORPCError("BAD_REQUEST", {
|
|
26
42
|
message: "Web Push is not configured for this environment",
|
|
27
43
|
});
|
|
28
44
|
}
|
|
29
45
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
.
|
|
35
|
-
.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
});
|
|
46
|
+
await notifications.database.pushSubscriptions.upsert({
|
|
47
|
+
recipientId: context.user.id,
|
|
48
|
+
endpoint: input.endpoint,
|
|
49
|
+
p256dh: input.keys.p256dh,
|
|
50
|
+
auth: input.keys.auth,
|
|
51
|
+
userAgent: context.headers.get("user-agent"),
|
|
52
|
+
lastSeenAt: new Date(),
|
|
53
|
+
expiresAt: input.expirationTime ? new Date(input.expirationTime) : null,
|
|
54
|
+
});
|
|
56
55
|
}),
|
|
57
56
|
unsubscribePush: protectedProcedure
|
|
58
57
|
.input(z.object({ endpoint: z.url() }))
|
|
59
58
|
.handler(async ({ context, input }) => {
|
|
60
|
-
await
|
|
61
|
-
.
|
|
62
|
-
.
|
|
63
|
-
|
|
64
|
-
eq(notificationPushSubscriptions.endpoint, input.endpoint),
|
|
65
|
-
eq(notificationPushSubscriptions.userId, context.user.id),
|
|
66
|
-
),
|
|
67
|
-
);
|
|
59
|
+
await notifications.database.pushSubscriptions.delete({
|
|
60
|
+
endpoint: input.endpoint,
|
|
61
|
+
recipientId: context.user.id,
|
|
62
|
+
});
|
|
68
63
|
}),
|
|
69
64
|
markAsRead: protectedProcedure
|
|
70
65
|
.input(z.object({ ids: z.array(z.string()) }))
|
|
71
66
|
.handler(async ({ context, input }) => {
|
|
72
|
-
await context.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
67
|
+
await notifications.markAsRead({ recipientId: context.user.id, ids: input.ids });
|
|
68
|
+
}),
|
|
69
|
+
markAsUnread: protectedProcedure
|
|
70
|
+
.input(z.object({ ids: z.array(z.string()) }))
|
|
71
|
+
.handler(async ({ context, input }) => {
|
|
72
|
+
await notifications.markAsUnread({ recipientId: context.user.id, ids: input.ids });
|
|
76
73
|
}),
|
|
74
|
+
markAllAsRead: protectedProcedure.handler(async ({ context }) => {
|
|
75
|
+
await notifications.markAllAsRead({ recipientId: context.user.id });
|
|
76
|
+
}),
|
|
77
77
|
delete: protectedProcedure
|
|
78
78
|
.input(z.object({ ids: z.array(z.string()) }))
|
|
79
79
|
.handler(async ({ context, input }) => {
|
|
80
|
-
await
|
|
80
|
+
await notifications.delete({ recipientId: context.user.id, ids: input.ids });
|
|
81
81
|
}),
|
|
82
82
|
};
|
|
@@ -1,24 +1,44 @@
|
|
|
1
1
|
import { relations } from "drizzle-orm";
|
|
2
|
-
import { pgTable } from "drizzle-orm/pg-core";
|
|
2
|
+
import { index, pgTable, unique } from "drizzle-orm/pg-core";
|
|
3
3
|
import { users } from "@/server/auth/schema";
|
|
4
4
|
import { baseColumns } from "@/server/db/helpers";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Notifications
|
|
8
8
|
*/
|
|
9
|
-
export const notifications = pgTable(
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
9
|
+
export const notifications = pgTable(
|
|
10
|
+
"notifications",
|
|
11
|
+
(t) => ({
|
|
12
|
+
...baseColumns,
|
|
13
|
+
recipientId: t
|
|
14
|
+
.uuid()
|
|
15
|
+
.notNull()
|
|
16
|
+
.references(() => users.id, { onDelete: "cascade" }),
|
|
17
|
+
type: t.varchar().notNull(),
|
|
18
|
+
data: t.jsonb().notNull(),
|
|
19
|
+
title: t.varchar().notNull(),
|
|
20
|
+
body: t.text().notNull(),
|
|
21
|
+
href: t.varchar(),
|
|
22
|
+
sourceType: t.varchar(),
|
|
23
|
+
sourceId: t.varchar(),
|
|
24
|
+
eventId: t.varchar(),
|
|
25
|
+
eventName: t.varchar(),
|
|
26
|
+
eventVersion: t.integer(),
|
|
27
|
+
dedupeKey: t.varchar(),
|
|
28
|
+
occurredAt: t.timestamp().notNull(),
|
|
29
|
+
readAt: t.timestamp(),
|
|
30
|
+
}),
|
|
31
|
+
(t) => [
|
|
32
|
+
unique("notifications_recipient_dedupe_unique").on(t.recipientId, t.dedupeKey),
|
|
33
|
+
index("notifications_recipient_read_idx").on(t.recipientId, t.readAt),
|
|
34
|
+
],
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
export const notificationsRelations = relations(notifications, ({ one }) => ({
|
|
38
|
+
recipient: one(users, {
|
|
39
|
+
fields: [notifications.recipientId],
|
|
40
|
+
references: [users.id],
|
|
41
|
+
}),
|
|
22
42
|
}));
|
|
23
43
|
|
|
24
44
|
/**
|
|
@@ -26,7 +46,7 @@ export const notifications = pgTable("notifications", (t) => ({
|
|
|
26
46
|
*/
|
|
27
47
|
export const notificationPushSubscriptions = pgTable("notification_push_subscriptions", (t) => ({
|
|
28
48
|
...baseColumns,
|
|
29
|
-
|
|
49
|
+
recipientId: t
|
|
30
50
|
.uuid()
|
|
31
51
|
.notNull()
|
|
32
52
|
.references(() => users.id, { onDelete: "cascade" }),
|
|
@@ -41,9 +61,29 @@ export const notificationPushSubscriptions = pgTable("notification_push_subscrip
|
|
|
41
61
|
export const notificationPushSubscriptionsRelations = relations(
|
|
42
62
|
notificationPushSubscriptions,
|
|
43
63
|
({ one }) => ({
|
|
44
|
-
|
|
45
|
-
fields: [notificationPushSubscriptions.
|
|
64
|
+
recipient: one(users, {
|
|
65
|
+
fields: [notificationPushSubscriptions.recipientId],
|
|
46
66
|
references: [users.id],
|
|
47
67
|
}),
|
|
48
68
|
}),
|
|
49
69
|
);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Notification Preferences
|
|
73
|
+
*/
|
|
74
|
+
export const notificationPreferences = pgTable("notification_preferences", (t) => ({
|
|
75
|
+
...baseColumns,
|
|
76
|
+
recipientId: t
|
|
77
|
+
.uuid()
|
|
78
|
+
.notNull()
|
|
79
|
+
.unique()
|
|
80
|
+
.references(() => users.id, { onDelete: "cascade" }),
|
|
81
|
+
preferences: t.jsonb().notNull(),
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
export const notificationPreferencesRelations = relations(notificationPreferences, ({ one }) => ({
|
|
85
|
+
recipient: one(users, {
|
|
86
|
+
fields: [notificationPreferences.recipientId],
|
|
87
|
+
references: [users.id],
|
|
88
|
+
}),
|
|
89
|
+
}));
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { Header, Topbar, TopbarTitle } from "@tulip-systems/app/components/client";
|
|
1
|
+
import { Header, Topbar, TopbarTitle, TopbarTools } from "@tulip-systems/app/components/client";
|
|
2
|
+
import { InlineCommandMenu, InlineCommandMenuLoading } from "@tulip-systems/commands/client";
|
|
2
3
|
import { TableLayout } from "@tulip-systems/data-tables";
|
|
3
|
-
import type
|
|
4
|
+
import { type PropsWithChildren, Suspense } from "react";
|
|
5
|
+
import { notificationMarkAllAsReadCommand } from "./_config/commands";
|
|
4
6
|
|
|
5
7
|
export default function Layout({ children }: PropsWithChildren) {
|
|
6
8
|
return (
|
|
@@ -8,6 +10,12 @@ export default function Layout({ children }: PropsWithChildren) {
|
|
|
8
10
|
<Header>
|
|
9
11
|
<Topbar>
|
|
10
12
|
<TopbarTitle breadcrumbs={[{ label: "Notifications" }]} />
|
|
13
|
+
|
|
14
|
+
<TopbarTools>
|
|
15
|
+
<Suspense fallback={<InlineCommandMenuLoading />}>
|
|
16
|
+
<InlineCommandMenu data={null} commands={[notificationMarkAllAsReadCommand]} />
|
|
17
|
+
</Suspense>
|
|
18
|
+
</TopbarTools>
|
|
11
19
|
</Topbar>
|
|
12
20
|
</Header>
|
|
13
21
|
|
|
@@ -1,27 +1,24 @@
|
|
|
1
|
-
import { InlineCommandMenu, InlineCommandMenuLoading } from "@tulip-systems/commands/client";
|
|
2
1
|
import { DataTable } from "@tulip-systems/data-tables/client";
|
|
3
|
-
import { Suspense } from "react";
|
|
4
2
|
import { NotificationTableProvider } from "./_components/tables";
|
|
5
3
|
import { NotificationTableToolbar } from "./_components/toolbars";
|
|
6
4
|
import { notificationColumns } from "./_config/columns-data";
|
|
7
5
|
import {
|
|
8
6
|
notificationDeleteCommand,
|
|
9
7
|
notificationMarkAsReadCommand,
|
|
10
|
-
|
|
8
|
+
notificationMarkAsUnreadCommand,
|
|
11
9
|
} from "./_config/commands";
|
|
12
10
|
|
|
13
11
|
export default function Page() {
|
|
14
12
|
return (
|
|
15
13
|
<NotificationTableProvider
|
|
16
14
|
columns={notificationColumns}
|
|
17
|
-
commands={[
|
|
15
|
+
commands={[
|
|
16
|
+
notificationMarkAsReadCommand,
|
|
17
|
+
notificationMarkAsUnreadCommand,
|
|
18
|
+
notificationDeleteCommand,
|
|
19
|
+
]}
|
|
18
20
|
>
|
|
19
|
-
<NotificationTableToolbar
|
|
20
|
-
<Suspense fallback={<InlineCommandMenuLoading />}>
|
|
21
|
-
<InlineCommandMenu data={null} commands={[notificationTogglePushCommand]} />
|
|
22
|
-
</Suspense>
|
|
23
|
-
</NotificationTableToolbar>
|
|
24
|
-
|
|
21
|
+
<NotificationTableToolbar />
|
|
25
22
|
<DataTable />
|
|
26
23
|
</NotificationTableProvider>
|
|
27
24
|
);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { defineNotification } from "@tulip-systems/notifications";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Sent when a project is created.
|
|
6
|
+
*/
|
|
7
|
+
export const projectCreatedNotification = defineNotification("project.created", {
|
|
8
|
+
label: "Project created",
|
|
9
|
+
description: "A new project was added.",
|
|
10
|
+
category: "Projects",
|
|
11
|
+
permission: { project: ["view"] },
|
|
12
|
+
schema: z.object({
|
|
13
|
+
projectId: z.uuid(),
|
|
14
|
+
title: z.string(),
|
|
15
|
+
status: z.string(),
|
|
16
|
+
}),
|
|
17
|
+
content: ({ data }) => ({
|
|
18
|
+
title: "Project created",
|
|
19
|
+
body: `${data.title} was created with status ${data.status}.`,
|
|
20
|
+
href: `/admin/projects/${data.projectId}`,
|
|
21
|
+
source: { type: "project", id: data.projectId },
|
|
22
|
+
}),
|
|
23
|
+
channels: {
|
|
24
|
+
webPush: ({ data }) => ({ tag: `project:${data.projectId}` }),
|
|
25
|
+
email: true,
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Sent when a project moves to another status.
|
|
31
|
+
*/
|
|
32
|
+
export const projectStatusChangedNotification = defineNotification("project.status-changed", {
|
|
33
|
+
label: "Project status changed",
|
|
34
|
+
description: "A project moved to another status.",
|
|
35
|
+
category: "Projects",
|
|
36
|
+
permission: { project: ["view"] },
|
|
37
|
+
schema: z.object({
|
|
38
|
+
projectId: z.uuid(),
|
|
39
|
+
title: z.string(),
|
|
40
|
+
previousStatus: z.string(),
|
|
41
|
+
nextStatus: z.string(),
|
|
42
|
+
}),
|
|
43
|
+
content: ({ data }) => ({
|
|
44
|
+
title: "Project status changed",
|
|
45
|
+
body: `${data.title} moved from ${data.previousStatus} to ${data.nextStatus}.`,
|
|
46
|
+
href: `/admin/projects/${data.projectId}`,
|
|
47
|
+
source: { type: "project", id: data.projectId },
|
|
48
|
+
}),
|
|
49
|
+
channels: {
|
|
50
|
+
webPush: ({ data }) => ({ tag: `project:${data.projectId}` }),
|
|
51
|
+
email: true,
|
|
52
|
+
},
|
|
53
|
+
});
|
|
@@ -38,6 +38,11 @@ export default function Layout(props: PropsWithChildren) {
|
|
|
38
38
|
href: "/admin/settings/theme",
|
|
39
39
|
segment: "theme",
|
|
40
40
|
},
|
|
41
|
+
{
|
|
42
|
+
name: "Notifications",
|
|
43
|
+
href: "/admin/settings/notifications",
|
|
44
|
+
segment: "notifications",
|
|
45
|
+
},
|
|
41
46
|
{
|
|
42
47
|
name: "Queue",
|
|
43
48
|
href: "/admin/settings/queue",
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
|
4
|
+
import {
|
|
5
|
+
applyNotificationPreferences,
|
|
6
|
+
type NotificationPreferences,
|
|
7
|
+
} from "@tulip-systems/notifications";
|
|
8
|
+
import { NotificationPreferencesTable } from "@tulip-systems/notifications/client";
|
|
9
|
+
import { toast } from "@tulip-systems/ui/sonner";
|
|
10
|
+
import { Suspense } from "react";
|
|
11
|
+
import { PushSubscription } from "@/app/admin/notifications/_components/push-subscription-card";
|
|
12
|
+
import { orpc } from "@/server/router/client";
|
|
13
|
+
|
|
14
|
+
export default function Page() {
|
|
15
|
+
const queryClient = useQueryClient();
|
|
16
|
+
const queryKey = orpc.notifications.getPreferences.queryKey();
|
|
17
|
+
const { data: settings } = useSuspenseQuery(orpc.notifications.getPreferences.queryOptions());
|
|
18
|
+
|
|
19
|
+
const updatePreferences = useMutation(
|
|
20
|
+
orpc.notifications.updatePreferences.mutationOptions({
|
|
21
|
+
onMutate: async ({ preferences }) => {
|
|
22
|
+
await queryClient.cancelQueries({ queryKey });
|
|
23
|
+
const previous = queryClient.getQueryData(queryKey);
|
|
24
|
+
|
|
25
|
+
if (previous) {
|
|
26
|
+
queryClient.setQueryData(queryKey, applyNotificationPreferences(previous, preferences));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return { previous };
|
|
30
|
+
},
|
|
31
|
+
onError: (_error, _variables, context) => {
|
|
32
|
+
if (context?.previous) queryClient.setQueryData(queryKey, context.previous);
|
|
33
|
+
toast.error("Could not update notification preferences");
|
|
34
|
+
},
|
|
35
|
+
onSuccess: (updated) => queryClient.setQueryData(queryKey, updated),
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const onChange = (preferences: NotificationPreferences) =>
|
|
40
|
+
updatePreferences.mutate({ preferences });
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="space-y-6 p-content">
|
|
44
|
+
<section className="space-y-1">
|
|
45
|
+
<h2 className="font-bold text-lg">Notifications</h2>
|
|
46
|
+
<p className="text-muted-foreground text-sm">
|
|
47
|
+
Choose where you want to be notified. Use the switch above a channel to turn it off for
|
|
48
|
+
every notification. Every notification stays available in your inbox.
|
|
49
|
+
</p>
|
|
50
|
+
</section>
|
|
51
|
+
|
|
52
|
+
<Suspense>
|
|
53
|
+
<PushSubscription />
|
|
54
|
+
</Suspense>
|
|
55
|
+
|
|
56
|
+
<NotificationPreferencesTable settings={settings} onChange={onChange} />
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { defineNotification } from "@tulip-systems/notifications";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Sent when a task is created or imported.
|
|
6
|
+
*/
|
|
7
|
+
export const taskCreatedNotification = defineNotification("task.created", {
|
|
8
|
+
label: "Task created",
|
|
9
|
+
description: "A task was added to a project.",
|
|
10
|
+
category: "Tasks",
|
|
11
|
+
permission: { task: ["view"] },
|
|
12
|
+
schema: z.object({
|
|
13
|
+
taskId: z.uuid(),
|
|
14
|
+
code: z.string(),
|
|
15
|
+
title: z.string(),
|
|
16
|
+
projectTitle: z.string(),
|
|
17
|
+
}),
|
|
18
|
+
content: ({ data }) => ({
|
|
19
|
+
title: "Task created",
|
|
20
|
+
body: `${data.code} ${data.title} was added to ${data.projectTitle}.`,
|
|
21
|
+
href: `/admin/tasks/${data.taskId}`,
|
|
22
|
+
source: { type: "task", id: data.taskId },
|
|
23
|
+
}),
|
|
24
|
+
channels: {
|
|
25
|
+
webPush: ({ data }) => ({ tag: `task:${data.taskId}` }),
|
|
26
|
+
email: true,
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Sent to the new assignee when a task is created with an assignee or reassigned.
|
|
32
|
+
*/
|
|
33
|
+
export const taskAssignedNotification = defineNotification("task.assigned", {
|
|
34
|
+
label: "Task assigned to you",
|
|
35
|
+
description: "Someone assigned a task to you.",
|
|
36
|
+
category: "Tasks",
|
|
37
|
+
permission: { task: ["view"] },
|
|
38
|
+
schema: z.object({
|
|
39
|
+
taskId: z.uuid(),
|
|
40
|
+
code: z.string(),
|
|
41
|
+
title: z.string(),
|
|
42
|
+
projectTitle: z.string(),
|
|
43
|
+
assignedBy: z.string(),
|
|
44
|
+
}),
|
|
45
|
+
content: ({ data }) => ({
|
|
46
|
+
title: "Task assigned to you",
|
|
47
|
+
body: `${data.assignedBy} assigned ${data.code} ${data.title} in ${data.projectTitle} to you.`,
|
|
48
|
+
href: `/admin/tasks/${data.taskId}`,
|
|
49
|
+
source: { type: "task", id: data.taskId },
|
|
50
|
+
}),
|
|
51
|
+
channels: {
|
|
52
|
+
webPush: ({ data }) => ({ tag: `task:${data.taskId}` }),
|
|
53
|
+
email: ({ data }) => ({ subject: `${data.code} ${data.title} was assigned to you` }),
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Sent to the assignee and creator when a task moves to another status.
|
|
59
|
+
*/
|
|
60
|
+
export const taskStatusChangedNotification = defineNotification("task.status-changed", {
|
|
61
|
+
label: "Task status changed",
|
|
62
|
+
description: "A task you're assigned to or created moved to another status.",
|
|
63
|
+
category: "Tasks",
|
|
64
|
+
permission: { task: ["view"] },
|
|
65
|
+
schema: z.object({
|
|
66
|
+
taskId: z.uuid(),
|
|
67
|
+
code: z.string(),
|
|
68
|
+
title: z.string(),
|
|
69
|
+
previousStatus: z.string(),
|
|
70
|
+
nextStatus: z.string(),
|
|
71
|
+
changedBy: z.string(),
|
|
72
|
+
}),
|
|
73
|
+
content: ({ data }) => ({
|
|
74
|
+
title: "Task status changed",
|
|
75
|
+
body: `${data.changedBy} moved ${data.code} ${data.title} from ${data.previousStatus} to ${data.nextStatus}.`,
|
|
76
|
+
href: `/admin/tasks/${data.taskId}`,
|
|
77
|
+
source: { type: "task", id: data.taskId },
|
|
78
|
+
}),
|
|
79
|
+
channels: {
|
|
80
|
+
webPush: ({ data }) => ({ tag: `task:${data.taskId}` }),
|
|
81
|
+
email: true,
|
|
82
|
+
},
|
|
83
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createLoginURL } from "@tulip-systems/auth";
|
|
2
|
+
import { createNotificationOpenHandler } from "@tulip-systems/notifications/server";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { auth } from "@/server/auth/init";
|
|
5
|
+
import { notifications } from "@/server/notifications/init";
|
|
6
|
+
|
|
7
|
+
const inbox = "/admin/notifications";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Link target of push notifications and emails: marks the notification as read and redirects to
|
|
11
|
+
* its page. Visitors who are not signed in log in first and come back here.
|
|
12
|
+
*/
|
|
13
|
+
const open = createNotificationOpenHandler({
|
|
14
|
+
notifications,
|
|
15
|
+
getRecipientId: async (request) => {
|
|
16
|
+
const session = await auth.api.getSession({ headers: request.headers });
|
|
17
|
+
return session?.user.id;
|
|
18
|
+
},
|
|
19
|
+
onUnauthenticated: (request) => {
|
|
20
|
+
const { pathname, search } = new URL(request.url);
|
|
21
|
+
|
|
22
|
+
return new Response(null, {
|
|
23
|
+
status: 303,
|
|
24
|
+
headers: { location: createLoginURL(`${pathname}${search}`) },
|
|
25
|
+
});
|
|
26
|
+
},
|
|
27
|
+
fallback: inbox,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export async function GET(
|
|
31
|
+
request: Request,
|
|
32
|
+
{ params }: RouteContext<"/api/notifications/[id]/open">,
|
|
33
|
+
) {
|
|
34
|
+
const { id } = await params;
|
|
35
|
+
|
|
36
|
+
// Notification ids are UUIDs, so any other id cannot exist and would fail the database query.
|
|
37
|
+
if (!z.uuid().safeParse(id).success) {
|
|
38
|
+
return new Response(null, { status: 303, headers: { location: inbox } });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return await open(request, id);
|
|
42
|
+
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
@import "@tulip-systems/drive-google/styles.css";
|
|
10
10
|
@import "@tulip-systems/editor/styles.css";
|
|
11
11
|
@import "@tulip-systems/inline/styles.css";
|
|
12
|
+
@import "@tulip-systems/notifications/styles.css";
|
|
12
13
|
@import "@tulip-systems/query/styles.css";
|
|
13
14
|
@import "@tulip-systems/storage/styles.css";
|
|
14
15
|
|
|
@@ -22,7 +22,9 @@ import {
|
|
|
22
22
|
UserSearchIcon,
|
|
23
23
|
UsersIcon,
|
|
24
24
|
} from "lucide-react";
|
|
25
|
+
import { Suspense } from "react";
|
|
25
26
|
import { DRIVE_NAMESPACES } from "@/app/admin/drive/_config/namespaces";
|
|
27
|
+
import { NotificationUnreadBadge } from "@/app/admin/notifications/_components/unread-badge";
|
|
26
28
|
import { Icons } from "@/components/icons";
|
|
27
29
|
|
|
28
30
|
export function AdminSidebar() {
|
|
@@ -54,6 +56,10 @@ export function AdminSidebar() {
|
|
|
54
56
|
<BellIcon />
|
|
55
57
|
Notifications
|
|
56
58
|
</PathLink>
|
|
59
|
+
|
|
60
|
+
<Suspense>
|
|
61
|
+
<NotificationUnreadBadge />
|
|
62
|
+
</Suspense>
|
|
57
63
|
</Path>
|
|
58
64
|
|
|
59
65
|
<Path permission={{ drive: ["view"] }} segment="drive">
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import "server-cli-only";
|
|
2
|
+
|
|
3
|
+
import { url } from "@tulip-systems/core";
|
|
4
|
+
import type { NotificationChannelLink } from "@tulip-systems/notifications";
|
|
5
|
+
import {
|
|
6
|
+
type EmailContent,
|
|
7
|
+
emailChannel,
|
|
8
|
+
} from "@tulip-systems/notifications/channels/email/server";
|
|
9
|
+
import {
|
|
10
|
+
type WebPushContent,
|
|
11
|
+
webPushChannel,
|
|
12
|
+
} from "@tulip-systems/notifications/channels/web-push/server";
|
|
13
|
+
import { env } from "@/env";
|
|
14
|
+
import { config } from "@/lib/config/base";
|
|
15
|
+
import { email as resend } from "@/server/emails/init";
|
|
16
|
+
|
|
17
|
+
declare module "@tulip-systems/notifications" {
|
|
18
|
+
interface NotificationChannelRegistry {
|
|
19
|
+
webPush: WebPushContent;
|
|
20
|
+
email: EmailContent;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Push and email link to the open route, which marks the notification as read and redirects to
|
|
26
|
+
* its page.
|
|
27
|
+
*/
|
|
28
|
+
const notificationLink: NotificationChannelLink = (notification) =>
|
|
29
|
+
`/api/notifications/${notification.id}/open`;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Web Push channel. Without the VAPID keys it skips delivery and `publicKey` is `null`.
|
|
33
|
+
*/
|
|
34
|
+
export const notificationWebPushChannel = webPushChannel({
|
|
35
|
+
publicKey: env.NEXT_PUBLIC_WEB_PUSH_PUBLIC_KEY,
|
|
36
|
+
privateKey: env.WEB_PUSH_PRIVATE_KEY,
|
|
37
|
+
subject: env.WEB_PUSH_SUBJECT,
|
|
38
|
+
link: notificationLink,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Email channel that sends through Resend. Retries of one notification reuse the idempotency key,
|
|
43
|
+
* so Resend sends the email only once.
|
|
44
|
+
*/
|
|
45
|
+
export const notificationEmailChannel = emailChannel({
|
|
46
|
+
baseUrl: url(),
|
|
47
|
+
preferencesUrl: url("/admin/settings/notifications"),
|
|
48
|
+
link: notificationLink,
|
|
49
|
+
send: async ({ to, subject, react, idempotencyKey }) => {
|
|
50
|
+
const { error } = await resend.emails.send(
|
|
51
|
+
{ from: config.email.defaultFrom, to, subject, react },
|
|
52
|
+
{ idempotencyKey },
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
if (error) throw new Error(error.message);
|
|
56
|
+
},
|
|
57
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import {
|
|
2
|
+
projectCreatedNotification,
|
|
3
|
+
projectStatusChangedNotification,
|
|
4
|
+
} from "@/app/admin/projects/_lib/notifications";
|
|
5
|
+
import {
|
|
6
|
+
taskAssignedNotification,
|
|
7
|
+
taskCreatedNotification,
|
|
8
|
+
taskStatusChangedNotification,
|
|
9
|
+
} from "@/app/admin/tasks/_lib/notifications";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Notification types shown in the preferences and inbox filters.
|
|
13
|
+
*/
|
|
14
|
+
export const notificationDefinitions = [
|
|
15
|
+
projectCreatedNotification,
|
|
16
|
+
projectStatusChangedNotification,
|
|
17
|
+
taskCreatedNotification,
|
|
18
|
+
taskAssignedNotification,
|
|
19
|
+
taskStatusChangedNotification,
|
|
20
|
+
];
|