Initial commit: Music Hub collaboration platform
Full-stack music production collaboration tool with: - SvelteKit frontend with Design System (CSS vars, 8 shared components) - Hono API with auth, projects, tracks, versions, comments - PostgreSQL + Drizzle ORM (8 tables, roles, permissions) - S3-compatible storage with presigned upload URLs - wavesurfer.js audio player with waveform visualization - A/B version comparison with synchronized playback - Timestamped comments with threading and resolve workflow - Magic Link authentication with Resend email integration - Background audio processing (ffmpeg transcode + waveform peaks) - Role-based access control (Owner, Engineers, Artist, Label, Management, Viewer) - Toast notifications, skeleton loading, responsive layout - Docker deployment setup (API + Web + Postgres) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
19
apps/api/package.json
Normal file
19
apps/api/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@music-hub/api",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run --hot src/index.ts",
|
||||
"build": "bun build src/index.ts --outdir dist --target bun"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3",
|
||||
"@aws-sdk/s3-request-presigner": "^3",
|
||||
"@hono/zod-validator": "^0.5",
|
||||
"@music-hub/db": "workspace:*",
|
||||
"@music-hub/shared": "workspace:*",
|
||||
"drizzle-orm": "^0.44",
|
||||
"hono": "^4",
|
||||
"resend": "^6.10.0"
|
||||
}
|
||||
}
|
||||
45
apps/api/src/index.ts
Normal file
45
apps/api/src/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { logger } from 'hono/logger';
|
||||
import { createDb } from '@music-hub/db';
|
||||
import { authRoutes } from './routes/auth.js';
|
||||
import { projectRoutes } from './routes/projects.js';
|
||||
import { trackRoutes } from './routes/tracks.js';
|
||||
import { versionRoutes } from './routes/versions.js';
|
||||
import { commentRoutes } from './routes/comments.js';
|
||||
import type { AppEnv } from './types.js';
|
||||
|
||||
const db = createDb(process.env.DATABASE_URL!);
|
||||
|
||||
const app = new Hono<AppEnv>()
|
||||
.use('*', logger())
|
||||
.use(
|
||||
'*',
|
||||
cors({
|
||||
origin: process.env.APP_URL || 'http://localhost:5173',
|
||||
credentials: true,
|
||||
}),
|
||||
)
|
||||
.use('*', async (c, next) => {
|
||||
c.set('db', db);
|
||||
await next();
|
||||
})
|
||||
.onError((err, c) => {
|
||||
console.error('Unhandled error:', err);
|
||||
return c.json({ error: err.message }, 500);
|
||||
})
|
||||
.get('/health', (c) => c.json({ status: 'ok' }))
|
||||
.basePath('/api/v1')
|
||||
.route('/auth', authRoutes)
|
||||
.route('/projects', projectRoutes)
|
||||
.route('/tracks', trackRoutes)
|
||||
.route('/versions', versionRoutes)
|
||||
.route('/comments', commentRoutes);
|
||||
|
||||
const port = parseInt(process.env.PORT || '3000');
|
||||
console.log(`Music Hub API running on port ${port}`);
|
||||
|
||||
export default {
|
||||
port,
|
||||
fetch: app.fetch,
|
||||
};
|
||||
37
apps/api/src/middleware/auth.ts
Normal file
37
apps/api/src/middleware/auth.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { createMiddleware } from 'hono/factory';
|
||||
import { getCookie } from 'hono/cookie';
|
||||
import { eq, gt } from 'drizzle-orm';
|
||||
import { sessions } from '@music-hub/db';
|
||||
import type { AppEnv } from '../types.js';
|
||||
|
||||
export const requireAuth = createMiddleware<AppEnv>(async (c, next) => {
|
||||
const sessionToken = getCookie(c, 'session');
|
||||
if (!sessionToken) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const tokenHash = await hashToken(sessionToken);
|
||||
const db = c.get('db');
|
||||
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.tokenHash, tokenHash))
|
||||
.limit(1);
|
||||
|
||||
if (!session || session.expiresAt < new Date()) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
c.set('userId', session.userId);
|
||||
await next();
|
||||
});
|
||||
|
||||
export async function hashToken(token: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(token);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
return Array.from(new Uint8Array(hashBuffer))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
159
apps/api/src/routes/auth.ts
Normal file
159
apps/api/src/routes/auth.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { setCookie, deleteCookie, getCookie } from 'hono/cookie';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { magicLinkSchema, verifyTokenSchema } from '@music-hub/shared';
|
||||
import { users, magicLinks, sessions } from '@music-hub/db';
|
||||
import { hashToken } from '../middleware/auth.js';
|
||||
import { sendMagicLinkEmail } from '../services/email.js';
|
||||
import type { AppEnv } from '../types.js';
|
||||
|
||||
export const authRoutes = new Hono<AppEnv>()
|
||||
.post('/magic-link', zValidator('json', magicLinkSchema), async (c) => {
|
||||
const { email } = c.req.valid('json');
|
||||
const db = c.get('db');
|
||||
|
||||
const token = generateToken();
|
||||
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 min
|
||||
|
||||
await db.insert(magicLinks).values({
|
||||
email,
|
||||
token,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
await sendMagicLinkEmail(email, token);
|
||||
|
||||
return c.json({ message: 'Magic link sent' });
|
||||
})
|
||||
|
||||
.post('/verify', zValidator('json', verifyTokenSchema), async (c) => {
|
||||
const { token } = c.req.valid('json');
|
||||
const db = c.get('db');
|
||||
|
||||
const [link] = await db
|
||||
.select()
|
||||
.from(magicLinks)
|
||||
.where(eq(magicLinks.token, token))
|
||||
.limit(1);
|
||||
|
||||
if (!link || link.expiresAt < new Date() || link.usedAt) {
|
||||
return c.json({ error: 'Invalid or expired token' }, 400);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(magicLinks)
|
||||
.set({ usedAt: new Date() })
|
||||
.where(eq(magicLinks.id, link.id));
|
||||
|
||||
// Find or create user
|
||||
let [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, link.email))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
const name = link.email.split('@')[0];
|
||||
[user] = await db
|
||||
.insert(users)
|
||||
.values({ email: link.email, name })
|
||||
.returning();
|
||||
}
|
||||
|
||||
// Create session
|
||||
const sessionToken = generateToken();
|
||||
const tokenHash = await hashToken(sessionToken);
|
||||
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
|
||||
|
||||
await db.insert(sessions).values({
|
||||
userId: user.id,
|
||||
tokenHash,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
setCookie(c, 'session', sessionToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'Lax',
|
||||
path: '/',
|
||||
maxAge: 30 * 24 * 60 * 60,
|
||||
});
|
||||
|
||||
return c.json({ user: { id: user.id, email: user.email, name: user.name } });
|
||||
})
|
||||
|
||||
.post('/logout', async (c) => {
|
||||
const sessionToken = getCookie(c, 'session');
|
||||
if (sessionToken) {
|
||||
const db = c.get('db');
|
||||
const tokenHash = await hashToken(sessionToken);
|
||||
await db.delete(sessions).where(eq(sessions.tokenHash, tokenHash));
|
||||
}
|
||||
deleteCookie(c, 'session');
|
||||
return c.json({ message: 'Logged out' });
|
||||
})
|
||||
|
||||
.get('/me', async (c) => {
|
||||
const sessionToken = getCookie(c, 'session');
|
||||
if (!sessionToken) {
|
||||
return c.json({ user: null });
|
||||
}
|
||||
|
||||
const db = c.get('db');
|
||||
const tokenHash = await hashToken(sessionToken);
|
||||
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.tokenHash, tokenHash))
|
||||
.limit(1);
|
||||
|
||||
if (!session || session.expiresAt < new Date()) {
|
||||
return c.json({ user: null });
|
||||
}
|
||||
|
||||
const [user] = await db
|
||||
.select({ id: users.id, email: users.email, name: users.name, avatarUrl: users.avatarUrl })
|
||||
.from(users)
|
||||
.where(eq(users.id, session.userId))
|
||||
.limit(1);
|
||||
|
||||
return c.json({ user: user || null });
|
||||
})
|
||||
|
||||
.patch('/me', async (c) => {
|
||||
const sessionToken = getCookie(c, 'session');
|
||||
if (!sessionToken) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
const db = c.get('db');
|
||||
const tokenHash = await hashToken(sessionToken);
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.tokenHash, tokenHash))
|
||||
.limit(1);
|
||||
|
||||
if (!session || session.expiresAt < new Date()) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const body = await c.req.json<{ name?: string }>();
|
||||
if (!body.name?.trim()) return c.json({ error: 'Name is required' }, 400);
|
||||
|
||||
const [user] = await db
|
||||
.update(users)
|
||||
.set({ name: body.name.trim(), updatedAt: new Date() })
|
||||
.where(eq(users.id, session.userId))
|
||||
.returning({ id: users.id, email: users.email, name: users.name, avatarUrl: users.avatarUrl });
|
||||
|
||||
return c.json({ user });
|
||||
});
|
||||
|
||||
function generateToken(): string {
|
||||
const bytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
171
apps/api/src/routes/comments.ts
Normal file
171
apps/api/src/routes/comments.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
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 type { AppEnv } from '../types.js';
|
||||
|
||||
export const commentRoutes = new Hono<AppEnv>()
|
||||
.use('*', requireAuth)
|
||||
|
||||
// Get comments for a version
|
||||
.get('/version/:versionId', async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const versionId = c.req.param('versionId');
|
||||
|
||||
const [version] = await db
|
||||
.select()
|
||||
.from(versions)
|
||||
.where(eq(versions.id, versionId))
|
||||
.limit(1);
|
||||
|
||||
if (!version) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
const [track] = await db
|
||||
.select()
|
||||
.from(tracks)
|
||||
.where(eq(tracks.id, version.trackId))
|
||||
.limit(1);
|
||||
|
||||
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: 'Not found' }, 404);
|
||||
|
||||
const versionComments = await db
|
||||
.select({
|
||||
id: comments.id,
|
||||
body: comments.body,
|
||||
timestampSeconds: comments.timestampSeconds,
|
||||
parentId: comments.parentId,
|
||||
resolvedAt: comments.resolvedAt,
|
||||
createdAt: comments.createdAt,
|
||||
user: {
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
avatarUrl: users.avatarUrl,
|
||||
},
|
||||
})
|
||||
.from(comments)
|
||||
.innerJoin(users, eq(users.id, comments.userId))
|
||||
.where(eq(comments.versionId, versionId))
|
||||
.orderBy(asc(comments.createdAt));
|
||||
|
||||
return c.json({ comments: versionComments });
|
||||
})
|
||||
|
||||
// Create comment
|
||||
.post(
|
||||
'/version/:versionId',
|
||||
zValidator('json', createCommentSchema),
|
||||
async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const versionId = c.req.param('versionId');
|
||||
const input = c.req.valid('json');
|
||||
|
||||
const [version] = await db
|
||||
.select()
|
||||
.from(versions)
|
||||
.where(eq(versions.id, versionId))
|
||||
.limit(1);
|
||||
|
||||
if (!version) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
const [track] = await db
|
||||
.select()
|
||||
.from(tracks)
|
||||
.where(eq(tracks.id, version.trackId))
|
||||
.limit(1);
|
||||
|
||||
const [membership] = await db
|
||||
.select()
|
||||
.from(projectMembers)
|
||||
.where(
|
||||
and(eq(projectMembers.projectId, track!.projectId), eq(projectMembers.userId, userId)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!membership || !membership.canComment) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
const [comment] = await db
|
||||
.insert(comments)
|
||||
.values({
|
||||
versionId,
|
||||
userId,
|
||||
body: input.body,
|
||||
timestampSeconds: input.timestampSeconds,
|
||||
parentId: input.parentId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return c.json({ comment }, 201);
|
||||
},
|
||||
)
|
||||
|
||||
// Update comment
|
||||
.patch('/:id', zValidator('json', updateCommentSchema), async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const commentId = c.req.param('id');
|
||||
const input = c.req.valid('json');
|
||||
|
||||
const [comment] = await db
|
||||
.select()
|
||||
.from(comments)
|
||||
.where(and(eq(comments.id, commentId), eq(comments.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!comment) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
const [updated] = await db
|
||||
.update(comments)
|
||||
.set({ body: input.body, updatedAt: new Date() })
|
||||
.where(eq(comments.id, commentId))
|
||||
.returning();
|
||||
|
||||
return c.json({ comment: updated });
|
||||
})
|
||||
|
||||
// Delete comment
|
||||
.delete('/:id', async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const commentId = c.req.param('id');
|
||||
|
||||
const [comment] = await db
|
||||
.select()
|
||||
.from(comments)
|
||||
.where(and(eq(comments.id, commentId), eq(comments.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!comment) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
await db.delete(comments).where(eq(comments.id, commentId));
|
||||
return c.json({ message: 'Comment deleted' });
|
||||
})
|
||||
|
||||
// Resolve comment
|
||||
.post('/:id/resolve', async (c) => {
|
||||
const db = c.get('db');
|
||||
const commentId = c.req.param('id');
|
||||
|
||||
const [updated] = await db
|
||||
.update(comments)
|
||||
.set({ resolvedAt: new Date() })
|
||||
.where(eq(comments.id, commentId))
|
||||
.returning();
|
||||
|
||||
if (!updated) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
return c.json({ comment: updated });
|
||||
});
|
||||
294
apps/api/src/routes/projects.ts
Normal file
294
apps/api/src/routes/projects.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import {
|
||||
createProjectSchema,
|
||||
updateProjectSchema,
|
||||
inviteMemberSchema,
|
||||
updateMemberSchema,
|
||||
} from '@music-hub/shared';
|
||||
import { projects, projectMembers, users } from '@music-hub/db';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import type { AppEnv } from '../types.js';
|
||||
|
||||
export const projectRoutes = new Hono<AppEnv>()
|
||||
.use('*', requireAuth)
|
||||
|
||||
.get('/', async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
|
||||
const memberships = await db
|
||||
.select({
|
||||
project: projects,
|
||||
role: projectMembers.role,
|
||||
})
|
||||
.from(projectMembers)
|
||||
.innerJoin(projects, eq(projects.id, projectMembers.projectId))
|
||||
.where(and(eq(projectMembers.userId, userId), eq(projects.isArchived, false)));
|
||||
|
||||
return c.json({ projects: memberships });
|
||||
})
|
||||
|
||||
.post('/', zValidator('json', createProjectSchema), async (c) => {
|
||||
const input = c.req.valid('json');
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
|
||||
const [project] = await db
|
||||
.insert(projects)
|
||||
.values({ ...input, createdById: userId })
|
||||
.returning();
|
||||
|
||||
await db.insert(projectMembers).values({
|
||||
projectId: project.id,
|
||||
userId,
|
||||
role: 'owner',
|
||||
canUpload: true,
|
||||
canComment: true,
|
||||
canApprove: true,
|
||||
});
|
||||
|
||||
return c.json({ project }, 201);
|
||||
})
|
||||
|
||||
.get('/:id', async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const projectId = c.req.param('id');
|
||||
|
||||
const [membership] = await db
|
||||
.select()
|
||||
.from(projectMembers)
|
||||
.where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!membership) {
|
||||
return c.json({ error: 'Not found' }, 404);
|
||||
}
|
||||
|
||||
const [project] = await db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(eq(projects.id, projectId))
|
||||
.limit(1);
|
||||
|
||||
return c.json({ project, role: membership.role });
|
||||
})
|
||||
|
||||
.patch('/:id', zValidator('json', updateProjectSchema), async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const projectId = c.req.param('id');
|
||||
const input = c.req.valid('json');
|
||||
|
||||
const [membership] = await db
|
||||
.select()
|
||||
.from(projectMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(projectMembers.projectId, projectId),
|
||||
eq(projectMembers.userId, userId),
|
||||
eq(projectMembers.role, 'owner'),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!membership) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
const [project] = await db
|
||||
.update(projects)
|
||||
.set({ ...input, updatedAt: new Date() })
|
||||
.where(eq(projects.id, projectId))
|
||||
.returning();
|
||||
|
||||
return c.json({ project });
|
||||
})
|
||||
|
||||
.delete('/:id', async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const projectId = c.req.param('id');
|
||||
|
||||
const [membership] = await db
|
||||
.select()
|
||||
.from(projectMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(projectMembers.projectId, projectId),
|
||||
eq(projectMembers.userId, userId),
|
||||
eq(projectMembers.role, 'owner'),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!membership) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(projects)
|
||||
.set({ isArchived: true, updatedAt: new Date() })
|
||||
.where(eq(projects.id, projectId));
|
||||
|
||||
return c.json({ message: 'Project archived' });
|
||||
})
|
||||
|
||||
// Members
|
||||
.get('/:id/members', async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const projectId = c.req.param('id');
|
||||
|
||||
// Check access
|
||||
const [membership] = await db
|
||||
.select()
|
||||
.from(projectMembers)
|
||||
.where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!membership) {
|
||||
return c.json({ error: 'Not found' }, 404);
|
||||
}
|
||||
|
||||
const members = await db
|
||||
.select({
|
||||
id: projectMembers.id,
|
||||
role: projectMembers.role,
|
||||
canUpload: projectMembers.canUpload,
|
||||
canComment: projectMembers.canComment,
|
||||
canApprove: projectMembers.canApprove,
|
||||
user: {
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
name: users.name,
|
||||
avatarUrl: users.avatarUrl,
|
||||
},
|
||||
})
|
||||
.from(projectMembers)
|
||||
.innerJoin(users, eq(users.id, projectMembers.userId))
|
||||
.where(eq(projectMembers.projectId, projectId));
|
||||
|
||||
return c.json({ members });
|
||||
})
|
||||
|
||||
.post('/:id/members', zValidator('json', inviteMemberSchema), async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const projectId = c.req.param('id');
|
||||
const { email, role } = c.req.valid('json');
|
||||
|
||||
// Check permission (owner or management)
|
||||
const [membership] = await db
|
||||
.select()
|
||||
.from(projectMembers)
|
||||
.where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!membership || (membership.role !== 'owner' && membership.role !== 'management')) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
// Find or create user
|
||||
let [invitedUser] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.limit(1);
|
||||
|
||||
if (!invitedUser) {
|
||||
[invitedUser] = await db
|
||||
.insert(users)
|
||||
.values({ email, name: email.split('@')[0] })
|
||||
.returning();
|
||||
}
|
||||
|
||||
const defaults = getRoleDefaults(role);
|
||||
const [member] = await db
|
||||
.insert(projectMembers)
|
||||
.values({
|
||||
projectId,
|
||||
userId: invitedUser.id,
|
||||
role,
|
||||
...defaults,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
|
||||
if (!member) {
|
||||
return c.json({ error: 'User already a member' }, 409);
|
||||
}
|
||||
|
||||
return c.json({ member }, 201);
|
||||
})
|
||||
|
||||
.patch('/:id/members/:memberId', zValidator('json', updateMemberSchema), async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const projectId = c.req.param('id');
|
||||
const memberId = c.req.param('memberId');
|
||||
const { role: newRole } = c.req.valid('json');
|
||||
|
||||
const [membership] = await db
|
||||
.select()
|
||||
.from(projectMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(projectMembers.projectId, projectId),
|
||||
eq(projectMembers.userId, userId),
|
||||
eq(projectMembers.role, 'owner'),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!membership) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
const defaults = getRoleDefaults(newRole);
|
||||
const [updated] = await db
|
||||
.update(projectMembers)
|
||||
.set({ role: newRole, ...defaults })
|
||||
.where(eq(projectMembers.id, memberId))
|
||||
.returning();
|
||||
|
||||
return c.json({ member: updated });
|
||||
})
|
||||
|
||||
.delete('/:id/members/:memberId', async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const projectId = c.req.param('id');
|
||||
const memberId = c.req.param('memberId');
|
||||
|
||||
const [membership] = await db
|
||||
.select()
|
||||
.from(projectMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(projectMembers.projectId, projectId),
|
||||
eq(projectMembers.userId, userId),
|
||||
eq(projectMembers.role, 'owner'),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!membership) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
await db.delete(projectMembers).where(eq(projectMembers.id, memberId));
|
||||
return c.json({ message: 'Member removed' });
|
||||
});
|
||||
|
||||
function getRoleDefaults(role: string) {
|
||||
const engineerRoles = ['recording_engineer', 'mixing_engineer', 'mastering_engineer'];
|
||||
if (role === 'owner') return { canUpload: true, canComment: true, canApprove: true };
|
||||
if (engineerRoles.includes(role)) return { canUpload: true, canComment: true, canApprove: false };
|
||||
if (role === 'artist') return { canUpload: false, canComment: true, canApprove: true };
|
||||
if (role === 'label') return { canUpload: false, canComment: true, canApprove: true };
|
||||
if (role === 'management') return { canUpload: false, canComment: true, canApprove: true };
|
||||
return { canUpload: false, canComment: false, canApprove: false }; // viewer
|
||||
}
|
||||
123
apps/api/src/routes/tracks.ts
Normal file
123
apps/api/src/routes/tracks.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { eq, and, asc } from 'drizzle-orm';
|
||||
import { createTrackSchema, updateTrackSchema } from '@music-hub/shared';
|
||||
import { tracks, projectMembers } from '@music-hub/db';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import type { AppEnv } from '../types.js';
|
||||
|
||||
export const trackRoutes = new Hono<AppEnv>()
|
||||
.use('*', requireAuth)
|
||||
|
||||
// Get all tracks for a project
|
||||
.get('/project/:projectId', async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const projectId = c.req.param('projectId');
|
||||
|
||||
const [membership] = await db
|
||||
.select()
|
||||
.from(projectMembers)
|
||||
.where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!membership) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
const projectTracks = await db
|
||||
.select()
|
||||
.from(tracks)
|
||||
.where(eq(tracks.projectId, projectId))
|
||||
.orderBy(asc(tracks.sortOrder), asc(tracks.createdAt));
|
||||
|
||||
return c.json({ tracks: projectTracks });
|
||||
})
|
||||
|
||||
.post('/:projectId', zValidator('json', createTrackSchema), async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const projectId = c.req.param('projectId');
|
||||
const input = c.req.valid('json');
|
||||
|
||||
const [membership] = await db
|
||||
.select()
|
||||
.from(projectMembers)
|
||||
.where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!membership || !membership.canUpload) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
const [track] = await db
|
||||
.insert(tracks)
|
||||
.values({ ...input, projectId, createdById: userId })
|
||||
.returning();
|
||||
|
||||
return c.json({ track }, 201);
|
||||
})
|
||||
|
||||
.patch('/:id', zValidator('json', updateTrackSchema), async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const trackId = c.req.param('id');
|
||||
const input = c.req.valid('json');
|
||||
|
||||
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 || !membership.canUpload) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(tracks)
|
||||
.set({ ...input, updatedAt: new Date() })
|
||||
.where(eq(tracks.id, trackId))
|
||||
.returning();
|
||||
|
||||
return c.json({ track: updated });
|
||||
})
|
||||
|
||||
.delete('/:id', async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const trackId = c.req.param('id');
|
||||
|
||||
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),
|
||||
eq(projectMembers.role, 'owner'),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!membership) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
await db.delete(tracks).where(eq(tracks.id, trackId));
|
||||
return c.json({ message: 'Track deleted' });
|
||||
});
|
||||
251
apps/api/src/routes/versions.ts
Normal file
251
apps/api/src/routes/versions.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { eq, and, desc, sql } from 'drizzle-orm';
|
||||
import { requestUploadUrlSchema, createVersionSchema } from '@music-hub/shared';
|
||||
import { tracks, versions, projectMembers } from '@music-hub/db';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { createUploadUrl, createDownloadUrl } from '../storage/s3.js';
|
||||
import { processVersion } from '../services/audio-processor.js';
|
||||
import type { AppEnv } from '../types.js';
|
||||
|
||||
export const versionRoutes = new Hono<AppEnv>()
|
||||
.use('*', requireAuth)
|
||||
|
||||
// Get all versions for a track
|
||||
.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: 'Not found' }, 404);
|
||||
|
||||
const trackVersions = await db
|
||||
.select()
|
||||
.from(versions)
|
||||
.where(eq(versions.trackId, trackId))
|
||||
.orderBy(desc(versions.versionNumber));
|
||||
|
||||
return c.json({ versions: trackVersions });
|
||||
})
|
||||
|
||||
// Request presigned upload URL
|
||||
.post(
|
||||
'/track/:trackId/upload-url',
|
||||
zValidator('json', requestUploadUrlSchema),
|
||||
async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const trackId = c.req.param('trackId');
|
||||
const { fileName, mimeType, fileSize } = c.req.valid('json');
|
||||
|
||||
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 || !membership.canUpload) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
const versionId = crypto.randomUUID();
|
||||
const fileKey = `projects/${track.projectId}/tracks/${trackId}/versions/${versionId}/original/${fileName}`;
|
||||
|
||||
const uploadUrl = await createUploadUrl(fileKey, mimeType, fileSize);
|
||||
|
||||
return c.json({ uploadUrl, fileKey, versionId });
|
||||
},
|
||||
)
|
||||
|
||||
// Register version after upload
|
||||
.post('/track/:trackId', zValidator('json', createVersionSchema), async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const trackId = c.req.param('trackId');
|
||||
const input = c.req.valid('json');
|
||||
|
||||
const [track] = await db.select().from(tracks).where(eq(tracks.id, trackId)).limit(1);
|
||||
if (!track) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
// Get next version number
|
||||
const [latest] = await db
|
||||
.select({ maxVersion: sql<number>`coalesce(max(${versions.versionNumber}), 0)` })
|
||||
.from(versions)
|
||||
.where(eq(versions.trackId, trackId));
|
||||
|
||||
const versionNumber = (latest?.maxVersion ?? 0) + 1;
|
||||
|
||||
const [version] = await db
|
||||
.insert(versions)
|
||||
.values({
|
||||
trackId,
|
||||
versionNumber,
|
||||
label: input.label,
|
||||
notes: input.notes,
|
||||
status: 'uploaded',
|
||||
originalFileName: input.originalFileName,
|
||||
mimeType: input.mimeType,
|
||||
fileSize: input.fileSize,
|
||||
originalFileKey: input.fileKey,
|
||||
createdById: userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Background processing (fire and forget)
|
||||
processVersion(db, version.id).catch((err) =>
|
||||
console.error(`[Worker] Failed: ${err.message}`),
|
||||
);
|
||||
|
||||
return c.json({ version }, 201);
|
||||
})
|
||||
|
||||
// Get stream URL
|
||||
.get('/:id/stream-url', async (c) => {
|
||||
const db = c.get('db');
|
||||
const versionId = c.req.param('id');
|
||||
|
||||
const [version] = await db
|
||||
.select()
|
||||
.from(versions)
|
||||
.where(eq(versions.id, versionId))
|
||||
.limit(1);
|
||||
|
||||
if (!version) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
const key = version.streamFileKey || version.originalFileKey;
|
||||
const url = await createDownloadUrl(key);
|
||||
|
||||
return c.json({ url });
|
||||
})
|
||||
|
||||
// Get download URL
|
||||
.get('/:id/download-url', async (c) => {
|
||||
const db = c.get('db');
|
||||
const versionId = c.req.param('id');
|
||||
|
||||
const [version] = await db
|
||||
.select()
|
||||
.from(versions)
|
||||
.where(eq(versions.id, versionId))
|
||||
.limit(1);
|
||||
|
||||
if (!version) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
const url = await createDownloadUrl(version.originalFileKey);
|
||||
return c.json({ url });
|
||||
})
|
||||
|
||||
// Get waveform data
|
||||
.get('/:id/waveform', async (c) => {
|
||||
const db = c.get('db');
|
||||
const versionId = c.req.param('id');
|
||||
|
||||
const [version] = await db
|
||||
.select()
|
||||
.from(versions)
|
||||
.where(eq(versions.id, versionId))
|
||||
.limit(1);
|
||||
|
||||
if (!version || !version.waveformDataKey) {
|
||||
return c.json({ error: 'Not found' }, 404);
|
||||
}
|
||||
|
||||
const url = await createDownloadUrl(version.waveformDataKey);
|
||||
return c.json({ url });
|
||||
})
|
||||
|
||||
// Approve version
|
||||
.post('/:id/approve', async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const versionId = c.req.param('id');
|
||||
|
||||
const [version] = await db
|
||||
.select()
|
||||
.from(versions)
|
||||
.where(eq(versions.id, versionId))
|
||||
.limit(1);
|
||||
|
||||
if (!version) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
const [track] = await db
|
||||
.select()
|
||||
.from(tracks)
|
||||
.where(eq(tracks.id, version.trackId))
|
||||
.limit(1);
|
||||
|
||||
const [membership] = await db
|
||||
.select()
|
||||
.from(projectMembers)
|
||||
.where(
|
||||
and(eq(projectMembers.projectId, track!.projectId), eq(projectMembers.userId, userId)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!membership || !membership.canApprove) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(versions)
|
||||
.set({ status: 'approved' })
|
||||
.where(eq(versions.id, versionId))
|
||||
.returning();
|
||||
|
||||
return c.json({ version: updated });
|
||||
})
|
||||
|
||||
// Reject version
|
||||
.post('/:id/reject', async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const versionId = c.req.param('id');
|
||||
|
||||
const [version] = await db
|
||||
.select()
|
||||
.from(versions)
|
||||
.where(eq(versions.id, versionId))
|
||||
.limit(1);
|
||||
|
||||
if (!version) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
const [track] = await db
|
||||
.select()
|
||||
.from(tracks)
|
||||
.where(eq(tracks.id, version.trackId))
|
||||
.limit(1);
|
||||
|
||||
const [membership] = await db
|
||||
.select()
|
||||
.from(projectMembers)
|
||||
.where(
|
||||
and(eq(projectMembers.projectId, track!.projectId), eq(projectMembers.userId, userId)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!membership || !membership.canApprove) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(versions)
|
||||
.set({ status: 'rejected' })
|
||||
.where(eq(versions.id, versionId))
|
||||
.returning();
|
||||
|
||||
return c.json({ version: updated });
|
||||
});
|
||||
159
apps/api/src/services/audio-processor.ts
Normal file
159
apps/api/src/services/audio-processor.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { versions } from '@music-hub/db';
|
||||
import { createUploadUrl, createDownloadUrl } from '../storage/s3.js';
|
||||
import type { Database } from '@music-hub/db';
|
||||
|
||||
export async function processVersion(db: Database, versionId: string) {
|
||||
console.log(`[Worker] Processing version ${versionId}`);
|
||||
|
||||
// Mark as processing
|
||||
const [version] = await db
|
||||
.update(versions)
|
||||
.set({ status: 'processing' })
|
||||
.where(eq(versions.id, versionId))
|
||||
.returning();
|
||||
|
||||
if (!version) {
|
||||
console.error(`[Worker] Version ${versionId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const originalUrl = await createDownloadUrl(version.originalFileKey, 3600);
|
||||
|
||||
// Extract metadata with ffprobe
|
||||
const metadata = await extractMetadata(originalUrl);
|
||||
|
||||
// Generate waveform peaks
|
||||
const peaks = await generateWaveformPeaks(originalUrl, metadata.duration);
|
||||
const waveformKey = version.originalFileKey.replace(/\/original\/.*$/, '/waveform/peaks.json');
|
||||
|
||||
// Upload waveform data to S3
|
||||
const waveformUploadUrl = await createUploadUrl(waveformKey, 'application/json', 10 * 1024 * 1024);
|
||||
await fetch(waveformUploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(peaks),
|
||||
});
|
||||
|
||||
// Transcode to MP3 for streaming
|
||||
const streamKey = version.originalFileKey.replace(/\/original\/.*$/, '/stream/audio.mp3');
|
||||
await transcodeToMp3(originalUrl, streamKey);
|
||||
|
||||
// Update version with metadata
|
||||
await db
|
||||
.update(versions)
|
||||
.set({
|
||||
status: 'ready',
|
||||
duration: metadata.duration,
|
||||
sampleRate: metadata.sampleRate,
|
||||
bitDepth: metadata.bitDepth,
|
||||
streamFileKey: streamKey,
|
||||
waveformDataKey: waveformKey,
|
||||
})
|
||||
.where(eq(versions.id, versionId));
|
||||
|
||||
console.log(`[Worker] Version ${versionId} ready`);
|
||||
} catch (error) {
|
||||
console.error(`[Worker] Failed to process version ${versionId}:`, error);
|
||||
// Still mark as ready so user can listen to original
|
||||
await db
|
||||
.update(versions)
|
||||
.set({ status: 'ready' })
|
||||
.where(eq(versions.id, versionId));
|
||||
}
|
||||
}
|
||||
|
||||
async function extractMetadata(url: string): Promise<{
|
||||
duration: number;
|
||||
sampleRate: number;
|
||||
bitDepth: number;
|
||||
}> {
|
||||
const proc = Bun.spawn([
|
||||
'ffprobe',
|
||||
'-v', 'quiet',
|
||||
'-print_format', 'json',
|
||||
'-show_format',
|
||||
'-show_streams',
|
||||
url,
|
||||
]);
|
||||
|
||||
const output = await new Response(proc.stdout).text();
|
||||
await proc.exited;
|
||||
|
||||
const data = JSON.parse(output);
|
||||
const audioStream = data.streams?.find((s: any) => s.codec_type === 'audio');
|
||||
|
||||
return {
|
||||
duration: parseFloat(data.format?.duration || '0'),
|
||||
sampleRate: parseInt(audioStream?.sample_rate || '44100'),
|
||||
bitDepth: parseInt(audioStream?.bits_per_raw_sample || audioStream?.bits_per_sample || '16'),
|
||||
};
|
||||
}
|
||||
|
||||
async function generateWaveformPeaks(url: string, duration: number): Promise<number[]> {
|
||||
// Generate raw PCM samples with ffmpeg, then compute peaks
|
||||
const samplesPerPixel = Math.max(1, Math.floor(duration * 44100 / 800)); // ~800 peaks
|
||||
|
||||
const proc = Bun.spawn([
|
||||
'ffmpeg',
|
||||
'-i', url,
|
||||
'-ac', '1', // mono
|
||||
'-ar', '8000', // low sample rate for peaks
|
||||
'-f', 'f32le', // raw 32-bit float
|
||||
'-v', 'quiet',
|
||||
'pipe:1',
|
||||
]);
|
||||
|
||||
const buffer = await new Response(proc.stdout).arrayBuffer();
|
||||
await proc.exited;
|
||||
|
||||
const samples = new Float32Array(buffer);
|
||||
const numPeaks = 800;
|
||||
const blockSize = Math.max(1, Math.floor(samples.length / numPeaks));
|
||||
const peaks: number[] = [];
|
||||
|
||||
for (let i = 0; i < numPeaks && i * blockSize < samples.length; i++) {
|
||||
let max = 0;
|
||||
const start = i * blockSize;
|
||||
const end = Math.min(start + blockSize, samples.length);
|
||||
for (let j = start; j < end; j++) {
|
||||
const abs = Math.abs(samples[j]);
|
||||
if (abs > max) max = abs;
|
||||
}
|
||||
peaks.push(Math.round(max * 1000) / 1000);
|
||||
}
|
||||
|
||||
return peaks;
|
||||
}
|
||||
|
||||
async function transcodeToMp3(inputUrl: string, outputKey: string) {
|
||||
// Transcode to temp file, then upload
|
||||
const tmpFile = `/tmp/musichub-${crypto.randomUUID()}.mp3`;
|
||||
|
||||
const proc = Bun.spawn([
|
||||
'ffmpeg',
|
||||
'-i', inputUrl,
|
||||
'-codec:a', 'libmp3lame',
|
||||
'-b:a', '128k',
|
||||
'-v', 'quiet',
|
||||
'-y',
|
||||
tmpFile,
|
||||
]);
|
||||
|
||||
await proc.exited;
|
||||
|
||||
// Upload to S3
|
||||
const file = Bun.file(tmpFile);
|
||||
const fileSize = file.size;
|
||||
const uploadUrl = await createUploadUrl(outputKey, 'audio/mpeg', fileSize);
|
||||
|
||||
await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'audio/mpeg' },
|
||||
body: file,
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
await Bun.file(tmpFile).exists() && (await Bun.spawn(['rm', tmpFile]).exited);
|
||||
}
|
||||
71
apps/api/src/services/email.ts
Normal file
71
apps/api/src/services/email.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Resend } from 'resend';
|
||||
|
||||
const resend = process.env.RESEND_API_KEY
|
||||
? new Resend(process.env.RESEND_API_KEY)
|
||||
: null;
|
||||
|
||||
const fromEmail = process.env.EMAIL_FROM || 'Music Hub <noreply@musichub.de>';
|
||||
|
||||
export async function sendMagicLinkEmail(email: string, token: string) {
|
||||
const url = `${process.env.APP_URL}/auth/verify?token=${token}`;
|
||||
|
||||
if (!resend) {
|
||||
console.log(`[DEV] Magic link for ${email}: ${url}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await resend.emails.send({
|
||||
from: fromEmail,
|
||||
to: email,
|
||||
subject: 'Your Music Hub Login Link',
|
||||
html: `
|
||||
<div style="font-family: -apple-system, sans-serif; max-width: 400px; margin: 0 auto; padding: 2rem;">
|
||||
<h1 style="font-size: 1.5rem; color: #f0f0f0;">Music Hub</h1>
|
||||
<p style="color: #a0a0a0;">Click the button below to log in:</p>
|
||||
<a href="${url}" style="
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #6366f1;
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
margin: 1rem 0;
|
||||
">Log in to Music Hub</a>
|
||||
<p style="color: #666; font-size: 0.85rem;">This link expires in 15 minutes.</p>
|
||||
<p style="color: #666; font-size: 0.85rem;">If you didn't request this, ignore this email.</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendInviteEmail(email: string, projectName: string, inviterName: string) {
|
||||
const url = `${process.env.APP_URL}`;
|
||||
|
||||
if (!resend) {
|
||||
console.log(`[DEV] Invite ${email} to project "${projectName}" by ${inviterName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await resend.emails.send({
|
||||
from: fromEmail,
|
||||
to: email,
|
||||
subject: `You've been invited to "${projectName}" on Music Hub`,
|
||||
html: `
|
||||
<div style="font-family: -apple-system, sans-serif; max-width: 400px; margin: 0 auto; padding: 2rem;">
|
||||
<h1 style="font-size: 1.5rem; color: #f0f0f0;">Music Hub</h1>
|
||||
<p style="color: #a0a0a0;">${inviterName} invited you to collaborate on <strong>"${projectName}"</strong>.</p>
|
||||
<a href="${url}" style="
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #6366f1;
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
margin: 1rem 0;
|
||||
">Open Music Hub</a>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
}
|
||||
49
apps/api/src/storage/s3.ts
Normal file
49
apps/api/src/storage/s3.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
|
||||
const s3 = new S3Client({
|
||||
region: 'eu-central-1',
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
credentials: {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY!,
|
||||
secretAccessKey: process.env.S3_SECRET_KEY!,
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
const bucket = process.env.S3_BUCKET!;
|
||||
|
||||
export async function createUploadUrl(
|
||||
key: string,
|
||||
contentType: string,
|
||||
maxSize: number,
|
||||
): Promise<string> {
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
ContentLength: maxSize,
|
||||
});
|
||||
return getSignedUrl(s3, command, { expiresIn: 900 }); // 15 min
|
||||
}
|
||||
|
||||
export async function createDownloadUrl(key: string, expiresIn = 3600): Promise<string> {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
});
|
||||
return getSignedUrl(s3, command, { expiresIn });
|
||||
}
|
||||
|
||||
export async function deleteObject(key: string): Promise<void> {
|
||||
const command = new DeleteObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
});
|
||||
await s3.send(command);
|
||||
}
|
||||
8
apps/api/src/types.ts
Normal file
8
apps/api/src/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { Database } from '@music-hub/db';
|
||||
|
||||
export type AppEnv = {
|
||||
Variables: {
|
||||
db: Database;
|
||||
userId: string;
|
||||
};
|
||||
};
|
||||
12
apps/api/tsconfig.json
Normal file
12
apps/api/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user