@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.
- package/dist/__tests__/cli.test.d.ts +2 -0
- package/dist/__tests__/cli.test.d.ts.map +1 -0
- package/dist/__tests__/cli.test.js +173 -0
- package/dist/__tests__/cli.test.js.map +1 -0
- package/dist/__tests__/registry.test.d.ts +2 -0
- package/dist/__tests__/registry.test.d.ts.map +1 -0
- package/dist/__tests__/registry.test.js +86 -0
- package/dist/__tests__/registry.test.js.map +1 -0
- package/dist/__tests__/sail-installer.test.d.ts +2 -0
- package/dist/__tests__/sail-installer.test.d.ts.map +1 -0
- package/dist/__tests__/sail-installer.test.js +158 -0
- package/dist/__tests__/sail-installer.test.js.map +1 -0
- package/dist/create-runner.d.ts +11 -0
- package/dist/create-runner.d.ts.map +1 -0
- package/dist/create-runner.js +63 -0
- package/dist/create-runner.js.map +1 -0
- package/dist/create.d.ts +10 -0
- package/dist/create.d.ts.map +1 -0
- package/dist/create.js +15 -0
- package/dist/create.js.map +1 -0
- package/dist/manage.d.ts +24 -0
- package/dist/manage.d.ts.map +1 -0
- package/dist/manage.js +1461 -0
- package/dist/manage.js.map +1 -0
- package/dist/prompts.d.ts +36 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +208 -0
- package/dist/prompts.js.map +1 -0
- package/dist/sail-installer.d.ts +37 -0
- package/dist/sail-installer.d.ts.map +1 -0
- package/dist/sail-installer.js +935 -0
- package/dist/sail-installer.js.map +1 -0
- package/dist/scaffold.d.ts +10 -0
- package/dist/scaffold.d.ts.map +1 -0
- package/dist/scaffold.js +297 -0
- package/dist/scaffold.js.map +1 -0
- package/package.json +57 -0
- package/sails/_template/addon.json +20 -0
- package/sails/_template/install.ts +402 -0
- package/sails/admin-dashboard/README.md +117 -0
- package/sails/admin-dashboard/addon.json +28 -0
- package/sails/admin-dashboard/files/backend/middleware/admin.ts +34 -0
- package/sails/admin-dashboard/files/backend/routes/admin.ts +243 -0
- package/sails/admin-dashboard/files/frontend/components/admin/StatsCard.tsx +40 -0
- package/sails/admin-dashboard/files/frontend/components/admin/UsersTable.tsx +240 -0
- package/sails/admin-dashboard/files/frontend/hooks/useAdmin.ts +149 -0
- package/sails/admin-dashboard/files/frontend/pages/admin/Dashboard.tsx +173 -0
- package/sails/admin-dashboard/files/frontend/pages/admin/UserDetail.tsx +203 -0
- package/sails/admin-dashboard/install.ts +305 -0
- package/sails/analytics/README.md +178 -0
- package/sails/analytics/addon.json +27 -0
- package/sails/analytics/files/frontend/components/AnalyticsProvider.tsx +58 -0
- package/sails/analytics/files/frontend/hooks/useAnalytics.ts +64 -0
- package/sails/analytics/files/frontend/lib/analytics.ts +103 -0
- package/sails/analytics/install.ts +297 -0
- package/sails/file-uploads/README.md +191 -0
- package/sails/file-uploads/addon.json +30 -0
- package/sails/file-uploads/files/backend/routes/files.ts +198 -0
- package/sails/file-uploads/files/backend/schema/files.ts +36 -0
- package/sails/file-uploads/files/backend/services/file-storage.ts +128 -0
- package/sails/file-uploads/files/frontend/components/FileList.tsx +248 -0
- package/sails/file-uploads/files/frontend/components/FileUploadButton.tsx +147 -0
- package/sails/file-uploads/files/frontend/hooks/useFileUpload.ts +106 -0
- package/sails/file-uploads/files/frontend/hooks/useFiles.ts +118 -0
- package/sails/file-uploads/files/frontend/pages/Files.tsx +37 -0
- package/sails/file-uploads/install.ts +466 -0
- package/sails/gdpr/README.md +174 -0
- package/sails/gdpr/addon.json +27 -0
- package/sails/gdpr/files/backend/routes/gdpr.ts +140 -0
- package/sails/gdpr/files/backend/services/gdpr.ts +293 -0
- package/sails/gdpr/files/frontend/components/auth/ConsentCheckboxes.tsx +97 -0
- package/sails/gdpr/files/frontend/components/gdpr/AccountDeletionRequest.tsx +192 -0
- package/sails/gdpr/files/frontend/components/gdpr/DataExportButton.tsx +75 -0
- package/sails/gdpr/files/frontend/pages/PrivacyPolicy.tsx +186 -0
- package/sails/gdpr/install.ts +756 -0
- package/sails/google-oauth/README.md +121 -0
- package/sails/google-oauth/addon.json +22 -0
- package/sails/google-oauth/files/GoogleButton.tsx +50 -0
- package/sails/google-oauth/install.ts +252 -0
- package/sails/i18n/README.md +193 -0
- package/sails/i18n/addon.json +30 -0
- package/sails/i18n/files/frontend/components/LanguageSwitcher.tsx +108 -0
- package/sails/i18n/files/frontend/hooks/useLanguage.ts +31 -0
- package/sails/i18n/files/frontend/lib/i18n.ts +32 -0
- package/sails/i18n/files/frontend/locales/de/common.json +44 -0
- package/sails/i18n/files/frontend/locales/en/common.json +44 -0
- package/sails/i18n/install.ts +407 -0
- package/sails/push-notifications/README.md +163 -0
- package/sails/push-notifications/addon.json +31 -0
- package/sails/push-notifications/files/backend/routes/notifications.ts +153 -0
- package/sails/push-notifications/files/backend/schema/notifications.ts +31 -0
- package/sails/push-notifications/files/backend/services/notifications.ts +117 -0
- package/sails/push-notifications/files/frontend/components/PushNotificationInit.tsx +12 -0
- package/sails/push-notifications/files/frontend/hooks/usePushNotifications.ts +154 -0
- package/sails/push-notifications/install.ts +384 -0
- package/sails/r2-storage/README.md +101 -0
- package/sails/r2-storage/addon.json +29 -0
- package/sails/r2-storage/files/backend/services/storage.ts +71 -0
- package/sails/r2-storage/files/frontend/components/ProfilePictureUpload.tsx +167 -0
- package/sails/r2-storage/install.ts +412 -0
- package/sails/rate-limiting/README.md +145 -0
- package/sails/rate-limiting/addon.json +20 -0
- package/sails/rate-limiting/files/backend/middleware/rate-limit-store.ts +104 -0
- package/sails/rate-limiting/files/backend/middleware/rate-limit.ts +137 -0
- package/sails/rate-limiting/install.ts +300 -0
- package/sails/registry.json +107 -0
- package/sails/stripe/README.md +214 -0
- package/sails/stripe/addon.json +24 -0
- package/sails/stripe/files/backend/routes/stripe.ts +154 -0
- package/sails/stripe/files/backend/schema/stripe.ts +74 -0
- package/sails/stripe/files/backend/services/stripe.ts +224 -0
- package/sails/stripe/files/frontend/components/SubscriptionStatus.tsx +135 -0
- package/sails/stripe/files/frontend/hooks/useSubscription.ts +86 -0
- package/sails/stripe/files/frontend/pages/Checkout.tsx +116 -0
- package/sails/stripe/files/frontend/pages/Pricing.tsx +226 -0
- 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
|
+
}
|