From e5d0b00761371b44b19c4d359cea8453c8e89996 Mon Sep 17 00:00:00 2001 From: Robin Choice Date: Thu, 23 Apr 2026 10:09:22 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20PWA=20Phase=202=20=E2=80=94=20push=20no?= =?UTF-8?q?tifications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 5 + .env.production.example | 5 + apps/api/package.json | 3 +- apps/api/src/index.ts | 4 +- apps/api/src/routes/push.ts | 49 +++++++++ apps/api/src/routes/versions.ts | 20 ++++ apps/api/src/services/push.ts | 100 ++++++++++++++++++ apps/web/src/lib/api/client.ts | 2 +- apps/web/src/lib/stores/push.ts | 70 ++++++++++++ .../web/src/routes/(app)/account/+page.svelte | 47 ++++++++ apps/web/src/service-worker.ts | 36 +++++++ bun.lock | 35 ++++++ .../migrations/0007_push_subscriptions.sql | 12 +++ packages/db/src/migrations/meta/_journal.json | 7 ++ packages/db/src/schema/index.ts | 1 + packages/db/src/schema/pushSubscriptions.ts | 14 +++ packages/shared/src/validation/index.ts | 1 + packages/shared/src/validation/push.ts | 10 ++ 18 files changed, 418 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/routes/push.ts create mode 100644 apps/api/src/services/push.ts create mode 100644 apps/web/src/lib/stores/push.ts create mode 100644 packages/db/src/migrations/0007_push_subscriptions.sql create mode 100644 packages/db/src/schema/pushSubscriptions.ts create mode 100644 packages/shared/src/validation/push.ts diff --git a/.env.example b/.env.example index 6786b2a..ca6ed2c 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,8 @@ S3_BUCKET=music-hub APP_URL=http://localhost:5173 API_URL=http://localhost:3000 MAGIC_LINK_SECRET=change-me-in-production + +# Push Notifications (VAPID) +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +VAPID_EMAIL=admin@musichub.app diff --git a/.env.production.example b/.env.production.example index 3fb8050..271a0d7 100644 --- a/.env.production.example +++ b/.env.production.example @@ -25,3 +25,8 @@ S3_BUCKET=music-hub # Externer Port (Coolify mappt das auf die Domain) PORT=3000 + +# Push Notifications (VAPID) +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +VAPID_EMAIL=admin@musichub.app diff --git a/apps/api/package.json b/apps/api/package.json index 95b218a..fdcbfb0 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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" } } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 8a2701a..cf01514 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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() .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}`); diff --git a/apps/api/src/routes/push.ts b/apps/api/src/routes/push.ts new file mode 100644 index 0000000..20aa594 --- /dev/null +++ b/apps/api/src/routes/push.ts @@ -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() + .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 }); + }); diff --git a/apps/api/src/routes/versions.ts b/apps/api/src/routes/versions.ts index f2a2dd6..045a4df 100644 --- a/apps/api/src/routes/versions.ts +++ b/apps/api/src/routes/versions.ts @@ -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() @@ -129,6 +130,13 @@ export const versionRoutes = new Hono() 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() .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() .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 }); }); diff --git a/apps/api/src/services/push.ts b/apps/api/src/services/push.ts new file mode 100644 index 0000000..1f3cf40 --- /dev/null +++ b/apps/api/src/services/push.ts @@ -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 { + 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 { + 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 { + 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)); + } +} diff --git a/apps/web/src/lib/api/client.ts b/apps/web/src/lib/api/client.ts index 08b3c75..310fb0a 100644 --- a/apps/web/src/lib/api/client.ts +++ b/apps/web/src/lib/api/client.ts @@ -34,5 +34,5 @@ export const api = { get: (path: string, silent = false) => request(path, { silent }), post: (path: string, body?: unknown) => request(path, { method: 'POST', body }), patch: (path: string, body?: unknown) => request(path, { method: 'PATCH', body }), - delete: (path: string) => request(path, { method: 'DELETE' }), + delete: (path: string, body?: unknown) => request(path, { method: 'DELETE', body }), }; diff --git a/apps/web/src/lib/stores/push.ts b/apps/web/src/lib/stores/push.ts new file mode 100644 index 0000000..832bcdf --- /dev/null +++ b/apps/web/src/lib/stores/push.ts @@ -0,0 +1,70 @@ +import { api } from '$lib/api/client.js'; + +export type PushState = 'unsupported' | 'denied' | 'subscribed' | 'unsubscribed'; + +let _state = $state('unsupported'); +let _loading = $state(false); + +export const pushStore = { + get state() { return _state; }, + get loading() { return _loading; }, +}; + +export async function initPush(): Promise { + 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 { + 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 { + 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 { + 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; +} diff --git a/apps/web/src/routes/(app)/account/+page.svelte b/apps/web/src/routes/(app)/account/+page.svelte index b1055a1..ba13f3e 100644 --- a/apps/web/src/routes/(app)/account/+page.svelte +++ b/apps/web/src/routes/(app)/account/+page.svelte @@ -1,4 +1,5 @@