@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
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import "server-cli-only";
|
|
2
|
+
|
|
3
|
+
import type { Permission } from "@tulip-systems/auth/permissions";
|
|
4
|
+
import { notificationsDrizzleAdapter } from "@tulip-systems/notifications/drizzle";
|
|
5
|
+
import { Notifications } from "@tulip-systems/notifications/server";
|
|
6
|
+
import { inArray } from "drizzle-orm";
|
|
7
|
+
import * as schema from "@/app/admin/notifications/_lib/schema";
|
|
8
|
+
import { auth } from "@/server/auth/init";
|
|
9
|
+
import type { AccessControl } from "@/server/auth/permissions";
|
|
10
|
+
import { users } from "@/server/auth/schema";
|
|
11
|
+
import { db } from "@/server/db/init";
|
|
12
|
+
import { notificationEmailChannel, notificationWebPushChannel } from "./channels";
|
|
13
|
+
import { notificationDefinitions } from "./definitions";
|
|
14
|
+
|
|
15
|
+
declare module "@tulip-systems/notifications" {
|
|
16
|
+
interface NotificationRegister {
|
|
17
|
+
permission: Permission<AccessControl>;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Initializes the notifications service for the application. Notifications that require a
|
|
23
|
+
* permission only reach users whose role grants it, checked by better-auth like the router's
|
|
24
|
+
* permission middleware.
|
|
25
|
+
*/
|
|
26
|
+
export const notifications = Notifications.init({
|
|
27
|
+
database: notificationsDrizzleAdapter({ db, schema }),
|
|
28
|
+
channels: [notificationWebPushChannel, notificationEmailChannel],
|
|
29
|
+
defaultPreferences: {
|
|
30
|
+
types: {
|
|
31
|
+
"project.created": { email: false },
|
|
32
|
+
"project.status-changed": { email: false },
|
|
33
|
+
"task.created": { email: false },
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
definitions: notificationDefinitions,
|
|
37
|
+
recipients: (ids) =>
|
|
38
|
+
db
|
|
39
|
+
.select({ id: users.id, name: users.name, email: users.email })
|
|
40
|
+
.from(users)
|
|
41
|
+
.where(inArray(users.id, ids)),
|
|
42
|
+
authorize: async (recipient, permission) => {
|
|
43
|
+
const { success } = await auth.api.userHasPermission({
|
|
44
|
+
body: { userId: recipient.id, permissions: permission },
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return success;
|
|
48
|
+
},
|
|
49
|
+
});
|
|
@@ -52,19 +52,19 @@
|
|
|
52
52
|
"@tiptap/extension-text-style": "catalog:",
|
|
53
53
|
"@tiptap/extension-underline": "catalog:",
|
|
54
54
|
"@tiptap/extensions": "catalog:",
|
|
55
|
-
"@tulip-systems/app": "0.
|
|
56
|
-
"@tulip-systems/auth": "0.
|
|
57
|
-
"@tulip-systems/commands": "0.
|
|
58
|
-
"@tulip-systems/core": "0.
|
|
59
|
-
"@tulip-systems/data-tables": "0.
|
|
60
|
-
"@tulip-systems/drive": "0.
|
|
61
|
-
"@tulip-systems/drive-local": "0.
|
|
62
|
-
"@tulip-systems/editor": "0.
|
|
63
|
-
"@tulip-systems/editor-storage": "0.
|
|
64
|
-
"@tulip-systems/inline": "0.
|
|
65
|
-
"@tulip-systems/query": "0.
|
|
66
|
-
"@tulip-systems/storage": "0.
|
|
67
|
-
"@tulip-systems/ui": "0.
|
|
55
|
+
"@tulip-systems/app": "0.15.0",
|
|
56
|
+
"@tulip-systems/auth": "0.15.0",
|
|
57
|
+
"@tulip-systems/commands": "0.15.0",
|
|
58
|
+
"@tulip-systems/core": "0.15.0",
|
|
59
|
+
"@tulip-systems/data-tables": "0.15.0",
|
|
60
|
+
"@tulip-systems/drive": "0.15.0",
|
|
61
|
+
"@tulip-systems/drive-local": "0.15.0",
|
|
62
|
+
"@tulip-systems/editor": "0.15.0",
|
|
63
|
+
"@tulip-systems/editor-storage": "0.15.0",
|
|
64
|
+
"@tulip-systems/inline": "0.15.0",
|
|
65
|
+
"@tulip-systems/query": "0.15.0",
|
|
66
|
+
"@tulip-systems/storage": "0.15.0",
|
|
67
|
+
"@tulip-systems/ui": "0.15.0",
|
|
68
68
|
"@vercel/functions": "^3.9.0",
|
|
69
69
|
"@zip.js/zip.js": "^2.8.34",
|
|
70
70
|
"better-auth": "catalog:",
|
package/package.json
CHANGED
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useSuspenseQuery } from "@tanstack/react-query";
|
|
4
|
-
import { CommandClick, CommandLabel } from "@tulip-systems/commands/client";
|
|
5
|
-
import { toast } from "@tulip-systems/ui/sonner";
|
|
6
|
-
import { BellIcon, BellOffIcon } from "lucide-react";
|
|
7
|
-
import { orpc } from "@/server/router/client";
|
|
8
|
-
|
|
9
|
-
const notificationPushSubscriptionQueryKey = ["notifications", "push-subscription"] as const;
|
|
10
|
-
|
|
11
|
-
export function CommandTogglePush() {
|
|
12
|
-
const { data: config } = useSuspenseQuery(orpc.notifications.getPushConfig.queryOptions());
|
|
13
|
-
const { data } = useSuspenseQuery({
|
|
14
|
-
queryKey: notificationPushSubscriptionQueryKey,
|
|
15
|
-
queryFn: async () => {
|
|
16
|
-
const supported =
|
|
17
|
-
typeof window !== "undefined" &&
|
|
18
|
-
"serviceWorker" in navigator &&
|
|
19
|
-
"PushManager" in window &&
|
|
20
|
-
"Notification" in window;
|
|
21
|
-
|
|
22
|
-
if (!supported) return { supported, enabled: false };
|
|
23
|
-
|
|
24
|
-
const registration = await navigator.serviceWorker.getRegistration("/sw.js");
|
|
25
|
-
const subscription = await registration?.pushManager.getSubscription();
|
|
26
|
-
|
|
27
|
-
return { supported, enabled: Boolean(subscription) };
|
|
28
|
-
},
|
|
29
|
-
refetchOnMount: "always",
|
|
30
|
-
staleTime: 0,
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
return (
|
|
34
|
-
<CommandClick
|
|
35
|
-
label={data.enabled ? "Disable push" : "Enable push"}
|
|
36
|
-
disabled={!data.supported}
|
|
37
|
-
variables={{ ...data, config }}
|
|
38
|
-
mutation={{
|
|
39
|
-
mutationFn: async (variables) => {
|
|
40
|
-
if (variables.enabled) {
|
|
41
|
-
const registration = await navigator.serviceWorker.getRegistration("/sw.js");
|
|
42
|
-
const subscription = await registration?.pushManager.getSubscription();
|
|
43
|
-
|
|
44
|
-
if (subscription) {
|
|
45
|
-
await orpc.notifications.unsubscribePush.call({ endpoint: subscription.endpoint });
|
|
46
|
-
await subscription.unsubscribe();
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
return false;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
if (!variables.supported) throw new Error("This browser does not support Web Push");
|
|
53
|
-
if (!variables.config.configured || !variables.config.publicKey) {
|
|
54
|
-
throw new Error("Web Push is not configured for this environment");
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
if (Notification.permission === "denied") {
|
|
58
|
-
throw new Error("Notifications are blocked for this site");
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const [registration, permission] = await Promise.all([
|
|
62
|
-
navigator.serviceWorker.register("/sw.js"),
|
|
63
|
-
Notification.requestPermission(),
|
|
64
|
-
]);
|
|
65
|
-
|
|
66
|
-
if (permission !== "granted") {
|
|
67
|
-
throw new Error("Notification permission was not granted");
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const publicKey = variables.config.publicKey;
|
|
71
|
-
const padding = "=".repeat((4 - (publicKey.length % 4)) % 4);
|
|
72
|
-
const base64 = `${publicKey}${padding}`.replace(/-/g, "+").replace(/_/g, "/");
|
|
73
|
-
const applicationServerKey = Uint8Array.from(window.atob(base64), (char) =>
|
|
74
|
-
char.charCodeAt(0),
|
|
75
|
-
);
|
|
76
|
-
const subscription = await registration.pushManager.subscribe({
|
|
77
|
-
userVisibleOnly: true,
|
|
78
|
-
applicationServerKey,
|
|
79
|
-
});
|
|
80
|
-
const subscriptionJson = subscription.toJSON();
|
|
81
|
-
|
|
82
|
-
if (!subscriptionJson.keys?.p256dh || !subscriptionJson.keys.auth) {
|
|
83
|
-
await subscription.unsubscribe();
|
|
84
|
-
throw new Error("Browser returned an invalid push subscription");
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
await orpc.notifications.subscribePush.call({
|
|
88
|
-
endpoint: subscription.endpoint,
|
|
89
|
-
expirationTime: subscription.expirationTime,
|
|
90
|
-
keys: {
|
|
91
|
-
p256dh: subscriptionJson.keys.p256dh,
|
|
92
|
-
auth: subscriptionJson.keys.auth,
|
|
93
|
-
},
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
return true;
|
|
97
|
-
},
|
|
98
|
-
meta: { invalidates: [notificationPushSubscriptionQueryKey] },
|
|
99
|
-
onSuccess: (enabled) => {
|
|
100
|
-
toast.success(
|
|
101
|
-
enabled ? "Browser notifications enabled" : "Browser notifications disabled",
|
|
102
|
-
);
|
|
103
|
-
},
|
|
104
|
-
onError: (error) => {
|
|
105
|
-
toast.error(
|
|
106
|
-
error instanceof Error ? error.message : "Could not update push notifications",
|
|
107
|
-
);
|
|
108
|
-
},
|
|
109
|
-
}}
|
|
110
|
-
>
|
|
111
|
-
{data.enabled ? <BellOffIcon className="size-4" /> : <BellIcon className="size-4" />}
|
|
112
|
-
<CommandLabel />
|
|
113
|
-
</CommandClick>
|
|
114
|
-
);
|
|
115
|
-
}
|
|
@@ -1,80 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,19 +0,0 @@
|
|
|
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
|
-
});
|