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

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

View File

@@ -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<AppEnv>()
@@ -109,6 +110,8 @@ export const commentRoutes = new Hono<AppEnv>()
})
.returning();
publish(track!.id, { type: 'comment:new', data: { versionId, commentId: comment.id } });
return c.json({ comment }, 201);
},
)

View File

@@ -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<string> {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(ip + 'musichub-salt'));
@@ -315,16 +316,48 @@ export const shareRoutes = new Hono<AppEnv>()
.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 });
})

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',
},
});
});

View File

@@ -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<AppEnv>()
@@ -130,13 +131,14 @@ 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(() => {});
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<AppEnv>()
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<AppEnv>()
});
})
// 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<AppEnv>()
.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 });
});

View File

@@ -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: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 460px; margin: 0 auto; padding: 2.5rem 2rem; color: #f4f0ec; background: #0a0910;">
<h1 style="font-size: 1.6rem; margin: 0 0 1rem; background: linear-gradient(135deg, #f43f5e, #fb923c); -webkit-background-clip: text; background-clip: text; color: transparent; display: inline-block;">Music Hub</h1>
<p style="color: #9b96a8; line-height: 1.55; margin: 0 0 0.5rem;">
<strong style="color: #f4f0ec;">${who}</strong> hat deinen Track gehört:
</p>
<p style="color: #f4f0ec; font-size: 1.1rem; font-weight: 600; margin: 0 0 0.25rem;">${trackName}</p>
<p style="color: #5e596b; font-size: 0.85rem; margin: 0 0 1.5rem;">${projectName}</p>
<a href="${trackUrl}" style="
display: inline-block;
padding: 0.8rem 1.6rem;
background: linear-gradient(135deg, #f43f5e, #fb923c);
color: #fff;
border-radius: 10px;
text-decoration: none;
font-weight: 600;
">Analytics ansehen</a>
</div>
`,
});
}
export async function sendInviteEmail(email: string, projectName: string, inviterName: string) {
const url = `${process.env.APP_URL}`;

View File

@@ -0,0 +1,21 @@
type SseClient = (data: string) => void;
const channels = new Map<string, Set<SseClient>>();
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 */ }
}
}