Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# OneSignal configuration (SvelteKit style)
# Public values (exposed to client build):
PUBLIC_ONESIGNAL_APP_ID=00000000-0000-0000-0000-000000000000
PUBLIC_ONESIGNAL_SAFARI_WEB_ID=web.onesignal.auto.xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
# Private (server only):
PRIVATE_ONESIGNAL_REST_API_KEY=OS_REST_API_KEY_HERE

# Existing variables (append your current ones below if you have a separate example file)
PUBLIC_SITE_URL=https://cropwatch.io
7 changes: 7 additions & 0 deletions src/lib/onesignalPublic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { env as publicEnv } from '$env/dynamic/public';

// Public-only config for client web push setup.
export const ONE_SIGNAL_PUBLIC_CONFIG = {
appId: publicEnv.PUBLIC_ONESIGNAL_APP_ID,
safari_web_id: publicEnv.PUBLIC_ONESIGNAL_SAFARI_WEB_ID
};
84 changes: 84 additions & 0 deletions src/lib/server/OneSignalService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService';
import { env as publicEnv } from '$env/dynamic/public';
import { env as privateEnv } from '$env/dynamic/private';

/**
* Server-side OneSignal push helper (2025 docs: Create message API)
* Uses SvelteKit dynamic env so build won't fail if vars absent at build-time.
* Required at runtime:
* PUBLIC_ONESIGNAL_APP_ID
* PRIVATE_ONESIGNAL_REST_API_KEY
*/
export class OneSignalService {
constructor(private errorHandler: ErrorHandlingService) {}

private assertEnv() {
if (!publicEnv.PUBLIC_ONESIGNAL_APP_ID)
throw new Error('PUBLIC_ONESIGNAL_APP_ID not configured');
if (!privateEnv.PRIVATE_ONESIGNAL_REST_API_KEY)
throw new Error('PRIVATE_ONESIGNAL_REST_API_KEY not configured');
}

private baseHeaders() {
this.assertEnv();
return {
'content-type': 'application/json; charset=utf-8',
authorization: `Key ${privateEnv.PRIVATE_ONESIGNAL_REST_API_KEY}`
};
}

async sendPush(options: {
headings?: Record<string, string>;
contents: Record<string, string>;
externalUserIds?: string[];
includedSegments?: string[];
data?: Record<string, unknown>;
iosAttachments?: Record<string, string>;
bigPicture?: string;
name?: string;
}) {
try {
this.assertEnv();
const {
headings,
contents,
externalUserIds,
includedSegments,
data,
iosAttachments,
bigPicture,
name
} = options;
if (!contents || Object.keys(contents).length === 0) throw new Error('contents is required');
if (!externalUserIds?.length && !includedSegments?.length)
throw new Error('Must supply externalUserIds or includedSegments');

const body: any = {
app_id: publicEnv.PUBLIC_ONESIGNAL_APP_ID,
target_channel: 'push',
contents,
headings,
data,
name
};
if (externalUserIds?.length) body.include_external_user_ids = externalUserIds;
if (includedSegments?.length) body.included_segments = includedSegments;
if (iosAttachments) body.ios_attachments = iosAttachments;
if (bigPicture) body.big_picture = bigPicture;

const res = await fetch('https://api.onesignal.com/notifications', {
method: 'POST',
headers: this.baseHeaders(),
body: JSON.stringify(body)
});
if (!res.ok) {
const text = await res.text();
throw new Error(`OneSignal error ${res.status}: ${text}`);
}
return await res.json();
} catch (err) {
this.errorHandler.logError(err as Error);
throw err;
}
}
}
98 changes: 98 additions & 0 deletions src/lib/services/OneSignalService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { ErrorHandlingService } from '../errors/ErrorHandlingService';
// SvelteKit env imports. Public App ID may be used on client; REST key must stay private.
import { PUBLIC_ONESIGNAL_APP_ID, PUBLIC_ONESIGNAL_SAFARI_WEB_ID } from '$env/static/public';
import { PRIVATE_ONESIGNAL_REST_API_KEY } from '$env/static/private';

/**
* OneSignal push helper (2025 docs: Create message API)
* Env vars (SvelteKit style):
* PUBLIC_ONESIGNAL_APP_ID (public)
* PUBLIC_ONESIGNAL_SAFARI_WEB_ID (public, optional for Safari web push)
* PRIVATE_ONESIGNAL_REST_API_KEY (server only)
*/
export class OneSignalService {
constructor(private errorHandler: ErrorHandlingService) {}

private assertEnv() {
if (!PUBLIC_ONESIGNAL_APP_ID) throw new Error('PUBLIC_ONESIGNAL_APP_ID not configured');
if (!PRIVATE_ONESIGNAL_REST_API_KEY)
throw new Error('PRIVATE_ONESIGNAL_REST_API_KEY not configured');
}

private baseHeaders() {
this.assertEnv();
return {
'content-type': 'application/json; charset=utf-8',
authorization: `Key ${PRIVATE_ONESIGNAL_REST_API_KEY}`
};
}

/**
* Send a push notification.
* Provide either externalUserIds (preferred) or includedSegments.
*/
async sendPush(options: {
headings?: Record<string, string>;
contents: Record<string, string>;
externalUserIds?: string[]; // OneSignal External IDs
includedSegments?: string[]; // e.g. ['Test Users']
data?: Record<string, unknown>;
iosAttachments?: Record<string, string>;
bigPicture?: string; // Android image
name?: string; // internal name in dashboard
}): Promise<any> {
try {
this.assertEnv();
const {
headings,
contents,
externalUserIds,
includedSegments,
data,
iosAttachments,
bigPicture,
name
} = options;

if (!contents || Object.keys(contents).length === 0) {
throw new Error('contents is required');
}
if (!externalUserIds?.length && !includedSegments?.length) {
throw new Error('Must supply externalUserIds or includedSegments');
}

const body: any = {
app_id: PUBLIC_ONESIGNAL_APP_ID,
target_channel: 'push',
contents,
headings,
data,
name
};
if (externalUserIds?.length) body.include_external_user_ids = externalUserIds;
if (includedSegments?.length) body.included_segments = includedSegments;
if (iosAttachments) body.ios_attachments = iosAttachments;
if (bigPicture) body.big_picture = bigPicture;

const res = await fetch('https://api.onesignal.com/notifications', {
method: 'POST',
headers: this.baseHeaders(),
body: JSON.stringify(body)
});
if (!res.ok) {
const text = await res.text();
throw new Error(`OneSignal error ${res.status}: ${text}`);
}
return await res.json();
} catch (err) {
this.errorHandler.logError(err as Error);
throw err;
}
}
}

// Optional helper for web push initialization (Safari ID is exposed for completeness)
export const ONE_SIGNAL_PUBLIC_CONFIG = {
appId: PUBLIC_ONESIGNAL_APP_ID,
safari_web_id: PUBLIC_ONESIGNAL_SAFARI_WEB_ID
};
24 changes: 24 additions & 0 deletions src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import { onMount } from 'svelte';
import '../app.css';
import { info, warning } from '$lib/stores/toast.svelte';
import { ONE_SIGNAL_PUBLIC_CONFIG } from '$lib/onesignalPublic';

// No preloading needed - dashboard will load its data when navigated to

Expand Down Expand Up @@ -75,6 +76,29 @@
i18n.initialize();
});

// OneSignal Web Push (2025 docs style) - only loads if appId present
onMount(() => {
if (typeof window === 'undefined') return;
if (!ONE_SIGNAL_PUBLIC_CONFIG.appId) return;
// Inject script once
if (!document.querySelector('script[data-onesignal-sdk]')) {
const s = document.createElement('script');
s.src = 'https://cdn.onesignal.com/sdks/web/v16/OneSignalSDK.page.js';
s.defer = true;
s.setAttribute('data-onesignal-sdk', 'true');
document.head.appendChild(s);
}
// Queue init
(window as any).OneSignalDeferred = (window as any).OneSignalDeferred || [];
(window as any).OneSignalDeferred.push(async function (OneSignal: any) {
await OneSignal.init({
appId: ONE_SIGNAL_PUBLIC_CONFIG.appId,
safari_web_id: ONE_SIGNAL_PUBLIC_CONFIG.safari_web_id,
notifyButton: { enable: true }
});
});
});

// Handle navigation loading states with a small delay to avoid flash on fast transitions
let navTimer: ReturnType<typeof setTimeout> | null = null;
beforeNavigate(() => {
Expand Down
36 changes: 36 additions & 0 deletions src/routes/api/notifications/test/+server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { RequestHandler } from '@sveltejs/kit';
import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService';
import { OneSignalService } from '$lib/server/OneSignalService';

/**
* Test endpoint to send a push to either provided externalUserIds or the Test Users segment.
* Secure this route (add auth / role checks) before using outside local dev.
* Uses env vars: PUBLIC_ONESIGNAL_APP_ID / PRIVATE_ONESIGNAL_REST_API_KEY
* POST body JSON: { contents: { en: "Hello" }, externalUserIds?: ["user123"], includedSegments?: ["Test Users"] }
*/
export const POST: RequestHandler = async ({ request }) => {
const errorHandler = new ErrorHandlingService();
const oneSignal = new OneSignalService(errorHandler);
try {
const body = await request.json();
// Basic validation
if (!body.contents) {
return new Response(JSON.stringify({ error: 'contents required' }), { status: 400 });
}

const result = await oneSignal.sendPush({
contents: body.contents,
headings: body.headings,
externalUserIds: body.externalUserIds,
includedSegments: body.includedSegments || (!body.externalUserIds && ['Test Users']),
data: body.data,
iosAttachments: body.iosAttachments,
bigPicture: body.bigPicture,
name: body.name || 'Test API Push'
});

return new Response(JSON.stringify({ success: true, result }), { status: 200 });
} catch (err) {
return new Response(JSON.stringify({ error: (err as Error).message }), { status: 500 });
}
};