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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
70
apps/web/src/lib/stores/push.ts
Normal file
70
apps/web/src/lib/stores/push.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
35
bun.lock
35
bun.lock
@@ -23,6 +23,7 @@
|
||||
"fflate": "^0.8.2",
|
||||
"hono": "^4",
|
||||
"resend": "^6.10.0",
|
||||
"web-push": "^3.6.7",
|
||||
},
|
||||
},
|
||||
"apps/web": {
|
||||
@@ -434,12 +435,20 @@
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
|
||||
|
||||
"asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="],
|
||||
|
||||
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
||||
|
||||
"bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="],
|
||||
|
||||
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
|
||||
|
||||
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||
@@ -452,6 +461,8 @@
|
||||
|
||||
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
||||
|
||||
"devalue": ["devalue@5.6.4", "", {}, "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA=="],
|
||||
@@ -460,6 +471,8 @@
|
||||
|
||||
"drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="],
|
||||
|
||||
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||
|
||||
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
|
||||
@@ -488,24 +501,40 @@
|
||||
|
||||
"hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
|
||||
|
||||
"http_ece": ["http_ece@1.2.0", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="],
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"idb": ["idb@8.0.3", "", {}, "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
|
||||
|
||||
"is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="],
|
||||
|
||||
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
|
||||
|
||||
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
|
||||
|
||||
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||
|
||||
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||
@@ -536,6 +565,10 @@
|
||||
|
||||
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
|
||||
|
||||
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
|
||||
@@ -580,6 +613,8 @@
|
||||
|
||||
"wavesurfer.js": ["wavesurfer.js@7.12.5", "", {}, "sha512-MSZcA13R9ZlxgYpzfakaSYf8dz5tCdZKYbjtN1qnKbCi+UoyfaTuhvjlXHrITi/fgeO3qWfsH7U3BP1AKnwRNg=="],
|
||||
|
||||
"web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="],
|
||||
|
||||
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
|
||||
|
||||
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
12
packages/db/src/migrations/0007_push_subscriptions.sql
Normal file
12
packages/db/src/migrations/0007_push_subscriptions.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE "push_subscriptions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"endpoint" text NOT NULL,
|
||||
"p256dh" text NOT NULL,
|
||||
"auth" text NOT NULL,
|
||||
"user_agent" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "push_subscriptions_endpoint_unique" UNIQUE("endpoint")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "push_subscriptions" ADD CONSTRAINT "push_subscriptions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -50,6 +50,13 @@
|
||||
"when": 1776094119472,
|
||||
"tag": "0006_brown_lily_hollister",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1745395200000,
|
||||
"tag": "0007_push_subscriptions",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,3 +4,4 @@ export * from './projects.js';
|
||||
export * from './tracks.js';
|
||||
export * from './comments.js';
|
||||
export * from './shareLinks.js';
|
||||
export * from './pushSubscriptions.js';
|
||||
|
||||
14
packages/db/src/schema/pushSubscriptions.ts
Normal file
14
packages/db/src/schema/pushSubscriptions.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { pgTable, uuid, text, timestamp } from 'drizzle-orm/pg-core';
|
||||
import { users } from './users.js';
|
||||
|
||||
export const pushSubscriptions = pgTable('push_subscriptions', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
endpoint: text('endpoint').notNull().unique(),
|
||||
p256dh: text('p256dh').notNull(),
|
||||
auth: text('auth').notNull(),
|
||||
userAgent: text('user_agent'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
});
|
||||
@@ -2,3 +2,4 @@ export * from './auth.js';
|
||||
export * from './project.js';
|
||||
export * from './track.js';
|
||||
export * from './comment.js';
|
||||
export * from './push.js';
|
||||
|
||||
10
packages/shared/src/validation/push.ts
Normal file
10
packages/shared/src/validation/push.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const subscribePushSchema = z.object({
|
||||
endpoint: z.string().url(),
|
||||
keys: z.object({
|
||||
p256dh: z.string(),
|
||||
auth: z.string(),
|
||||
}),
|
||||
userAgent: z.string().optional(),
|
||||
});
|
||||
Reference in New Issue
Block a user