Skip to content
Open
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
15 changes: 14 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,20 @@ DREAMSYNC_MAPPING_DB_PATH="/path/to/dreamsync/mapping/db"
GROUP_CHARTER_MAPPING_DB_PATH=/path/to/charter/mapping/db
CERBERUS_MAPPING_DB_PATH=/path/to/cerberus/mapping/db

GOOGLE_APPLICATION_CREDENTIALS="/path/to/firebase-secrets.json"
GOOGLE_APPLICATION_CREDENTIALS="/Users/sosweetham/projs/metastate/prototype/secrets/eid-w-firebase-adminsdk.json"

# Notification Trigger (APNS/FCM toy platform)
NOTIFICATION_TRIGGER_PORT=3998
# Full URL for control panel proxy (optional; defaults to http://localhost:NOTIFICATION_TRIGGER_PORT)
NOTIFICATION_TRIGGER_URL=http://localhost:3998
# APNS (iOS) - from Apple Developer
APNS_KEY_PATH="/Users/sosweetham/projs/metastate/prototype/secrets/AuthKey_A3BBXD9YR3.p8"
APNS_KEY_ID="A3BBXD9YR3"
APNS_TEAM_ID="M49C8XS835"
APNS_BUNDLE_ID="com.example.app"
APNS_PRODUCTION=false
# Broadcast push (Live Activities) - base64 channel ID
APNS_BROADCAST_CHANNEL_ID=znbhuBJCEfEAAMIJbS9xUw==

#PUBLIC_REGISTRY_URL="https://registry.w3ds.metastate.foundation"
#PUBLIC_PROVISIONER_URL="https://provisioner.w3ds.metastate.foundation"
Expand Down
3 changes: 3 additions & 0 deletions infrastructure/control-panel/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@ LOKI_PASSWORD=admin

# Registry Configuration
PUBLIC_REGISTRY_URL=https://registry.staging.metastate.foundation

# Notification Trigger (for Notifications tab proxy)
NOTIFICATION_TRIGGER_URL=http://localhost:3998
132 changes: 132 additions & 0 deletions infrastructure/control-panel/src/lib/services/notificationService.ts
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';
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' }))
};
}

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 };
}
}
3 changes: 2 additions & 1 deletion infrastructure/control-panel/src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
const navLinks = [
{ label: 'Dashboard', href: '/' },
{ label: 'Monitoring', href: '/monitoring' },
{ label: 'Actions', href: '/actions' }
{ label: 'Actions', href: '/actions' },
{ label: 'Notifications', href: '/notifications' }
];

const isActive = (href: string) => (href === '/' ? pageUrl === '/' : pageUrl.startsWith(href));
Expand Down
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
});
} 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 }
);
}
};
Loading
Loading