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:
Robin Choice
2026-04-02 13:23:10 +02:00
commit e420ed198b
88 changed files with 7306 additions and 0 deletions

159
apps/api/src/routes/auth.ts Normal file
View 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('');
}

View 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 });
});

View 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
}

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

View 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 });
});