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