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 */ }
|
||||
}
|
||||
}
|
||||
40
apps/web/src/lib/stores/sse.ts
Normal file
40
apps/web/src/lib/stores/sse.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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`);
|
||||
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 @@
|
||||
<ShareModal bind:open={shareOpen} versionId={selectedVersion.id} />
|
||||
{/if}
|
||||
|
||||
<Modal bind:open={rejectOpen} title="Version ablehnen">
|
||||
<div class="edit-form">
|
||||
<label>
|
||||
<span class="lbl">Begründung <span style="color: var(--color-error)">*</span></span>
|
||||
<textarea
|
||||
bind:value={rejectReason}
|
||||
rows="4"
|
||||
placeholder="Was muss geändert werden? (Pflichtfeld)"
|
||||
autofocus
|
||||
></textarea>
|
||||
</label>
|
||||
</div>
|
||||
{#snippet actions()}
|
||||
<Button variant="ghost" onclick={() => (rejectOpen = false)}>Abbrechen</Button>
|
||||
<Button onclick={submitReject} loading={rejecting} disabled={!rejectReason.trim()}>
|
||||
Ablehnen
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<Modal bind:open={coverEditOpen} title="Track-Cover ändern">
|
||||
<div class="cover-modal">
|
||||
<CoverUpload currentUrl={trackCoverUrl} name={trackName} onUploaded={saveTrackCover} />
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user