-
Notifications
You must be signed in to change notification settings - Fork 5
feat: push notifs #820
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat: push notifs #820
Changes from all commits
2e04db4
3067091
59ab8d1
d6ca599
c6f5611
7bf40b1
2450232
fc4d3fe
305bdab
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,132 @@ | ||||||||||
| import { env } from '$env/dynamic/private'; | ||||||||||
|
|
||||||||||
| export interface NotificationPayload { | ||||||||||
| title: string; | ||||||||||
| body: string; | ||||||||||
| subtitle?: string; | ||||||||||
| data?: Record<string, string>; | ||||||||||
| sound?: string; | ||||||||||
| badge?: number; | ||||||||||
| clickAction?: string; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| export interface SendNotificationRequest { | ||||||||||
| token: string; | ||||||||||
| platform?: 'ios' | 'android'; | ||||||||||
| payload: NotificationPayload; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| export interface SendResult { | ||||||||||
| success: boolean; | ||||||||||
| error?: string; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| function getBaseUrl(): string { | ||||||||||
| const url = env.NOTIFICATION_TRIGGER_URL; | ||||||||||
| if (url) return url; | ||||||||||
| const port = env.NOTIFICATION_TRIGGER_PORT || '3998'; | ||||||||||
| return `http://localhost:${port}`; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| export async function sendNotification( | ||||||||||
| request: SendNotificationRequest | ||||||||||
| ): Promise<SendResult> { | ||||||||||
| const baseUrl = getBaseUrl(); | ||||||||||
| try { | ||||||||||
| const response = await fetch(`${baseUrl}/api/send`, { | ||||||||||
| method: 'POST', | ||||||||||
| headers: { 'Content-Type': 'application/json' }, | ||||||||||
| body: JSON.stringify(request), | ||||||||||
| signal: AbortSignal.timeout(15000) | ||||||||||
| }); | ||||||||||
| const data = await response.json(); | ||||||||||
| if (data.success) return { success: true }; | ||||||||||
| return { success: false, error: data.error ?? 'Unknown error' }; | ||||||||||
| } catch (err) { | ||||||||||
| return { | ||||||||||
| success: false, | ||||||||||
| error: err instanceof Error ? err.message : 'Request failed' | ||||||||||
| }; | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| export async function getDevicesWithTokens(): Promise< | ||||||||||
| { token: string; platform: string; eName: string }[] | ||||||||||
| > { | ||||||||||
| const { env } = await import('$env/dynamic/private'); | ||||||||||
| const provisionerUrl = | ||||||||||
| env.PUBLIC_PROVISIONER_URL || env.PROVISIONER_URL || 'http://localhost:3001'; | ||||||||||
|
Comment on lines
+56
to
+58
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Remove the dynamic re-import and read 🐛 Proposed fix export async function getDevicesWithTokens(): Promise<
{ token: string; platform: string; eName: string }[]
> {
- const { env } = await import('$env/dynamic/private');
- const provisionerUrl =
- env.PUBLIC_PROVISIONER_URL || env.PROVISIONER_URL || 'http://localhost:3001';
+ const provisionerUrl = env.PROVISIONER_URL || 'http://localhost:3001';
try {Apply the same fix to 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
| try { | ||||||||||
| const response = await fetch(`${provisionerUrl}/api/devices/list`, { | ||||||||||
| signal: AbortSignal.timeout(10000) | ||||||||||
| }); | ||||||||||
| if (!response.ok) throw new Error(`HTTP ${response.status}`); | ||||||||||
| const data = await response.json(); | ||||||||||
| return data.devices ?? []; | ||||||||||
| } catch (err) { | ||||||||||
| console.error('Failed to fetch devices:', err); | ||||||||||
| return []; | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| export async function getDevicesByEName(eName: string): Promise< | ||||||||||
| { token: string; platform: string; eName: string }[] | ||||||||||
| > { | ||||||||||
| const { env } = await import('$env/dynamic/private'); | ||||||||||
| const provisionerUrl = | ||||||||||
| env.PUBLIC_PROVISIONER_URL || env.PROVISIONER_URL || 'http://localhost:3001'; | ||||||||||
| try { | ||||||||||
| const response = await fetch( | ||||||||||
| `${provisionerUrl}/api/devices/by-ename/${encodeURIComponent(eName)}`, | ||||||||||
| { signal: AbortSignal.timeout(10000) } | ||||||||||
| ); | ||||||||||
| if (!response.ok) throw new Error(`HTTP ${response.status}`); | ||||||||||
| const data = await response.json(); | ||||||||||
| return data.devices ?? []; | ||||||||||
| } catch (err) { | ||||||||||
| console.error('Failed to fetch devices by eName:', err); | ||||||||||
| return []; | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| export async function sendBulkNotifications( | ||||||||||
| tokens: string[], | ||||||||||
| payload: NotificationPayload, | ||||||||||
| platform?: 'ios' | 'android' | ||||||||||
| ): Promise<{ sent: number; failed: number; errors: { token: string; error: string }[] }> { | ||||||||||
| const results = await Promise.all( | ||||||||||
| tokens.map(async (token) => { | ||||||||||
| const result = await sendNotification({ | ||||||||||
| token: token.trim(), | ||||||||||
| platform, | ||||||||||
| payload | ||||||||||
| }); | ||||||||||
| return { token: token.trim(), ...result }; | ||||||||||
| }) | ||||||||||
| ); | ||||||||||
|
|
||||||||||
| const sent = results.filter((r) => r.success).length; | ||||||||||
| const failed = results.filter((r) => !r.success); | ||||||||||
| return { | ||||||||||
| sent, | ||||||||||
| failed: failed.length, | ||||||||||
| errors: failed.map((r) => ({ token: r.token.slice(0, 20) + '...', error: r.error ?? 'Unknown' })) | ||||||||||
| }; | ||||||||||
| } | ||||||||||
|
Comment on lines
+92
to
+115
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
All ♻️ Proposed fix using a simple chunk helper+async function* chunks<T>(arr: T[], size: number) {
+ for (let i = 0; i < arr.length; i += size) yield arr.slice(i, i + size);
+}
export async function sendBulkNotifications(
tokens: string[],
payload: NotificationPayload,
platform?: 'ios' | 'android'
): Promise<{ sent: number; failed: number; errors: { token: string; error: string }[] }> {
- const results = await Promise.all(
- tokens.map(async (token) => {
- const result = await sendNotification({ token: token.trim(), platform, payload });
- return { token: token.trim(), ...result };
- })
- );
+ const results: ({ token: string } & SendResult)[] = [];
+ for await (const batch of chunks(tokens, 50)) {
+ const batchResults = await Promise.all(
+ batch.map(async (token) => {
+ const result = await sendNotification({ token: token.trim(), platform, payload });
+ return { token: token.trim(), ...result };
+ })
+ );
+ results.push(...batchResults);
+ }🤖 Prompt for AI Agents |
||||||||||
|
|
||||||||||
| export async function checkNotificationTriggerHealth(): Promise<{ | ||||||||||
| ok: boolean; | ||||||||||
| apns: boolean; | ||||||||||
| fcm: boolean; | ||||||||||
| }> { | ||||||||||
| const baseUrl = getBaseUrl(); | ||||||||||
| try { | ||||||||||
| const response = await fetch(`${baseUrl}/api/health`, { | ||||||||||
| signal: AbortSignal.timeout(5000) | ||||||||||
| }); | ||||||||||
| const data = await response.json(); | ||||||||||
| return { ok: data.ok ?? false, apns: data.apns ?? false, fcm: data.fcm ?? false }; | ||||||||||
| } catch { | ||||||||||
| return { ok: false, apns: false, fcm: false }; | ||||||||||
| } | ||||||||||
| } | ||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| import { json } from '@sveltejs/kit'; | ||
| import type { RequestHandler } from '@sveltejs/kit'; | ||
| import { getDevicesWithTokens } from '$lib/services/notificationService'; | ||
|
|
||
| export const GET: RequestHandler = async () => { | ||
| try { | ||
| const devices = await getDevicesWithTokens(); | ||
| return json({ count: devices.length }); | ||
| } catch { | ||
| return json({ count: 0 }); | ||
| } | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| import { json } from '@sveltejs/kit'; | ||
| import type { RequestHandler } from '@sveltejs/kit'; | ||
| import { checkNotificationTriggerHealth } from '$lib/services/notificationService'; | ||
|
|
||
| export const GET: RequestHandler = async () => { | ||
| try { | ||
| const health = await checkNotificationTriggerHealth(); | ||
| return json(health); | ||
| } catch { | ||
| return json({ ok: false, apns: false, fcm: false }); | ||
| } | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| import { json } from '@sveltejs/kit'; | ||
| import type { RequestHandler } from '@sveltejs/kit'; | ||
| import { | ||
| getDevicesWithTokens, | ||
| sendBulkNotifications | ||
| } from '$lib/services/notificationService'; | ||
|
|
||
| export const POST: RequestHandler = async ({ request }) => { | ||
| try { | ||
| const body = await request.json(); | ||
| const { payload } = body; | ||
|
|
||
| if (!payload?.title || !payload?.body) { | ||
| return json( | ||
| { success: false, error: 'Missing payload.title or payload.body' }, | ||
| { status: 400 } | ||
| ); | ||
| } | ||
|
|
||
| const devices = await getDevicesWithTokens(); | ||
| const tokens = devices.map((d) => d.token); | ||
|
|
||
| if (tokens.length === 0) { | ||
| return json( | ||
| { | ||
| success: false, | ||
| error: 'No registered devices with push tokens found' | ||
| }, | ||
| { status: 400 } | ||
| ); | ||
| } | ||
|
|
||
| const result = await sendBulkNotifications( | ||
| tokens, | ||
| { | ||
| title: String(payload.title), | ||
| body: String(payload.body), | ||
| subtitle: payload.subtitle ? String(payload.subtitle) : undefined, | ||
| data: payload.data, | ||
| sound: payload.sound ? String(payload.sound) : undefined, | ||
| badge: payload.badge !== undefined ? Number(payload.badge) : undefined, | ||
| clickAction: payload.clickAction ? String(payload.clickAction) : undefined | ||
| } | ||
| // platform auto-detected per token | ||
| ); | ||
|
|
||
| return json({ | ||
| success: true, | ||
| sent: result.sent, | ||
| failed: result.failed, | ||
| total: tokens.length, | ||
| errors: result.errors | ||
| }); | ||
| } catch (err) { | ||
| console.error('Bulk-all send error:', err); | ||
| return json( | ||
| { success: false, error: err instanceof Error ? err.message : 'Internal error' }, | ||
| { status: 500 } | ||
| ); | ||
| } | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| import { json } from '@sveltejs/kit'; | ||
| import type { RequestHandler } from '@sveltejs/kit'; | ||
| import { sendBulkNotifications } from '$lib/services/notificationService'; | ||
|
|
||
| export const POST: RequestHandler = async ({ request }) => { | ||
| try { | ||
| const body = await request.json(); | ||
| const { tokens, platform, payload } = body; | ||
|
|
||
| if (!Array.isArray(tokens) || tokens.length === 0) { | ||
| return json({ success: false, error: 'tokens must be a non-empty array' }, { status: 400 }); | ||
| } | ||
| if (!payload?.title || !payload?.body) { | ||
| return json( | ||
| { success: false, error: 'Missing payload.title or payload.body' }, | ||
| { status: 400 } | ||
| ); | ||
| } | ||
|
|
||
| const validTokens = tokens | ||
| .filter((t: unknown) => typeof t === 'string' && t.trim().length > 0) | ||
| .map((t: string) => t.trim()); | ||
|
|
||
| if (validTokens.length === 0) { | ||
| return json({ success: false, error: 'No valid tokens' }, { status: 400 }); | ||
| } | ||
|
|
||
| const result = await sendBulkNotifications( | ||
| validTokens, | ||
| { | ||
| title: String(payload.title), | ||
| body: String(payload.body), | ||
| subtitle: payload.subtitle ? String(payload.subtitle) : undefined, | ||
| data: payload.data, | ||
| sound: payload.sound ? String(payload.sound) : undefined, | ||
| badge: payload.badge !== undefined ? Number(payload.badge) : undefined, | ||
| clickAction: payload.clickAction ? String(payload.clickAction) : undefined | ||
| }, | ||
| platform && ['ios', 'android'].includes(platform) ? platform : undefined | ||
| ); | ||
|
|
||
| return json({ | ||
| success: true, | ||
| sent: result.sent, | ||
| failed: result.failed, | ||
| errors: result.errors | ||
| }); | ||
|
Comment on lines
+42
to
+47
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing
🔧 Proposed fix return json({
success: true,
+ total: validTokens.length,
sent: result.sent,
failed: result.failed,
errors: result.errors
});🤖 Prompt for AI Agents |
||
| } catch (err) { | ||
| console.error('Bulk notification send error:', err); | ||
| return json( | ||
| { success: false, error: err instanceof Error ? err.message : 'Internal error' }, | ||
| { status: 500 } | ||
| ); | ||
| } | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| import { json } from '@sveltejs/kit'; | ||
| import type { RequestHandler } from '@sveltejs/kit'; | ||
| import { | ||
| getDevicesByEName, | ||
| sendBulkNotifications | ||
| } from '$lib/services/notificationService'; | ||
|
|
||
| export const POST: RequestHandler = async ({ request }) => { | ||
| try { | ||
| const body = await request.json(); | ||
| const { eName, payload } = body; | ||
|
|
||
| if (!eName || typeof eName !== 'string' || !eName.trim()) { | ||
| return json( | ||
| { success: false, error: 'Missing or invalid eName' }, | ||
| { status: 400 } | ||
| ); | ||
| } | ||
| if (!payload?.title || !payload?.body) { | ||
| return json( | ||
| { success: false, error: 'Missing payload.title or payload.body' }, | ||
| { status: 400 } | ||
| ); | ||
| } | ||
|
|
||
| const devices = await getDevicesByEName(eName.trim()); | ||
| const tokens = devices.map((d) => d.token); | ||
|
|
||
| if (tokens.length === 0) { | ||
| return json( | ||
| { | ||
| success: false, | ||
| error: `No devices with push tokens found for eName: ${eName}` | ||
| }, | ||
| { status: 400 } | ||
| ); | ||
| } | ||
|
|
||
| const result = await sendBulkNotifications(tokens, { | ||
| title: String(payload.title), | ||
| body: String(payload.body), | ||
| subtitle: payload.subtitle ? String(payload.subtitle) : undefined, | ||
| data: payload.data, | ||
| sound: payload.sound ? String(payload.sound) : undefined, | ||
| badge: payload.badge !== undefined ? Number(payload.badge) : undefined, | ||
| clickAction: payload.clickAction ? String(payload.clickAction) : undefined | ||
| }); | ||
|
|
||
| return json({ | ||
| success: true, | ||
| sent: result.sent, | ||
| failed: result.failed, | ||
| total: tokens.length, | ||
| errors: result.errors | ||
| }); | ||
| } catch (err) { | ||
| console.error('Send by eName error:', err); | ||
| return json( | ||
| { success: false, error: err instanceof Error ? err.message : 'Internal error' }, | ||
| { status: 500 } | ||
| ); | ||
| } | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Replace machine-specific values in
.env.examplewith placeholders.Line 40 and Line 47 currently expose local absolute paths (including a username), and the APNS block uses concrete account identifiers. This is a portability and privacy/compliance risk for a template file.
🔧 Proposed fix
📝 Committable suggestion
🧰 Tools
🪛 dotenv-linter (4.0.0)
[warning] 40-40: [QuoteCharacter] The value has quote characters (', ")
(QuoteCharacter)
[warning] 47-47: [QuoteCharacter] The value has quote characters (', ")
(QuoteCharacter)
[warning] 47-47: [UnorderedKey] The APNS_KEY_PATH key should go before the NOTIFICATION_TRIGGER_PORT key
(UnorderedKey)
[warning] 48-48: [QuoteCharacter] The value has quote characters (', ")
(QuoteCharacter)
[warning] 48-48: [UnorderedKey] The APNS_KEY_ID key should go before the APNS_KEY_PATH key
(UnorderedKey)
[warning] 49-49: [QuoteCharacter] The value has quote characters (', ")
(QuoteCharacter)
[warning] 49-49: [UnorderedKey] The APNS_TEAM_ID key should go before the NOTIFICATION_TRIGGER_PORT key
(UnorderedKey)
[warning] 50-50: [QuoteCharacter] The value has quote characters (', ")
(QuoteCharacter)
[warning] 50-50: [UnorderedKey] The APNS_BUNDLE_ID key should go before the APNS_KEY_ID key
(UnorderedKey)
[warning] 51-51: [UnorderedKey] The APNS_PRODUCTION key should go before the APNS_TEAM_ID key
(UnorderedKey)
[warning] 53-53: [UnorderedKey] The APNS_BROADCAST_CHANNEL_ID key should go before the APNS_BUNDLE_ID key
(UnorderedKey)
🤖 Prompt for AI Agents