feat: reject with feedback, email alerts, SSE real-time updates

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 <noreply@anthropic.com>
This commit is contained in:
Robin Choice
2026-04-23 10:28:58 +02:00
parent df571df567
commit 06f0a43532
10 changed files with 269 additions and 13 deletions

View File

@@ -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<AppEnv>()
.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',
},
});
});