From 06f0a43532d06c19816419da6b10235eb19fb51c Mon Sep 17 00:00:00 2001 From: Robin Choice Date: Thu, 23 Apr 2026 10:28:58 +0200 Subject: [PATCH] feat: reject with feedback, email alerts, SSE real-time updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reject modal now requires a reason — stored as an auto-comment on the version so context stays in the thread. Email alert fires on first play of a shared link (fire-and-forget, no-op without RESEND_API_KEY). SSE endpoint per track broadcasts version:new, version:status and comment:new events; track page subscribes and reloads data live. Co-Authored-By: Claude Sonnet 4.6 --- apps/api/src/index.ts | 4 +- apps/api/src/routes/comments.ts | 3 + apps/api/src/routes/share.ts | 35 ++++++++++- apps/api/src/routes/sse.ts | 49 +++++++++++++++ apps/api/src/routes/versions.ts | 27 +++++++-- apps/api/src/services/email.ts | 40 +++++++++++++ apps/api/src/services/sse.ts | 21 +++++++ apps/web/src/lib/stores/sse.ts | 40 +++++++++++++ .../[projectId]/tracks/[trackId]/+page.svelte | 59 +++++++++++++++++-- packages/shared/src/validation/track.ts | 4 ++ 10 files changed, 269 insertions(+), 13 deletions(-) create mode 100644 apps/api/src/routes/sse.ts create mode 100644 apps/api/src/services/sse.ts create mode 100644 apps/web/src/lib/stores/sse.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index cf01514..2aaaa76 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -13,6 +13,7 @@ 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 { sseRoutes } from './routes/sse.js'; import type { AppEnv } from './types.js'; const db = createDb(process.env.DATABASE_URL!); @@ -80,7 +81,8 @@ const app = new Hono() .route('/activity', activityRoutes) .route('/onboarding', onboardingRoutes) .route('/stems', stemRoutes) - .route('/push', pushRoutes); + .route('/push', pushRoutes) + .route('/sse', sseRoutes); const port = parseInt(process.env.PORT || '3000'); console.log(`Music Hub API running on port ${port}`); diff --git a/apps/api/src/routes/comments.ts b/apps/api/src/routes/comments.ts index 25f521a..e729b26 100644 --- a/apps/api/src/routes/comments.ts +++ b/apps/api/src/routes/comments.ts @@ -4,6 +4,7 @@ import { eq, and, asc } from 'drizzle-orm'; import { createCommentSchema, updateCommentSchema } from '@music-hub/shared'; import { comments, versions, tracks, projectMembers, users } from '@music-hub/db'; import { requireAuth } from '../middleware/auth.js'; +import { publish } from '../services/sse.js'; import type { AppEnv } from '../types.js'; export const commentRoutes = new Hono() @@ -109,6 +110,8 @@ export const commentRoutes = new Hono() }) .returning(); + publish(track!.id, { type: 'comment:new', data: { versionId, commentId: comment.id } }); + return c.json({ comment }, 201); }, ) diff --git a/apps/api/src/routes/share.ts b/apps/api/src/routes/share.ts index 2b6cfa7..41536cf 100644 --- a/apps/api/src/routes/share.ts +++ b/apps/api/src/routes/share.ts @@ -14,6 +14,7 @@ import { } from '@music-hub/db'; import { requireAuth } from '../middleware/auth.js'; import { createDownloadUrl } from '../storage/s3.js'; +import { sendListenAlertEmail } from '../services/email.js'; async function hashIp(ip: string): Promise { const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(ip + 'musichub-salt')); @@ -315,16 +316,48 @@ export const shareRoutes = new Hono() .limit(1); if (!event) return c.json({ error: 'Not found' }, 404); + const isFirstPlay = input.firstPlay && !event.firstPlayAt; + await db .update(listenEvents) .set({ ...(input.listenerName !== undefined ? { listenerName: input.listenerName } : {}), - ...(input.firstPlay && !event.firstPlayAt ? { firstPlayAt: new Date() } : {}), + ...(isFirstPlay ? { firstPlayAt: new Date() } : {}), ...(input.listenSeconds !== undefined ? { listenSeconds: input.listenSeconds } : {}), ...(input.completed !== undefined ? { completed: input.completed } : {}), }) .where(eq(listenEvents.id, eventId)); + if (isFirstPlay) { + // Fire-and-forget: alert link creator by email + Promise.resolve().then(async () => { + try { + const [fullLink] = await db + .select({ createdById: shareLinks.createdById, versionId: shareLinks.versionId }) + .from(shareLinks) + .where(eq(shareLinks.id, link.id)) + .limit(1); + if (!fullLink) return; + + const [creator] = await db + .select({ email: users.email, name: users.name }) + .from(users) + .where(eq(users.id, fullLink.createdById)) + .limit(1); + if (!creator) return; + + const [version] = await db.select().from(versions).where(eq(versions.id, fullLink.versionId)).limit(1); + const [track] = version ? await db.select().from(tracks).where(eq(tracks.id, version.trackId)).limit(1) : [null]; + const [project] = track ? await db.select().from(projects).where(eq(projects.id, track.projectId)).limit(1) : [null]; + if (!track || !project) return; + + const listenerName = input.listenerName ?? event.listenerName; + const trackUrl = `${process.env.APP_URL}/projects/${track.projectId}/tracks/${track.id}`; + await sendListenAlertEmail(creator.email, listenerName ?? null, track.name, project.name, trackUrl); + } catch { /* non-critical */ } + }); + } + return c.json({ ok: true }); }) diff --git a/apps/api/src/routes/sse.ts b/apps/api/src/routes/sse.ts new file mode 100644 index 0000000..6fa3a1e --- /dev/null +++ b/apps/api/src/routes/sse.ts @@ -0,0 +1,49 @@ +import { Hono } from 'hono'; +import { eq, and } from 'drizzle-orm'; +import { tracks, projectMembers } from '@music-hub/db'; +import { requireAuth } from '../middleware/auth.js'; +import { subscribe } from '../services/sse.js'; +import type { AppEnv } from '../types.js'; + +export const sseRoutes = new Hono() + .use('*', requireAuth) + + .get('/track/:trackId', async (c) => { + const db = c.get('db'); + const userId = c.get('userId'); + const trackId = c.req.param('trackId'); + + const [track] = await db.select().from(tracks).where(eq(tracks.id, trackId)).limit(1); + if (!track) return c.json({ error: 'Not found' }, 404); + + const [membership] = await db + .select() + .from(projectMembers) + .where(and(eq(projectMembers.projectId, track.projectId), eq(projectMembers.userId, userId))) + .limit(1); + if (!membership) return c.json({ error: 'Forbidden' }, 403); + + const { readable, writable } = new TransformStream(); + const writer = writable.getWriter(); + const encoder = new TextEncoder(); + + const send = (data: string) => writer.write(encoder.encode(data)).catch(() => {}); + const unsubscribe = subscribe(trackId, send); + + // Initial ping + send(': connected\n\n'); + + c.req.raw.signal.addEventListener('abort', () => { + unsubscribe(); + writer.close().catch(() => {}); + }); + + return new Response(readable, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }); + }); diff --git a/apps/api/src/routes/versions.ts b/apps/api/src/routes/versions.ts index 045a4df..11d0a4d 100644 --- a/apps/api/src/routes/versions.ts +++ b/apps/api/src/routes/versions.ts @@ -1,12 +1,13 @@ import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { eq, and, desc, asc, sql } from 'drizzle-orm'; -import { requestUploadUrlSchema, createVersionSchema, updateVersionSchema } from '@music-hub/shared'; -import { tracks, versions, projectMembers } from '@music-hub/db'; +import { requestUploadUrlSchema, createVersionSchema, updateVersionSchema, rejectVersionSchema } from '@music-hub/shared'; +import { tracks, versions, projectMembers, comments } 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 { publish } from '../services/sse.js'; import type { AppEnv } from '../types.js'; export const versionRoutes = new Hono() @@ -130,13 +131,14 @@ 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(() => {}); + publish(trackId, { type: 'version:new', data: { versionId: version.id, versionNumber, trackId } }); + return c.json({ version }, 201); }) @@ -400,6 +402,8 @@ export const versionRoutes = new Hono() url: `/projects/${track!.projectId}/tracks/${version.trackId}`, }).catch(() => {}); + publish(version.trackId, { type: 'version:status', data: { versionId, status: 'approved' } }); + return c.json({ version: updated }); }) @@ -464,11 +468,12 @@ export const versionRoutes = new Hono() }); }) - // Reject version - .post('/:id/reject', async (c) => { + // Reject version (requires reason — posted as a comment) + .post('/:id/reject', zValidator('json', rejectVersionSchema), async (c) => { const db = c.get('db'); const userId = c.get('userId'); const versionId = c.req.param('id'); + const { reason } = c.req.valid('json'); const [version] = await db .select() @@ -502,11 +507,21 @@ export const versionRoutes = new Hono() .where(eq(versions.id, versionId)) .returning(); + await db.insert(comments).values({ + versionId, + userId, + body: `❌ Abgelehnt: ${reason}`, + timestampSeconds: null, + parentId: null, + }); + notifyUser(db, version.createdById, { title: 'Version abgelehnt', - body: `${track!.name} V${version.versionNumber} wurde abgelehnt`, + body: `${track!.name} V${version.versionNumber}: ${reason.slice(0, 80)}`, url: `/projects/${track!.projectId}/tracks/${version.trackId}`, }).catch(() => {}); + publish(version.trackId, { type: 'version:status', data: { versionId, status: 'rejected' } }); + return c.json({ version: updated }); }); diff --git a/apps/api/src/services/email.ts b/apps/api/src/services/email.ts index e22dc81..667b380 100644 --- a/apps/api/src/services/email.ts +++ b/apps/api/src/services/email.ts @@ -39,6 +39,46 @@ export async function sendMagicLinkEmail(email: string, token: string) { }); } +export async function sendListenAlertEmail( + to: string, + listenerName: string | null, + trackName: string, + projectName: string, + trackUrl: string, +) { + const who = listenerName ?? 'Jemand'; + + if (!resend) { + console.log(`[DEV] Listen alert: ${who} hat "${trackName}" gehört — ${to}`); + return; + } + + await resend.emails.send({ + from: fromEmail, + to, + subject: `${who} hat "${trackName}" gehört`, + html: ` +
+

Music Hub

+

+ ${who} hat deinen Track gehört: +

+

${trackName}

+

${projectName}

+ Analytics ansehen +
+ `, + }); +} + export async function sendInviteEmail(email: string, projectName: string, inviterName: string) { const url = `${process.env.APP_URL}`; diff --git a/apps/api/src/services/sse.ts b/apps/api/src/services/sse.ts new file mode 100644 index 0000000..72589d5 --- /dev/null +++ b/apps/api/src/services/sse.ts @@ -0,0 +1,21 @@ +type SseClient = (data: string) => void; + +const channels = new Map>(); + +export function subscribe(trackId: string, send: SseClient): () => void { + if (!channels.has(trackId)) channels.set(trackId, new Set()); + channels.get(trackId)!.add(send); + return () => { + channels.get(trackId)?.delete(send); + if (channels.get(trackId)?.size === 0) channels.delete(trackId); + }; +} + +export function publish(trackId: string, event: { type: string; data: unknown }) { + const clients = channels.get(trackId); + if (!clients || clients.size === 0) return; + const msg = `event: ${event.type}\ndata: ${JSON.stringify(event.data)}\n\n`; + for (const send of clients) { + try { send(msg); } catch { /* client gone */ } + } +} diff --git a/apps/web/src/lib/stores/sse.ts b/apps/web/src/lib/stores/sse.ts new file mode 100644 index 0000000..f684ddf --- /dev/null +++ b/apps/web/src/lib/stores/sse.ts @@ -0,0 +1,40 @@ +type SseHandler = (event: { type: string; data: unknown }) => void; + +let es: EventSource | null = null; +let currentTrackId: string | null = null; +let handler: SseHandler | null = null; + +export function connectTrackSse(trackId: string, onEvent: SseHandler): () => void { + if (currentTrackId === trackId && es?.readyState === EventSource.OPEN) { + handler = onEvent; + return () => disconnect(); + } + + disconnect(); + currentTrackId = trackId; + handler = onEvent; + + es = new EventSource(`/api/v1/sse/track/${trackId}`, { withCredentials: true }); + + const types = ['version:new', 'version:status', 'comment:new']; + for (const type of types) { + es.addEventListener(type, (e: MessageEvent) => { + try { + handler?.({ type, data: JSON.parse(e.data) }); + } catch { /* ignore malformed */ } + }); + } + + es.onerror = () => { + // Browser auto-reconnects EventSource — nothing to do + }; + + return () => disconnect(); +} + +function disconnect() { + es?.close(); + es = null; + currentTrackId = null; + handler = null; +} diff --git a/apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/+page.svelte b/apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/+page.svelte index 4fd7862..1d56ae9 100644 --- a/apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/+page.svelte +++ b/apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/+page.svelte @@ -16,8 +16,10 @@ import CoverImage from '$lib/components/ui/CoverImage.svelte'; import CoverUpload from '$lib/components/ui/CoverUpload.svelte'; import TrackStatusPill from '$lib/components/ui/TrackStatusPill.svelte'; + import { onDestroy } from 'svelte'; import { onKey } from '$lib/utils/shortcuts.js'; import { snapshotForTrack, continuationFor } from '$lib/stores/player.js'; + import { connectTrackSse } from '$lib/stores/sse.js'; import { offlineVersions, downloadForOffline, @@ -106,6 +108,9 @@ let offlineDropdownOpen = $state(false); let offlineDownloading = $state(false); let offlineProgress = $state(0); + let rejectOpen = $state(false); + let rejectReason = $state(''); + let rejecting = $state(false); const canUpload = $derived(role === 'owner' || role.includes('engineer')); const canApprove = $derived(['owner', 'artist', 'label', 'management'].includes(role)); @@ -137,6 +142,19 @@ } finally { loading = false; } + + const disconnectSse = connectTrackSse(trackId, async ({ type, data }: { type: string; data: any }) => { + if (type === 'version:new') { + await loadVersions(); + } else if (type === 'version:status') { + const v = versions.find((v) => v.id === data.versionId); + if (v) { v.status = data.status; versions = [...versions]; } + } else if (type === 'comment:new' && selectedVersion?.id === data.versionId) { + const res = await api.get<{ comments: Comment[] }>(`/comments/version/${data.versionId}`); + comments = res.comments; + } + }); + onDestroy(disconnectSse); }); async function selectVersion(version: Version) { @@ -205,11 +223,22 @@ await loadVersions(); } - async function handleReject() { - if (!selectedVersion) return; - await api.post(`/versions/${selectedVersion.id}/reject`); - toastSuccess('Version abgelehnt'); - await loadVersions(); + function handleReject() { + rejectReason = ''; + rejectOpen = true; + } + + async function submitReject() { + if (!selectedVersion || !rejectReason.trim()) return; + rejecting = true; + try { + await api.post(`/versions/${selectedVersion.id}/reject`, { reason: rejectReason.trim() }); + rejectOpen = false; + toastSuccess('Version abgelehnt'); + await loadVersions(); + } finally { + rejecting = false; + } } async function handleComment(body: string, timestamp: number | null, parentId?: string) { @@ -638,6 +667,26 @@ {/if} + +
+ +
+ {#snippet actions()} + + + {/snippet} +
+
diff --git a/packages/shared/src/validation/track.ts b/packages/shared/src/validation/track.ts index 6765582..a555677 100644 --- a/packages/shared/src/validation/track.ts +++ b/packages/shared/src/validation/track.ts @@ -53,6 +53,10 @@ export const updateVersionSchema = z.object({ branchLabel: z.string().max(100).nullable().optional(), }); +export const rejectVersionSchema = z.object({ + reason: z.string().min(1, 'Begründung erforderlich').max(2000), +}); + export const requestStemUploadUrlSchema = z.object({ fileName: z.string().min(1), mimeType: z.string().min(1),