feat: PWA Phase 2 — push notifications

Add web push notification support: push_subscriptions table (migration
0007), VAPID-based push service, subscribe/unsubscribe API routes, SW
push+notificationclick handlers, and subscribe UI on account page.
Triggers: new version uploaded (all project members) and version
approved/rejected (uploader).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Robin Choice
2026-04-23 10:09:22 +02:00
parent 9bad5c704a
commit e5d0b00761
18 changed files with 418 additions and 3 deletions

View File

@@ -16,6 +16,7 @@
"drizzle-orm": "^0.44",
"fflate": "^0.8.2",
"hono": "^4",
"resend": "^6.10.0"
"resend": "^6.10.0",
"web-push": "^3.6.7"
}
}

View File

@@ -12,6 +12,7 @@ import { uploadRoutes } from './routes/uploads.js';
import { activityRoutes } from './routes/activity.js';
import { onboardingRoutes } from './routes/onboarding.js';
import { stemRoutes } from './routes/stems.js';
import { pushRoutes } from './routes/push.js';
import type { AppEnv } from './types.js';
const db = createDb(process.env.DATABASE_URL!);
@@ -78,7 +79,8 @@ const app = new Hono<AppEnv>()
.route('/uploads', uploadRoutes)
.route('/activity', activityRoutes)
.route('/onboarding', onboardingRoutes)
.route('/stems', stemRoutes);
.route('/stems', stemRoutes)
.route('/push', pushRoutes);
const port = parseInt(process.env.PORT || '3000');
console.log(`Music Hub API running on port ${port}`);

View File

@@ -0,0 +1,49 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { eq, and } from 'drizzle-orm';
import { subscribePushSchema } from '@music-hub/shared';
import { pushSubscriptions } from '@music-hub/db';
import { requireAuth } from '../middleware/auth.js';
import type { AppEnv } from '../types.js';
export const pushRoutes = new Hono<AppEnv>()
.use('*', requireAuth)
.get('/vapid-public-key', (c) => {
const key = process.env.VAPID_PUBLIC_KEY;
if (!key) return c.json({ error: 'Push not configured' }, 503);
return c.json({ key });
})
.post('/subscribe', zValidator('json', subscribePushSchema), async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const { endpoint, keys, userAgent } = c.req.valid('json');
await db
.insert(pushSubscriptions)
.values({ userId, endpoint, p256dh: keys.p256dh, auth: keys.auth, userAgent })
.onConflictDoUpdate({
target: pushSubscriptions.endpoint,
set: { userId, p256dh: keys.p256dh, auth: keys.auth, userAgent },
});
return c.json({ ok: true }, 201);
})
.delete('/subscribe', async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const body = await c.req.json().catch(() => ({}));
const endpoint = body?.endpoint;
if (endpoint) {
await db
.delete(pushSubscriptions)
.where(and(eq(pushSubscriptions.userId, userId), eq(pushSubscriptions.endpoint, endpoint)));
} else {
await db.delete(pushSubscriptions).where(eq(pushSubscriptions.userId, userId));
}
return c.json({ ok: true });
});

View File

@@ -6,6 +6,7 @@ import { tracks, versions, projectMembers } from '@music-hub/db';
import { requireAuth } from '../middleware/auth.js';
import { createUploadUrl, createDownloadUrl, getObjectBuffer } from '../storage/s3.js';
import { processVersion } from '../services/audio-processor.js';
import { notifyProjectMembers, notifyUser } from '../services/push.js';
import type { AppEnv } from '../types.js';
export const versionRoutes = new Hono<AppEnv>()
@@ -129,6 +130,13 @@ export const versionRoutes = new Hono<AppEnv>()
console.error(`[Worker] Failed: ${err.message}`),
);
// Push: notify other project members
notifyProjectMembers(db, track.projectId, userId, {
title: 'Neue Version',
body: `${track.name} — V${versionNumber} hochgeladen`,
url: `/projects/${track.projectId}/tracks/${trackId}`,
}).catch(() => {});
return c.json({ version }, 201);
})
@@ -386,6 +394,12 @@ export const versionRoutes = new Hono<AppEnv>()
.where(eq(versions.id, versionId))
.returning();
notifyUser(db, version.createdById, {
title: 'Version freigegeben',
body: `${track!.name} V${version.versionNumber} wurde freigegeben`,
url: `/projects/${track!.projectId}/tracks/${version.trackId}`,
}).catch(() => {});
return c.json({ version: updated });
})
@@ -488,5 +502,11 @@ export const versionRoutes = new Hono<AppEnv>()
.where(eq(versions.id, versionId))
.returning();
notifyUser(db, version.createdById, {
title: 'Version abgelehnt',
body: `${track!.name} V${version.versionNumber} wurde abgelehnt`,
url: `/projects/${track!.projectId}/tracks/${version.trackId}`,
}).catch(() => {});
return c.json({ version: updated });
});

View File

@@ -0,0 +1,100 @@
import webpush from 'web-push';
import { eq, inArray } from 'drizzle-orm';
import { pushSubscriptions, projectMembers } from '@music-hub/db';
export type PushPayload = {
title: string;
body: string;
url?: string;
icon?: string;
};
function initVapid() {
const pub = process.env.VAPID_PUBLIC_KEY;
const priv = process.env.VAPID_PRIVATE_KEY;
const email = process.env.VAPID_EMAIL || 'admin@musichub.app';
if (pub && priv) {
webpush.setVapidDetails(`mailto:${email}`, pub, priv);
}
}
initVapid();
async function send(
sub: { endpoint: string; p256dh: string; auth: string },
payload: PushPayload,
): Promise<boolean> {
try {
await webpush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
JSON.stringify(payload),
);
return true;
} catch (err: any) {
if (err.statusCode === 410 || err.statusCode === 404) return false; // subscription gone
console.error('[Push] send error:', err.message);
return true;
}
}
export async function notifyProjectMembers(
db: any,
projectId: string,
excludeUserId: string,
payload: PushPayload,
): Promise<void> {
if (!process.env.VAPID_PUBLIC_KEY) return;
const members = await db
.select({ userId: projectMembers.userId })
.from(projectMembers)
.where(eq(projectMembers.projectId, projectId));
const userIds = members
.map((m: { userId: string }) => m.userId)
.filter((id: string) => id !== excludeUserId);
if (userIds.length === 0) return;
const subs = await db
.select()
.from(pushSubscriptions)
.where(inArray(pushSubscriptions.userId, userIds));
const stale: string[] = [];
await Promise.all(
subs.map(async (sub: typeof pushSubscriptions.$inferSelect) => {
const ok = await send(sub, payload);
if (!ok) stale.push(sub.id);
}),
);
if (stale.length > 0) {
await db.delete(pushSubscriptions).where(inArray(pushSubscriptions.id, stale));
}
}
export async function notifyUser(
db: any,
userId: string,
payload: PushPayload,
): Promise<void> {
if (!process.env.VAPID_PUBLIC_KEY) return;
const subs = await db
.select()
.from(pushSubscriptions)
.where(eq(pushSubscriptions.userId, userId));
const stale: string[] = [];
await Promise.all(
subs.map(async (sub: typeof pushSubscriptions.$inferSelect) => {
const ok = await send(sub, payload);
if (!ok) stale.push(sub.id);
}),
);
if (stale.length > 0) {
await db.delete(pushSubscriptions).where(inArray(pushSubscriptions.id, stale));
}
}

View File

@@ -34,5 +34,5 @@ export const api = {
get: <T>(path: string, silent = false) => request<T>(path, { silent }),
post: <T>(path: string, body?: unknown) => request<T>(path, { method: 'POST', body }),
patch: <T>(path: string, body?: unknown) => request<T>(path, { method: 'PATCH', body }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
delete: <T>(path: string, body?: unknown) => request<T>(path, { method: 'DELETE', body }),
};

View File

@@ -0,0 +1,70 @@
import { api } from '$lib/api/client.js';
export type PushState = 'unsupported' | 'denied' | 'subscribed' | 'unsubscribed';
let _state = $state<PushState>('unsupported');
let _loading = $state(false);
export const pushStore = {
get state() { return _state; },
get loading() { return _loading; },
};
export async function initPush(): Promise<void> {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
if (Notification.permission === 'denied') { _state = 'denied'; return; }
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
_state = sub ? 'subscribed' : 'unsubscribed';
}
export async function subscribePush(): Promise<void> {
if (_loading) return;
_loading = true;
try {
const { key } = await api.get<{ key: string }>('/push/vapid-public-key');
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(key),
});
const json = sub.toJSON();
await api.post('/push/subscribe', {
endpoint: sub.endpoint,
keys: { p256dh: json.keys!.p256dh, auth: json.keys!.auth },
userAgent: navigator.userAgent.slice(0, 200),
});
_state = 'subscribed';
} catch (err: any) {
if (Notification.permission === 'denied') _state = 'denied';
console.error('[Push] subscribe error:', err.message);
} finally {
_loading = false;
}
}
export async function unsubscribePush(): Promise<void> {
if (_loading) return;
_loading = true;
try {
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
if (sub) {
await api.delete('/push/subscribe', { endpoint: sub.endpoint });
await sub.unsubscribe();
}
_state = 'unsubscribed';
} finally {
_loading = false;
}
}
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(base64);
const arr = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
return arr;
}

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { user } from '$lib/stores/auth.js';
import { api } from '$lib/api/client.js';
import { toastSuccess } from '$lib/stores/toast.js';
@@ -6,10 +7,13 @@
import Input from '$lib/components/ui/Input.svelte';
import Avatar from '$lib/components/ui/Avatar.svelte';
import TopBar from '$lib/components/workspace/TopBar.svelte';
import { pushStore, initPush, subscribePush, unsubscribePush } from '$lib/stores/push.js';
let name = $state('');
let saving = $state(false);
onMount(() => initPush());
$effect(() => {
if ($user && !name) name = $user.name;
});
@@ -35,6 +39,25 @@
<p class="sub">Dein Profil — sichtbar für andere im Projekt.</p>
</header>
{#if pushStore.state !== 'unsupported'}
<section class="card">
<h2>Benachrichtigungen</h2>
{#if pushStore.state === 'denied'}
<p class="push-hint">Push-Benachrichtigungen wurden im Browser blockiert. Bitte in den Browser-Einstellungen erlauben.</p>
{:else if pushStore.state === 'subscribed'}
<div class="push-row">
<span class="push-active">Push-Benachrichtigungen aktiv</span>
<Button size="sm" variant="ghost" onclick={unsubscribePush} loading={pushStore.loading}>Deaktivieren</Button>
</div>
{:else}
<div class="push-row">
<span class="push-desc">Werde benachrichtigt wenn neue Versionen hochgeladen oder freigegeben werden.</span>
<Button size="sm" onclick={subscribePush} loading={pushStore.loading}>Aktivieren</Button>
</div>
{/if}
</section>
{/if}
{#if $user}
<section class="card">
<h2>Profil</h2>
@@ -112,4 +135,28 @@
color: var(--color-text-primary);
font-family: var(--font-mono);
}
.push-row {
display: flex;
align-items: center;
gap: var(--space-4);
flex-wrap: wrap;
}
.push-active {
color: var(--color-text-secondary);
font-size: var(--text-sm);
flex: 1;
}
.push-desc {
color: var(--color-text-tertiary);
font-size: var(--text-sm);
flex: 1;
}
.push-hint {
color: var(--color-text-tertiary);
font-size: var(--text-sm);
margin: 0;
}
.card + .card {
margin-top: var(--space-5);
}
</style>

View File

@@ -34,6 +34,42 @@ sw.addEventListener('activate', (event) => {
);
});
sw.addEventListener('push', (event) => {
if (!event.data) return;
let payload: { title?: string; body?: string; url?: string } = {};
try {
payload = event.data.json();
} catch {
payload = { title: 'Music Hub', body: event.data.text() };
}
event.waitUntil(
sw.registration.showNotification(payload.title ?? 'Music Hub', {
body: payload.body,
icon: '/icons/icon-192.png',
badge: '/icons/badge-72.png',
data: { url: payload.url ?? '/' },
}),
);
});
sw.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = event.notification.data?.url ?? '/';
event.waitUntil(
sw.clients
.matchAll({ type: 'window', includeUncontrolled: true })
.then((clientList) => {
for (const client of clientList) {
if (client.url.includes(sw.location.origin) && 'focus' in client) {
(client as WindowClient).navigate(url);
return client.focus();
}
}
return sw.clients.openWindow(url);
}),
);
});
sw.addEventListener('fetch', (event) => {
const req = event.request;
if (req.method !== 'GET') return;