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:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
49
apps/api/src/routes/push.ts
Normal file
49
apps/api/src/routes/push.ts
Normal 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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
100
apps/api/src/services/push.ts
Normal file
100
apps/api/src/services/push.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user