@codaijs/keel 0.1.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 (116) hide show
  1. package/dist/__tests__/cli.test.d.ts +2 -0
  2. package/dist/__tests__/cli.test.d.ts.map +1 -0
  3. package/dist/__tests__/cli.test.js +173 -0
  4. package/dist/__tests__/cli.test.js.map +1 -0
  5. package/dist/__tests__/registry.test.d.ts +2 -0
  6. package/dist/__tests__/registry.test.d.ts.map +1 -0
  7. package/dist/__tests__/registry.test.js +86 -0
  8. package/dist/__tests__/registry.test.js.map +1 -0
  9. package/dist/__tests__/sail-installer.test.d.ts +2 -0
  10. package/dist/__tests__/sail-installer.test.d.ts.map +1 -0
  11. package/dist/__tests__/sail-installer.test.js +158 -0
  12. package/dist/__tests__/sail-installer.test.js.map +1 -0
  13. package/dist/create-runner.d.ts +11 -0
  14. package/dist/create-runner.d.ts.map +1 -0
  15. package/dist/create-runner.js +63 -0
  16. package/dist/create-runner.js.map +1 -0
  17. package/dist/create.d.ts +10 -0
  18. package/dist/create.d.ts.map +1 -0
  19. package/dist/create.js +15 -0
  20. package/dist/create.js.map +1 -0
  21. package/dist/manage.d.ts +24 -0
  22. package/dist/manage.d.ts.map +1 -0
  23. package/dist/manage.js +1461 -0
  24. package/dist/manage.js.map +1 -0
  25. package/dist/prompts.d.ts +36 -0
  26. package/dist/prompts.d.ts.map +1 -0
  27. package/dist/prompts.js +208 -0
  28. package/dist/prompts.js.map +1 -0
  29. package/dist/sail-installer.d.ts +37 -0
  30. package/dist/sail-installer.d.ts.map +1 -0
  31. package/dist/sail-installer.js +935 -0
  32. package/dist/sail-installer.js.map +1 -0
  33. package/dist/scaffold.d.ts +10 -0
  34. package/dist/scaffold.d.ts.map +1 -0
  35. package/dist/scaffold.js +297 -0
  36. package/dist/scaffold.js.map +1 -0
  37. package/package.json +57 -0
  38. package/sails/_template/addon.json +20 -0
  39. package/sails/_template/install.ts +402 -0
  40. package/sails/admin-dashboard/README.md +117 -0
  41. package/sails/admin-dashboard/addon.json +28 -0
  42. package/sails/admin-dashboard/files/backend/middleware/admin.ts +34 -0
  43. package/sails/admin-dashboard/files/backend/routes/admin.ts +243 -0
  44. package/sails/admin-dashboard/files/frontend/components/admin/StatsCard.tsx +40 -0
  45. package/sails/admin-dashboard/files/frontend/components/admin/UsersTable.tsx +240 -0
  46. package/sails/admin-dashboard/files/frontend/hooks/useAdmin.ts +149 -0
  47. package/sails/admin-dashboard/files/frontend/pages/admin/Dashboard.tsx +173 -0
  48. package/sails/admin-dashboard/files/frontend/pages/admin/UserDetail.tsx +203 -0
  49. package/sails/admin-dashboard/install.ts +305 -0
  50. package/sails/analytics/README.md +178 -0
  51. package/sails/analytics/addon.json +27 -0
  52. package/sails/analytics/files/frontend/components/AnalyticsProvider.tsx +58 -0
  53. package/sails/analytics/files/frontend/hooks/useAnalytics.ts +64 -0
  54. package/sails/analytics/files/frontend/lib/analytics.ts +103 -0
  55. package/sails/analytics/install.ts +297 -0
  56. package/sails/file-uploads/README.md +191 -0
  57. package/sails/file-uploads/addon.json +30 -0
  58. package/sails/file-uploads/files/backend/routes/files.ts +198 -0
  59. package/sails/file-uploads/files/backend/schema/files.ts +36 -0
  60. package/sails/file-uploads/files/backend/services/file-storage.ts +128 -0
  61. package/sails/file-uploads/files/frontend/components/FileList.tsx +248 -0
  62. package/sails/file-uploads/files/frontend/components/FileUploadButton.tsx +147 -0
  63. package/sails/file-uploads/files/frontend/hooks/useFileUpload.ts +106 -0
  64. package/sails/file-uploads/files/frontend/hooks/useFiles.ts +118 -0
  65. package/sails/file-uploads/files/frontend/pages/Files.tsx +37 -0
  66. package/sails/file-uploads/install.ts +466 -0
  67. package/sails/gdpr/README.md +174 -0
  68. package/sails/gdpr/addon.json +27 -0
  69. package/sails/gdpr/files/backend/routes/gdpr.ts +140 -0
  70. package/sails/gdpr/files/backend/services/gdpr.ts +293 -0
  71. package/sails/gdpr/files/frontend/components/auth/ConsentCheckboxes.tsx +97 -0
  72. package/sails/gdpr/files/frontend/components/gdpr/AccountDeletionRequest.tsx +192 -0
  73. package/sails/gdpr/files/frontend/components/gdpr/DataExportButton.tsx +75 -0
  74. package/sails/gdpr/files/frontend/pages/PrivacyPolicy.tsx +186 -0
  75. package/sails/gdpr/install.ts +756 -0
  76. package/sails/google-oauth/README.md +121 -0
  77. package/sails/google-oauth/addon.json +22 -0
  78. package/sails/google-oauth/files/GoogleButton.tsx +50 -0
  79. package/sails/google-oauth/install.ts +252 -0
  80. package/sails/i18n/README.md +193 -0
  81. package/sails/i18n/addon.json +30 -0
  82. package/sails/i18n/files/frontend/components/LanguageSwitcher.tsx +108 -0
  83. package/sails/i18n/files/frontend/hooks/useLanguage.ts +31 -0
  84. package/sails/i18n/files/frontend/lib/i18n.ts +32 -0
  85. package/sails/i18n/files/frontend/locales/de/common.json +44 -0
  86. package/sails/i18n/files/frontend/locales/en/common.json +44 -0
  87. package/sails/i18n/install.ts +407 -0
  88. package/sails/push-notifications/README.md +163 -0
  89. package/sails/push-notifications/addon.json +31 -0
  90. package/sails/push-notifications/files/backend/routes/notifications.ts +153 -0
  91. package/sails/push-notifications/files/backend/schema/notifications.ts +31 -0
  92. package/sails/push-notifications/files/backend/services/notifications.ts +117 -0
  93. package/sails/push-notifications/files/frontend/components/PushNotificationInit.tsx +12 -0
  94. package/sails/push-notifications/files/frontend/hooks/usePushNotifications.ts +154 -0
  95. package/sails/push-notifications/install.ts +384 -0
  96. package/sails/r2-storage/README.md +101 -0
  97. package/sails/r2-storage/addon.json +29 -0
  98. package/sails/r2-storage/files/backend/services/storage.ts +71 -0
  99. package/sails/r2-storage/files/frontend/components/ProfilePictureUpload.tsx +167 -0
  100. package/sails/r2-storage/install.ts +412 -0
  101. package/sails/rate-limiting/README.md +145 -0
  102. package/sails/rate-limiting/addon.json +20 -0
  103. package/sails/rate-limiting/files/backend/middleware/rate-limit-store.ts +104 -0
  104. package/sails/rate-limiting/files/backend/middleware/rate-limit.ts +137 -0
  105. package/sails/rate-limiting/install.ts +300 -0
  106. package/sails/registry.json +107 -0
  107. package/sails/stripe/README.md +214 -0
  108. package/sails/stripe/addon.json +24 -0
  109. package/sails/stripe/files/backend/routes/stripe.ts +154 -0
  110. package/sails/stripe/files/backend/schema/stripe.ts +74 -0
  111. package/sails/stripe/files/backend/services/stripe.ts +224 -0
  112. package/sails/stripe/files/frontend/components/SubscriptionStatus.tsx +135 -0
  113. package/sails/stripe/files/frontend/hooks/useSubscription.ts +86 -0
  114. package/sails/stripe/files/frontend/pages/Checkout.tsx +116 -0
  115. package/sails/stripe/files/frontend/pages/Pricing.tsx +226 -0
  116. package/sails/stripe/install.ts +378 -0
@@ -0,0 +1,163 @@
1
+ # Push Notifications Sail
2
+
3
+ Adds push notification support to your keel application using Capacitor and Firebase Cloud Messaging (FCM).
4
+
5
+ ## Features
6
+
7
+ - Firebase Cloud Messaging for push delivery (iOS, Android)
8
+ - Capacitor integration for native device token management
9
+ - Device token registration and storage in PostgreSQL
10
+ - Server-side notification sending via firebase-admin
11
+ - React hook for permission handling, token lifecycle, and notification taps
12
+ - Automatic deep-link navigation on notification tap
13
+
14
+ ## Prerequisites
15
+
16
+ - A Firebase project (https://console.firebase.google.com)
17
+ - Cloud Messaging API (V1) enabled in the Firebase project
18
+ - A Firebase service account JSON key file
19
+ - For iOS: An APNs authentication key from Apple Developer
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ npx tsx sails/push-notifications/install.ts
25
+ ```
26
+
27
+ The installer will guide you through Firebase setup and collect your service account credentials.
28
+
29
+ ## Manual Setup
30
+
31
+ ### 1. Firebase Project
32
+
33
+ 1. Go to https://console.firebase.google.com
34
+ 2. Create a new project (or select existing)
35
+ 3. Go to **Project Settings > Cloud Messaging**
36
+ 4. Ensure Cloud Messaging API (V1) is enabled
37
+
38
+ ### 2. Service Account Key
39
+
40
+ 1. Go to **Project Settings > Service Accounts**
41
+ 2. Click **Generate new private key**
42
+ 3. Download the JSON file
43
+ 4. Extract these values for your `.env`:
44
+
45
+ ```env
46
+ FIREBASE_PROJECT_ID=your-project-id
47
+ FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
48
+ FIREBASE_CLIENT_EMAIL=firebase-adminsdk-xxxxx@your-project.iam.gserviceaccount.com
49
+ ```
50
+
51
+ ### 3. iOS Configuration (APNs)
52
+
53
+ 1. Go to https://developer.apple.com/account/resources/authkeys/list
54
+ 2. Create a new key, check **Apple Push Notifications service (APNs)**
55
+ 3. Download the `.p8` key file
56
+ 4. In Firebase Console > Project Settings > Cloud Messaging > iOS app:
57
+ - Upload the APNs authentication key
58
+ - Enter the Key ID and Team ID
59
+ 5. Run `npx cap sync ios`
60
+
61
+ ### 4. Android Configuration
62
+
63
+ 1. In Firebase Console, add an Android app (use your app's package name)
64
+ 2. Download `google-services.json`
65
+ 3. Place it at `android/app/google-services.json`
66
+ 4. Run `npx cap sync android`
67
+
68
+ ## Architecture
69
+
70
+ ### Database Schema
71
+
72
+ **push_tokens**
73
+ | Column | Type | Description |
74
+ |--------|------|-------------|
75
+ | id | text | Primary key (UUID) |
76
+ | user_id | text | FK to users table (cascade delete) |
77
+ | token | text | FCM device token |
78
+ | platform | varchar(20) | ios, android, or web |
79
+ | created_at | timestamp | Registration time |
80
+
81
+ ### API Routes
82
+
83
+ | Method | Path | Auth | Description |
84
+ |--------|------|------|-------------|
85
+ | POST | /api/notifications/register | Yes | Register a device push token |
86
+ | DELETE | /api/notifications/unregister | Yes | Remove a device push token |
87
+ | POST | /api/notifications/send | Yes | Send a notification to a user |
88
+
89
+ ### Backend Service
90
+
91
+ The `notifications` service provides two functions:
92
+
93
+ ```typescript
94
+ import {
95
+ sendPushNotification,
96
+ sendMultiplePushNotifications,
97
+ } from "./services/notifications.js";
98
+
99
+ // Send to a single device
100
+ await sendPushNotification(token, "Title", "Body", { route: "/notifications" });
101
+
102
+ // Send to multiple devices
103
+ await sendMultiplePushNotifications(tokens, "Title", "Body");
104
+ ```
105
+
106
+ ### Frontend Hook
107
+
108
+ ```tsx
109
+ import { usePushNotifications } from "@/hooks/usePushNotifications";
110
+
111
+ function MyComponent() {
112
+ const { isRegistered, permissionStatus, register, unregister } =
113
+ usePushNotifications();
114
+
115
+ return (
116
+ <div>
117
+ <p>Permission: {permissionStatus}</p>
118
+ <p>Registered: {isRegistered ? "Yes" : "No"}</p>
119
+ <button onClick={register}>Enable Notifications</button>
120
+ <button onClick={unregister}>Disable Notifications</button>
121
+ </div>
122
+ );
123
+ }
124
+ ```
125
+
126
+ ### PushNotificationInit Component
127
+
128
+ The `<PushNotificationInit />` component is placed in `Layout.tsx` and silently handles push registration on app mount. It renders nothing visible.
129
+
130
+ ### Deep Linking
131
+
132
+ When a user taps a notification, the hook checks for a `route` field in the notification data payload and navigates to it:
133
+
134
+ ```typescript
135
+ // When sending a notification, include a route:
136
+ await sendPushNotification(token, "New Message", "You have a new message", {
137
+ route: "/messages/123",
138
+ });
139
+ ```
140
+
141
+ ## Testing
142
+
143
+ Push notifications **only work on physical devices**, not simulators or emulators.
144
+
145
+ ### Sending Test Notifications
146
+
147
+ 1. **Firebase Console**: Go to Messaging > Create your first campaign > Notifications
148
+ 2. **API endpoint**: `POST /api/notifications/send` with body:
149
+ ```json
150
+ {
151
+ "userId": "user-id-here",
152
+ "title": "Test Notification",
153
+ "body": "This is a test push notification",
154
+ "data": { "route": "/profile" }
155
+ }
156
+ ```
157
+
158
+ ### Debugging
159
+
160
+ - Check the browser/device console for registration logs
161
+ - Check server logs for firebase-admin errors
162
+ - Verify the FCM token is stored in the `push_tokens` table
163
+ - Make sure APNs (iOS) or google-services.json (Android) are configured correctly
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "push-notifications",
3
+ "displayName": "Push Notifications",
4
+ "description": "Push notifications via Capacitor + Firebase Cloud Messaging with device token management and server-side sending",
5
+ "version": "1.0.0",
6
+ "compatibility": ">=1.0.0",
7
+ "requiredEnvVars": [
8
+ { "key": "FIREBASE_PROJECT_ID", "description": "Firebase project ID (from Firebase Console > Project Settings)" },
9
+ { "key": "FIREBASE_PRIVATE_KEY", "description": "Firebase service account private key (the full PEM key including -----BEGIN/END-----)" },
10
+ { "key": "FIREBASE_CLIENT_EMAIL", "description": "Firebase service account client email (e.g., firebase-adminsdk-xxxxx@project.iam.gserviceaccount.com)" }
11
+ ],
12
+ "dependencies": {
13
+ "backend": { "firebase-admin": "^13.0.0" },
14
+ "frontend": { "@capacitor/push-notifications": "^6.0.0" }
15
+ },
16
+ "modifies": {
17
+ "backend": ["src/index.ts", "src/db/schema/index.ts", "src/env.ts"],
18
+ "frontend": ["src/components/layout/Layout.tsx"]
19
+ },
20
+ "adds": {
21
+ "backend": [
22
+ "src/db/schema/notifications.ts",
23
+ "src/routes/notifications.ts",
24
+ "src/services/notifications.ts"
25
+ ],
26
+ "frontend": [
27
+ "src/hooks/usePushNotifications.ts",
28
+ "src/components/PushNotificationInit.tsx"
29
+ ]
30
+ }
31
+ }
@@ -0,0 +1,153 @@
1
+ import { Router, type Request, type Response } from "express";
2
+ import { eq, and } from "drizzle-orm";
3
+ import { db } from "../db/index.js";
4
+ import { pushTokens } from "../db/schema/notifications.js";
5
+ import {
6
+ sendPushNotification,
7
+ sendMultiplePushNotifications,
8
+ } from "../services/notifications.js";
9
+ import { requireAuth } from "../middleware/auth.js";
10
+
11
+ export const notificationsRouter = Router();
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // POST /register — Register a device push token
15
+ // ---------------------------------------------------------------------------
16
+
17
+ notificationsRouter.post(
18
+ "/register",
19
+ requireAuth,
20
+ async (req: Request, res: Response) => {
21
+ try {
22
+ const { token, platform } = req.body;
23
+
24
+ if (!token || typeof token !== "string") {
25
+ return res.status(400).json({ error: "token is required" });
26
+ }
27
+
28
+ const userId = req.user!.id;
29
+
30
+ // Check if this token is already registered for this user
31
+ const existing = await db.query.pushTokens.findFirst({
32
+ where: and(
33
+ eq(pushTokens.userId, userId),
34
+ eq(pushTokens.token, token),
35
+ ),
36
+ });
37
+
38
+ if (existing) {
39
+ return res.json({ message: "Token already registered", id: existing.id });
40
+ }
41
+
42
+ // Insert the new token
43
+ const [inserted] = await db
44
+ .insert(pushTokens)
45
+ .values({
46
+ userId,
47
+ token,
48
+ platform: platform ?? null,
49
+ })
50
+ .returning({ id: pushTokens.id });
51
+
52
+ return res.status(201).json({ message: "Token registered", id: inserted.id });
53
+ } catch (error) {
54
+ console.error("Error registering push token:", error);
55
+ return res.status(500).json({ error: "Failed to register push token" });
56
+ }
57
+ },
58
+ );
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // DELETE /unregister — Remove a device push token
62
+ // ---------------------------------------------------------------------------
63
+
64
+ notificationsRouter.delete(
65
+ "/unregister",
66
+ requireAuth,
67
+ async (req: Request, res: Response) => {
68
+ try {
69
+ const { token } = req.body;
70
+
71
+ if (!token || typeof token !== "string") {
72
+ return res.status(400).json({ error: "token is required" });
73
+ }
74
+
75
+ const userId = req.user!.id;
76
+
77
+ await db
78
+ .delete(pushTokens)
79
+ .where(
80
+ and(
81
+ eq(pushTokens.userId, userId),
82
+ eq(pushTokens.token, token),
83
+ ),
84
+ );
85
+
86
+ return res.json({ message: "Token unregistered" });
87
+ } catch (error) {
88
+ console.error("Error unregistering push token:", error);
89
+ return res.status(500).json({ error: "Failed to unregister push token" });
90
+ }
91
+ },
92
+ );
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // POST /send — Send a notification to a user (admin/internal use)
96
+ // ---------------------------------------------------------------------------
97
+
98
+ notificationsRouter.post(
99
+ "/send",
100
+ requireAuth,
101
+ async (req: Request, res: Response) => {
102
+ try {
103
+ const { userId, title, body, data } = req.body;
104
+
105
+ if (!userId || typeof userId !== "string") {
106
+ return res.status(400).json({ error: "userId is required" });
107
+ }
108
+ if (!title || typeof title !== "string") {
109
+ return res.status(400).json({ error: "title is required" });
110
+ }
111
+ if (!body || typeof body !== "string") {
112
+ return res.status(400).json({ error: "body is required" });
113
+ }
114
+
115
+ // Fetch all tokens for the target user
116
+ const tokens = await db.query.pushTokens.findMany({
117
+ where: eq(pushTokens.userId, userId),
118
+ });
119
+
120
+ if (tokens.length === 0) {
121
+ return res.status(404).json({ error: "No push tokens found for user" });
122
+ }
123
+
124
+ const tokenStrings = tokens.map((t) => t.token);
125
+
126
+ if (tokenStrings.length === 1) {
127
+ const messageId = await sendPushNotification(
128
+ tokenStrings[0],
129
+ title,
130
+ body,
131
+ data,
132
+ );
133
+ return res.json({ message: "Notification sent", messageId });
134
+ }
135
+
136
+ const result = await sendMultiplePushNotifications(
137
+ tokenStrings,
138
+ title,
139
+ body,
140
+ data,
141
+ );
142
+
143
+ return res.json({
144
+ message: "Notifications sent",
145
+ successCount: result.successCount,
146
+ failureCount: result.failureCount,
147
+ });
148
+ } catch (error) {
149
+ console.error("Error sending push notification:", error);
150
+ return res.status(500).json({ error: "Failed to send notification" });
151
+ }
152
+ },
153
+ );
@@ -0,0 +1,31 @@
1
+ import { pgTable, text, varchar, timestamp } from "drizzle-orm/pg-core";
2
+ import { relations } from "drizzle-orm";
3
+ import { users } from "./users.js";
4
+
5
+ /**
6
+ * Push notification device tokens table.
7
+ *
8
+ * Stores FCM device tokens for each user. A user can have multiple tokens
9
+ * (one per device). Tokens are registered when the user grants push permission
10
+ * on a native device and removed on logout or token invalidation.
11
+ */
12
+ export const pushTokens = pgTable("push_tokens", {
13
+ id: text("id")
14
+ .primaryKey()
15
+ .$defaultFn(() => crypto.randomUUID()),
16
+ userId: text("user_id")
17
+ .notNull()
18
+ .references(() => users.id, { onDelete: "cascade" }),
19
+ token: text("token").notNull(),
20
+ platform: varchar("platform", { length: 20 }),
21
+ createdAt: timestamp("created_at", { withTimezone: true })
22
+ .notNull()
23
+ .defaultNow(),
24
+ });
25
+
26
+ export const pushTokensRelations = relations(pushTokens, ({ one }) => ({
27
+ user: one(users, {
28
+ fields: [pushTokens.userId],
29
+ references: [users.id],
30
+ }),
31
+ }));
@@ -0,0 +1,117 @@
1
+ import admin from "firebase-admin";
2
+ import { env } from "../env.js";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Firebase Admin initialization
6
+ // ---------------------------------------------------------------------------
7
+
8
+ if (!admin.apps.length) {
9
+ admin.initializeApp({
10
+ credential: admin.credential.cert({
11
+ projectId: env.FIREBASE_PROJECT_ID,
12
+ privateKey: env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, "\n"),
13
+ clientEmail: env.FIREBASE_CLIENT_EMAIL,
14
+ }),
15
+ });
16
+ }
17
+
18
+ const messaging = admin.messaging();
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Send a push notification to a single device
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /**
25
+ * Send a push notification to a single device token.
26
+ *
27
+ * @param token - The FCM device token
28
+ * @param title - Notification title
29
+ * @param body - Notification body text
30
+ * @param data - Optional key-value data payload (for deep linking, etc.)
31
+ * @returns The message ID from Firebase
32
+ */
33
+ export async function sendPushNotification(
34
+ token: string,
35
+ title: string,
36
+ body: string,
37
+ data?: Record<string, string>,
38
+ ): Promise<string> {
39
+ const message: admin.messaging.Message = {
40
+ token,
41
+ notification: {
42
+ title,
43
+ body,
44
+ },
45
+ data,
46
+ android: {
47
+ priority: "high",
48
+ notification: {
49
+ sound: "default",
50
+ channelId: "default",
51
+ },
52
+ },
53
+ apns: {
54
+ payload: {
55
+ aps: {
56
+ sound: "default",
57
+ badge: 1,
58
+ },
59
+ },
60
+ },
61
+ };
62
+
63
+ return messaging.send(message);
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Send push notifications to multiple devices
68
+ // ---------------------------------------------------------------------------
69
+
70
+ /**
71
+ * Send a push notification to multiple device tokens.
72
+ *
73
+ * Uses `sendEachForMulticast` to handle per-token delivery. Returns the
74
+ * batch response so callers can check individual success/failure.
75
+ *
76
+ * @param tokens - Array of FCM device tokens
77
+ * @param title - Notification title
78
+ * @param body - Notification body text
79
+ * @param data - Optional key-value data payload
80
+ * @returns The batch response from Firebase
81
+ */
82
+ export async function sendMultiplePushNotifications(
83
+ tokens: string[],
84
+ title: string,
85
+ body: string,
86
+ data?: Record<string, string>,
87
+ ): Promise<admin.messaging.BatchResponse> {
88
+ if (tokens.length === 0) {
89
+ return { responses: [], successCount: 0, failureCount: 0 };
90
+ }
91
+
92
+ const message: admin.messaging.MulticastMessage = {
93
+ tokens,
94
+ notification: {
95
+ title,
96
+ body,
97
+ },
98
+ data,
99
+ android: {
100
+ priority: "high",
101
+ notification: {
102
+ sound: "default",
103
+ channelId: "default",
104
+ },
105
+ },
106
+ apns: {
107
+ payload: {
108
+ aps: {
109
+ sound: "default",
110
+ badge: 1,
111
+ },
112
+ },
113
+ },
114
+ };
115
+
116
+ return messaging.sendEachForMulticast(message);
117
+ }
@@ -0,0 +1,12 @@
1
+ import { usePushNotifications } from "@/hooks/usePushNotifications.js";
2
+
3
+ /**
4
+ * Invisible component that initializes push notification registration.
5
+ *
6
+ * Place this inside Layout.tsx so that push notifications are set up as soon
7
+ * as the app mounts on a native device. Renders nothing visible.
8
+ */
9
+ export function PushNotificationInit() {
10
+ usePushNotifications();
11
+ return null;
12
+ }
@@ -0,0 +1,154 @@
1
+ import { useState, useEffect, useCallback, useRef } from "react";
2
+ import { useNavigate } from "react-router";
3
+ import { PushNotifications } from "@capacitor/push-notifications";
4
+ import { isNative, platform } from "@/lib/capacitor.js";
5
+ import { useAuth } from "@/hooks/useAuth.js";
6
+
7
+ type PermissionStatus = "prompt" | "granted" | "denied" | "unknown";
8
+
9
+ interface UsePushNotificationsResult {
10
+ isRegistered: boolean;
11
+ permissionStatus: PermissionStatus;
12
+ register: () => Promise<void>;
13
+ unregister: () => Promise<void>;
14
+ }
15
+
16
+ /**
17
+ * Hook to manage push notification registration and handling.
18
+ *
19
+ * On mount (if running on a native platform), requests push permission,
20
+ * registers the device token with the backend, and sets up listeners for
21
+ * incoming notifications and token refreshes.
22
+ *
23
+ * On notification tap, navigates to the route specified in the notification
24
+ * data payload (data.route).
25
+ */
26
+ export function usePushNotifications(): UsePushNotificationsResult {
27
+ const [isRegistered, setIsRegistered] = useState(false);
28
+ const [permissionStatus, setPermissionStatus] = useState<PermissionStatus>("unknown");
29
+ const currentTokenRef = useRef<string | null>(null);
30
+ const navigate = useNavigate();
31
+ const { user } = useAuth();
32
+
33
+ // Register token with backend
34
+ const registerTokenWithBackend = useCallback(
35
+ async (token: string) => {
36
+ try {
37
+ const response = await fetch("/api/notifications/register", {
38
+ method: "POST",
39
+ headers: { "Content-Type": "application/json" },
40
+ credentials: "include",
41
+ body: JSON.stringify({
42
+ token,
43
+ platform: platform,
44
+ }),
45
+ });
46
+
47
+ if (response.ok) {
48
+ currentTokenRef.current = token;
49
+ setIsRegistered(true);
50
+ }
51
+ } catch (error) {
52
+ console.error("Failed to register push token with backend:", error);
53
+ }
54
+ },
55
+ [],
56
+ );
57
+
58
+ // Unregister token from backend
59
+ const unregisterTokenFromBackend = useCallback(async () => {
60
+ if (!currentTokenRef.current) return;
61
+
62
+ try {
63
+ await fetch("/api/notifications/unregister", {
64
+ method: "DELETE",
65
+ headers: { "Content-Type": "application/json" },
66
+ credentials: "include",
67
+ body: JSON.stringify({ token: currentTokenRef.current }),
68
+ });
69
+
70
+ currentTokenRef.current = null;
71
+ setIsRegistered(false);
72
+ } catch (error) {
73
+ console.error("Failed to unregister push token:", error);
74
+ }
75
+ }, []);
76
+
77
+ // Request permission and register
78
+ const register = useCallback(async () => {
79
+ if (!isNative) return;
80
+
81
+ const permResult = await PushNotifications.requestPermissions();
82
+ setPermissionStatus(permResult.receive as PermissionStatus);
83
+
84
+ if (permResult.receive === "granted") {
85
+ await PushNotifications.register();
86
+ }
87
+ }, []);
88
+
89
+ // Unregister
90
+ const unregister = useCallback(async () => {
91
+ await unregisterTokenFromBackend();
92
+ }, [unregisterTokenFromBackend]);
93
+
94
+ useEffect(() => {
95
+ if (!isNative || !user) return;
96
+
97
+ // Check current permission status
98
+ PushNotifications.checkPermissions().then((result) => {
99
+ setPermissionStatus(result.receive as PermissionStatus);
100
+ });
101
+
102
+ // Listen for successful registration (we receive the FCM token)
103
+ const registrationListener = PushNotifications.addListener(
104
+ "registration",
105
+ (token) => {
106
+ registerTokenWithBackend(token.value);
107
+ },
108
+ );
109
+
110
+ // Listen for registration errors
111
+ const registrationErrorListener = PushNotifications.addListener(
112
+ "registrationError",
113
+ (error) => {
114
+ console.error("Push notification registration error:", error);
115
+ },
116
+ );
117
+
118
+ // Listen for incoming push notifications (app in foreground)
119
+ const pushReceivedListener = PushNotifications.addListener(
120
+ "pushNotificationReceived",
121
+ (notification) => {
122
+ console.log("Push notification received in foreground:", notification);
123
+ },
124
+ );
125
+
126
+ // Listen for notification taps (app opened from notification)
127
+ const pushActionListener = PushNotifications.addListener(
128
+ "pushNotificationActionPerformed",
129
+ (action) => {
130
+ const data = action.notification.data;
131
+ if (data?.route && typeof data.route === "string") {
132
+ navigate(data.route);
133
+ }
134
+ },
135
+ );
136
+
137
+ // Request permissions and register on mount
138
+ register();
139
+
140
+ return () => {
141
+ registrationListener.then((l) => l.remove());
142
+ registrationErrorListener.then((l) => l.remove());
143
+ pushReceivedListener.then((l) => l.remove());
144
+ pushActionListener.then((l) => l.remove());
145
+ };
146
+ }, [user, register, registerTokenWithBackend, navigate]);
147
+
148
+ return {
149
+ isRegistered,
150
+ permissionStatus,
151
+ register,
152
+ unregister,
153
+ };
154
+ }