diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 0eb0c37..21bd62b 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -7,6 +7,7 @@ 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 { shareRoutes } from './routes/share.js'; import type { AppEnv } from './types.js'; const db = createDb(process.env.DATABASE_URL!); @@ -34,7 +35,8 @@ const app = new Hono() .route('/projects', projectRoutes) .route('/tracks', trackRoutes) .route('/versions', versionRoutes) - .route('/comments', commentRoutes); + .route('/comments', commentRoutes) + .route('/share', shareRoutes); const port = parseInt(process.env.PORT || '3000'); console.log(`Music Hub API running on port ${port}`); diff --git a/apps/api/src/routes/comments.ts b/apps/api/src/routes/comments.ts index 66a83eb..b6f7080 100644 --- a/apps/api/src/routes/comments.ts +++ b/apps/api/src/routes/comments.ts @@ -47,6 +47,7 @@ export const commentRoutes = new Hono() parentId: comments.parentId, resolvedAt: comments.resolvedAt, createdAt: comments.createdAt, + guestName: comments.guestName, user: { id: users.id, name: users.name, @@ -54,7 +55,7 @@ export const commentRoutes = new Hono() }, }) .from(comments) - .innerJoin(users, eq(users.id, comments.userId)) + .leftJoin(users, eq(users.id, comments.userId)) .where(eq(comments.versionId, versionId)) .orderBy(asc(comments.createdAt)); diff --git a/apps/api/src/routes/share.ts b/apps/api/src/routes/share.ts new file mode 100644 index 0000000..0daf414 --- /dev/null +++ b/apps/api/src/routes/share.ts @@ -0,0 +1,265 @@ +import { Hono } from 'hono'; +import { zValidator } from '@hono/zod-validator'; +import { eq, and, asc } from 'drizzle-orm'; +import { createShareLinkSchema, guestCommentSchema } from '@music-hub/shared'; +import { + shareLinks, + versions, + tracks, + projects, + comments, + users, + projectMembers, +} from '@music-hub/db'; +import { requireAuth } from '../middleware/auth.js'; +import { createDownloadUrl } from '../storage/s3.js'; +import type { AppEnv } from '../types.js'; + +function generateToken(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); +} + +export const shareRoutes = new Hono() + // --- Authenticated: create / list / revoke --- + .post( + '/version/:versionId', + requireAuth, + zValidator('json', createShareLinkSchema), + 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) return c.json({ error: 'Forbidden' }, 403); + + const token = generateToken(); + const passwordHash = input.password + ? await Bun.password.hash(input.password) + : null; + + const [link] = await db + .insert(shareLinks) + .values({ + versionId, + token, + createdById: userId, + expiresAt: input.expiresAt ? new Date(input.expiresAt) : null, + allowComments: input.allowComments ?? true, + allowDownload: input.allowDownload ?? false, + passwordHash, + }) + .returning(); + + return c.json({ link: { ...link, passwordHash: undefined } }, 201); + }, + ) + + .get('/version/:versionId', requireAuth, 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: 'Forbidden' }, 403); + + const links = await db + .select({ + id: shareLinks.id, + token: shareLinks.token, + expiresAt: shareLinks.expiresAt, + allowComments: shareLinks.allowComments, + allowDownload: shareLinks.allowDownload, + hasPassword: shareLinks.passwordHash, + createdAt: shareLinks.createdAt, + }) + .from(shareLinks) + .where(eq(shareLinks.versionId, versionId)) + .orderBy(asc(shareLinks.createdAt)); + + return c.json({ + links: links.map((l) => ({ ...l, hasPassword: l.hasPassword !== null })), + }); + }) + + .delete('/:linkId', requireAuth, async (c) => { + const db = c.get('db'); + const userId = c.get('userId'); + const linkId = c.req.param('linkId'); + + const [link] = await db + .select() + .from(shareLinks) + .where(eq(shareLinks.id, linkId)) + .limit(1); + if (!link) return c.json({ error: 'Not found' }, 404); + if (link.createdById !== userId) return c.json({ error: 'Forbidden' }, 403); + + await db.delete(shareLinks).where(eq(shareLinks.id, linkId)); + return c.json({ message: 'Revoked' }); + }) + + // --- Public: resolve token, fetch, comment --- + .get('/public/:token', async (c) => { + const db = c.get('db'); + const token = c.req.param('token'); + const password = c.req.header('x-share-password'); + + const [link] = await db + .select() + .from(shareLinks) + .where(eq(shareLinks.token, token)) + .limit(1); + if (!link) return c.json({ error: 'Not found' }, 404); + if (link.expiresAt && link.expiresAt < new Date()) { + return c.json({ error: 'Expired' }, 410); + } + if (link.passwordHash) { + if (!password || !(await Bun.password.verify(password, link.passwordHash))) { + return c.json({ error: 'Password required', passwordRequired: true }, 401); + } + } + + const [version] = await db + .select() + .from(versions) + .where(eq(versions.id, link.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 [project] = await db + .select() + .from(projects) + .where(eq(projects.id, track!.projectId)) + .limit(1); + + const streamKey = version.streamFileKey || version.originalFileKey; + const streamUrl = await createDownloadUrl(streamKey); + const waveformUrl = version.waveformDataKey + ? await createDownloadUrl(version.waveformDataKey) + : null; + const downloadUrl = link.allowDownload + ? await createDownloadUrl(version.originalFileKey) + : null; + + const versionComments = await db + .select({ + id: comments.id, + body: comments.body, + timestampSeconds: comments.timestampSeconds, + parentId: comments.parentId, + resolvedAt: comments.resolvedAt, + createdAt: comments.createdAt, + guestName: comments.guestName, + user: { + id: users.id, + name: users.name, + avatarUrl: users.avatarUrl, + }, + }) + .from(comments) + .leftJoin(users, eq(users.id, comments.userId)) + .where(eq(comments.versionId, version.id)) + .orderBy(asc(comments.createdAt)); + + return c.json({ + project: { name: project!.name }, + track: { id: track!.id, name: track!.name }, + version: { + id: version.id, + label: version.label, + notes: version.notes, + duration: version.duration, + status: version.status, + originalFileName: version.originalFileName, + }, + streamUrl, + waveformUrl, + downloadUrl, + allowComments: link.allowComments, + comments: versionComments, + }); + }) + + .post('/public/:token/comments', zValidator('json', guestCommentSchema), async (c) => { + const db = c.get('db'); + const token = c.req.param('token'); + const password = c.req.header('x-share-password'); + const input = c.req.valid('json'); + + const [link] = await db + .select() + .from(shareLinks) + .where(eq(shareLinks.token, token)) + .limit(1); + if (!link) return c.json({ error: 'Not found' }, 404); + if (link.expiresAt && link.expiresAt < new Date()) { + return c.json({ error: 'Expired' }, 410); + } + if (!link.allowComments) return c.json({ error: 'Comments disabled' }, 403); + if (link.passwordHash) { + if (!password || !(await Bun.password.verify(password, link.passwordHash))) { + return c.json({ error: 'Password required' }, 401); + } + } + + const [comment] = await db + .insert(comments) + .values({ + versionId: link.versionId, + userId: null, + guestName: input.guestName, + body: input.body, + timestampSeconds: input.timestampSeconds, + parentId: input.parentId, + }) + .returning(); + + return c.json({ comment }, 201); + }); diff --git a/apps/api/src/routes/versions.ts b/apps/api/src/routes/versions.ts index 7022def..845df71 100644 --- a/apps/api/src/routes/versions.ts +++ b/apps/api/src/routes/versions.ts @@ -1,6 +1,6 @@ import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; -import { eq, and, desc, sql } from 'drizzle-orm'; +import { eq, and, desc, asc, 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'; @@ -97,6 +97,8 @@ export const versionRoutes = new Hono() label: input.label, notes: input.notes, status: 'uploaded', + parentVersionId: input.parentVersionId, + branchLabel: input.branchLabel, originalFileName: input.originalFileName, mimeType: input.mimeType, fileSize: input.fileSize, @@ -113,6 +115,81 @@ export const versionRoutes = new Hono() return c.json({ version }, 201); }) + // Get version tree (graph) for a track + .get('/track/:trackId/tree', 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 nodes = await db + .select({ + id: versions.id, + parentVersionId: versions.parentVersionId, + branchLabel: versions.branchLabel, + versionNumber: versions.versionNumber, + label: versions.label, + status: versions.status, + createdById: versions.createdById, + createdAt: versions.createdAt, + }) + .from(versions) + .where(eq(versions.trackId, trackId)) + .orderBy(asc(versions.createdAt)); + + return c.json({ nodes }); + }) + + // Promote a version to mainline (clears branchLabel) + .post('/:id/promote', 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({ branchLabel: null }) + .where(eq(versions.id, versionId)) + .returning(); + + return c.json({ version: updated }); + }) + // Get stream URL .get('/:id/stream-url', async (c) => { const db = c.get('db'); diff --git a/apps/web/src/lib/components/audio/UploadDropzone.svelte b/apps/web/src/lib/components/audio/UploadDropzone.svelte index e028c36..f2c3f45 100644 --- a/apps/web/src/lib/components/audio/UploadDropzone.svelte +++ b/apps/web/src/lib/components/audio/UploadDropzone.svelte @@ -4,9 +4,13 @@ let { trackId, + parentVersionId = null, + branchLabel = null, onUploaded, }: { trackId: string; + parentVersionId?: string | null; + branchLabel?: string | null; onUploaded: () => void; } = $props(); @@ -78,6 +82,8 @@ originalFileName: file.name, mimeType: file.type || 'audio/wav', fileSize: file.size, + parentVersionId: parentVersionId ?? undefined, + branchLabel: branchLabel ?? undefined, }); label = ''; diff --git a/apps/web/src/routes/listen/[token]/+page.svelte b/apps/web/src/routes/listen/[token]/+page.svelte new file mode 100644 index 0000000..653f38e --- /dev/null +++ b/apps/web/src/routes/listen/[token]/+page.svelte @@ -0,0 +1,339 @@ + + + + {data ? `${data.track.name} — ${data.project.name}` : 'Music Hub'} + + +
+ {#if loading} +

Lädt…

+ {:else if passwordRequired} +
+

🔒 Geschützter Link

+

Bitte Passwort eingeben:

+ + + {#if error}

{error}

{/if} +
+ {:else if error} +

{error}

+ {:else if data} +
+

{data.project.name}

+

{data.track.name}

+ {#if data.version.label}

{data.version.label}

{/if} +
+ + c.timestampSeconds !== null) + .map((c) => ({ + id: c.id, + timestampSeconds: c.timestampSeconds!, + body: c.body, + userName: c.user?.name ?? c.guestName ?? 'Gast', + }))} + onTimeClick={(t) => (commentTimestamp = Math.round(t * 10) / 10)} + /> + + {#if data.downloadUrl} + + {/if} + + {#if data.allowComments} +
+

Feedback hinterlassen

+ + {#if commentTimestamp !== null} + + bei {formatTime(commentTimestamp)} + + + {/if} + + +
+ {/if} + +
+

Kommentare ({data.comments.length})

+ {#each data.comments.filter((c) => !c.parentId) as c} +
+
+ {c.user?.name ?? c.guestName ?? 'Gast'} + {#if !c.user}Gast{/if} + {#if c.timestampSeconds !== null} + + {/if} +
+

{c.body}

+
+ {/each} + {#if data.comments.length === 0} +

Noch keine Kommentare.

+ {/if} +
+ +
+

Geteilt über Music Hub

+
+ {/if} +
+ + diff --git a/apps/web/src/routes/projects/[projectId]/tracks/[trackId]/+page.svelte b/apps/web/src/routes/projects/[projectId]/tracks/[trackId]/+page.svelte index 8697368..cc7adf9 100644 --- a/apps/web/src/routes/projects/[projectId]/tracks/[trackId]/+page.svelte +++ b/apps/web/src/routes/projects/[projectId]/tracks/[trackId]/+page.svelte @@ -11,6 +11,8 @@ import ABCompare from '$lib/components/audio/ABCompare.svelte'; import VersionInfo from './components/VersionInfo.svelte'; import VersionHistory from './components/VersionHistory.svelte'; + import VersionGraph from './components/VersionGraph.svelte'; + import ShareModal from './components/ShareModal.svelte'; import CommentSection from './components/CommentSection.svelte'; type Version = { @@ -22,6 +24,18 @@ originalFileName: string; duration: number | null; createdAt: string; + parentVersionId?: string | null; + branchLabel?: string | null; + }; + + type GraphNode = { + id: string; + parentVersionId: string | null; + branchLabel: string | null; + versionNumber: number; + label: string | null; + status: string; + createdAt: string; }; type Comment = { @@ -31,7 +45,8 @@ parentId: string | null; resolvedAt: string | null; createdAt: string; - user: { id: string; name: string; avatarUrl: string | null }; + guestName?: string | null; + user: { id: string; name: string; avatarUrl: string | null } | null; }; const projectId = $page.params.projectId!; @@ -49,6 +64,11 @@ let playerRef = $state(); let compareVersion = $state(null); let compareStreamUrl = $state(''); + let graphNodes = $state([]); + let viewMode = $state<'list' | 'graph'>('list'); + let branchFromId = $state(null); + let branchLabelInput = $state(''); + let shareOpen = $state(false); const canUpload = $derived(role === 'owner' || role.includes('engineer')); const canApprove = $derived(['owner', 'artist', 'label', 'management'].includes(role)); @@ -56,15 +76,17 @@ onMount(async () => { try { - const [projectRes, trackVersions, tracksRes] = await Promise.all([ + const [projectRes, trackVersions, tracksRes, treeRes] = await Promise.all([ api.get<{ project: any; role: string }>(`/projects/${projectId}`), api.get<{ versions: Version[] }>(`/versions/track/${trackId}`), api.get<{ tracks: { id: string; name: string }[] }>(`/tracks/project/${projectId}`), + api.get<{ nodes: GraphNode[] }>(`/versions/track/${trackId}/tree`), ]); role = projectRes.role; trackName = tracksRes.tracks.find((t) => t.id === trackId)?.name || ''; versions = trackVersions.versions; + graphNodes = treeRes.nodes; if (versions.length > 0) await selectVersion(versions[0]); } finally { @@ -83,11 +105,28 @@ } async function loadVersions() { - const res = await api.get<{ versions: Version[] }>(`/versions/track/${trackId}`); + const [res, treeRes] = await Promise.all([ + api.get<{ versions: Version[] }>(`/versions/track/${trackId}`), + api.get<{ nodes: GraphNode[] }>(`/versions/track/${trackId}/tree`), + ]); versions = res.versions; + graphNodes = treeRes.nodes; if (versions.length > 0) await selectVersion(versions[0]); } + async function handlePromote() { + if (!selectedVersion) return; + await api.post(`/versions/${selectedVersion.id}/promote`); + toastSuccess('Version übernommen'); + await loadVersions(); + } + + function startBranch(id: string) { + branchFromId = id; + branchLabelInput = ''; + showUpload = true; + } + async function handleApprove() { if (!selectedVersion) return; await api.post(`/versions/${selectedVersion.id}/approve`); @@ -163,7 +202,7 @@ id: c.id, timestampSeconds: c.timestampSeconds!, body: c.body, - userName: c.user.name, + userName: c.user?.name ?? c.guestName ?? 'Gast', }))} onTimeClick={(time) => commentTimestamp = Math.round(time * 10) / 10} /> @@ -178,13 +217,21 @@
{#if canUpload} - {/if} + + {#if canApprove && selectedVersion.branchLabel} + + {/if} {#if versions.length > 1} + +
+ {/if} + { showUpload = false; branchFromId = null; loadVersions(); toastSuccess('Version uploaded'); }} + /> {/if} {#if loading} @@ -248,11 +311,34 @@ /> {/if} - + {#if versions.length > 1} +
+ + +
+ {/if} + + {#if viewMode === 'graph'} + { + const v = versions.find((v) => v.id === id); + if (v) selectVersion(v); + }} + onBranch={canUpload ? startBranch : undefined} + /> + {:else} + + {/if} + + {#if selectedVersion} + + {/if} diff --git a/apps/web/src/routes/projects/[projectId]/tracks/[trackId]/components/VersionGraph.svelte b/apps/web/src/routes/projects/[projectId]/tracks/[trackId]/components/VersionGraph.svelte new file mode 100644 index 0000000..5797a9f --- /dev/null +++ b/apps/web/src/routes/projects/[projectId]/tracks/[trackId]/components/VersionGraph.svelte @@ -0,0 +1,170 @@ + + +
+

Version Graph

+ {#if nodes.length === 0} +

Noch keine Versionen.

+ {:else} + + + {#each layout.positions as p} + {#if p.node.parentVersionId} + {@const parent = layout.positions.find((q) => q.node.id === p.node.parentVersionId)} + {#if parent} + {@const a = pos(parent.col, parent.row)} + {@const b = pos(p.col, p.row)} + + {/if} + {/if} + {/each} + + + {#each layout.positions as p} + {@const c = pos(p.col, p.row)} + onSelect(p.node.id)} + role="button" + tabindex="0" + > + + + {p.node.versionNumber} + + + {p.node.label || p.node.branchLabel || (p.col === 0 ? 'main' : 'branch')} + + + {/each} + + {#if onBranch && selectedId} + + {/if} + {/if} +
+ + diff --git a/packages/db/src/migrations/0001_many_sir_ram.sql b/packages/db/src/migrations/0001_many_sir_ram.sql new file mode 100644 index 0000000..fd4239b --- /dev/null +++ b/packages/db/src/migrations/0001_many_sir_ram.sql @@ -0,0 +1,21 @@ +CREATE TABLE "share_links" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "version_id" uuid NOT NULL, + "token" varchar(64) NOT NULL, + "created_by_id" uuid NOT NULL, + "expires_at" timestamp, + "allow_comments" boolean DEFAULT true NOT NULL, + "allow_download" boolean DEFAULT false NOT NULL, + "password_hash" varchar(255), + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "share_links_token_unique" UNIQUE("token") +); +--> statement-breakpoint +ALTER TABLE "versions" DROP CONSTRAINT "versions_track_id_version_number_unique";--> statement-breakpoint +ALTER TABLE "comments" ALTER COLUMN "user_id" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "versions" ADD COLUMN "parent_version_id" uuid;--> statement-breakpoint +ALTER TABLE "versions" ADD COLUMN "branch_label" varchar(100);--> statement-breakpoint +ALTER TABLE "comments" ADD COLUMN "guest_name" varchar(100);--> statement-breakpoint +ALTER TABLE "share_links" ADD CONSTRAINT "share_links_version_id_versions_id_fk" FOREIGN KEY ("version_id") REFERENCES "public"."versions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "share_links" ADD CONSTRAINT "share_links_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "versions" ADD CONSTRAINT "versions_parent_version_id_versions_id_fk" FOREIGN KEY ("parent_version_id") REFERENCES "public"."versions"("id") ON DELETE set null ON UPDATE no action; \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0001_snapshot.json b/packages/db/src/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..b3bb485 --- /dev/null +++ b/packages/db/src/migrations/meta/0001_snapshot.json @@ -0,0 +1,885 @@ +{ + "id": "7e1d45fa-02c2-43ae-96f3-d5c8367c15ed", + "prevId": "4e5be5fd-2fae-43d2-8273-5a372d714cc5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.magic_links": { + "name": "magic_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "magic_links_token_unique": { + "name": "magic_links_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_hash_unique": { + "name": "sessions_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_members": { + "name": "project_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "project_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "can_upload": { + "name": "can_upload", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "can_comment": { + "name": "can_comment", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "can_approve": { + "name": "can_approve", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "invited_at": { + "name": "invited_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_members_project_id_projects_id_fk": { + "name": "project_members_project_id_projects_id_fk", + "tableFrom": "project_members", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_members_user_id_users_id_fk": { + "name": "project_members_user_id_users_id_fk", + "tableFrom": "project_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_members_project_id_user_id_unique": { + "name": "project_members_project_id_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_image_url": { + "name": "cover_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_created_by_id_users_id_fk": { + "name": "projects_created_by_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tracks": { + "name": "tracks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "tracks_project_id_projects_id_fk": { + "name": "tracks_project_id_projects_id_fk", + "tableFrom": "tracks", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tracks_created_by_id_users_id_fk": { + "name": "tracks_created_by_id_users_id_fk", + "tableFrom": "tracks", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.versions": { + "name": "versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "track_id": { + "name": "track_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version_number": { + "name": "version_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "version_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'uploaded'" + }, + "parent_version_id": { + "name": "parent_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "branch_label": { + "name": "branch_label", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "original_file_name": { + "name": "original_file_name", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "sample_rate": { + "name": "sample_rate", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "bit_depth": { + "name": "bit_depth", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "original_file_key": { + "name": "original_file_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream_file_key": { + "name": "stream_file_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "waveform_data_key": { + "name": "waveform_data_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "versions_track_id_tracks_id_fk": { + "name": "versions_track_id_tracks_id_fk", + "tableFrom": "versions", + "tableTo": "tracks", + "columnsFrom": [ + "track_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "versions_parent_version_id_versions_id_fk": { + "name": "versions_parent_version_id_versions_id_fk", + "tableFrom": "versions", + "tableTo": "versions", + "columnsFrom": [ + "parent_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "versions_created_by_id_users_id_fk": { + "name": "versions_created_by_id_users_id_fk", + "tableFrom": "versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comments": { + "name": "comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "version_id": { + "name": "version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "guest_name": { + "name": "guest_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp_seconds": { + "name": "timestamp_seconds", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "comments_version_id_versions_id_fk": { + "name": "comments_version_id_versions_id_fk", + "tableFrom": "comments", + "tableTo": "versions", + "columnsFrom": [ + "version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_user_id_users_id_fk": { + "name": "comments_user_id_users_id_fk", + "tableFrom": "comments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.share_links": { + "name": "share_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "version_id": { + "name": "version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "allow_comments": { + "name": "allow_comments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "allow_download": { + "name": "allow_download", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "share_links_version_id_versions_id_fk": { + "name": "share_links_version_id_versions_id_fk", + "tableFrom": "share_links", + "tableTo": "versions", + "columnsFrom": [ + "version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "share_links_created_by_id_users_id_fk": { + "name": "share_links_created_by_id_users_id_fk", + "tableFrom": "share_links", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "share_links_token_unique": { + "name": "share_links_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.project_role": { + "name": "project_role", + "schema": "public", + "values": [ + "owner", + "recording_engineer", + "mixing_engineer", + "mastering_engineer", + "artist", + "label", + "management", + "viewer" + ] + }, + "public.version_status": { + "name": "version_status", + "schema": "public", + "values": [ + "uploaded", + "processing", + "ready", + "approved", + "rejected" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index c0adc18..4b0e1ee 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1775122377765, "tag": "0000_magenta_apocalypse", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1775571497577, + "tag": "0001_many_sir_ram", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/comments.ts b/packages/db/src/schema/comments.ts index 21a996d..91b9053 100644 --- a/packages/db/src/schema/comments.ts +++ b/packages/db/src/schema/comments.ts @@ -1,4 +1,4 @@ -import { pgTable, uuid, text, real, timestamp } from 'drizzle-orm/pg-core'; +import { pgTable, uuid, varchar, text, real, timestamp } from 'drizzle-orm/pg-core'; import { users } from './users.js'; import { versions } from './tracks.js'; @@ -7,9 +7,8 @@ export const comments = pgTable('comments', { versionId: uuid('version_id') .references(() => versions.id, { onDelete: 'cascade' }) .notNull(), - userId: uuid('user_id') - .references(() => users.id) - .notNull(), + userId: uuid('user_id').references(() => users.id), + guestName: varchar('guest_name', { length: 100 }), body: text('body').notNull(), timestampSeconds: real('timestamp_seconds'), parentId: uuid('parent_id'), diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index dd8f410..e56c32c 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -3,3 +3,4 @@ export * from './auth.js'; export * from './projects.js'; export * from './tracks.js'; export * from './comments.js'; +export * from './shareLinks.js'; diff --git a/packages/db/src/schema/shareLinks.ts b/packages/db/src/schema/shareLinks.ts new file mode 100644 index 0000000..4ae46cc --- /dev/null +++ b/packages/db/src/schema/shareLinks.ts @@ -0,0 +1,19 @@ +import { pgTable, uuid, varchar, boolean, timestamp } from 'drizzle-orm/pg-core'; +import { users } from './users.js'; +import { versions } from './tracks.js'; + +export const shareLinks = pgTable('share_links', { + id: uuid('id').defaultRandom().primaryKey(), + versionId: uuid('version_id') + .references(() => versions.id, { onDelete: 'cascade' }) + .notNull(), + token: varchar('token', { length: 64 }).notNull().unique(), + createdById: uuid('created_by_id') + .references(() => users.id) + .notNull(), + expiresAt: timestamp('expires_at'), + allowComments: boolean('allow_comments').default(true).notNull(), + allowDownload: boolean('allow_download').default(false).notNull(), + passwordHash: varchar('password_hash', { length: 255 }), + createdAt: timestamp('created_at').defaultNow().notNull(), +}); diff --git a/packages/db/src/schema/tracks.ts b/packages/db/src/schema/tracks.ts index 64a99fd..bc75cb7 100644 --- a/packages/db/src/schema/tracks.ts +++ b/packages/db/src/schema/tracks.ts @@ -8,8 +8,8 @@ import { bigint, real, timestamp, - unique, } from 'drizzle-orm/pg-core'; +import type { AnyPgColumn } from 'drizzle-orm/pg-core'; import { users } from './users.js'; import { projects } from './projects.js'; @@ -36,33 +36,34 @@ export const tracks = pgTable('tracks', { updatedAt: timestamp('updated_at').defaultNow().notNull(), }); -export const versions = pgTable( - 'versions', - { - id: uuid('id').defaultRandom().primaryKey(), - trackId: uuid('track_id') - .references(() => tracks.id, { onDelete: 'cascade' }) - .notNull(), - versionNumber: integer('version_number').notNull(), - label: varchar('label', { length: 100 }), - notes: text('notes'), - status: versionStatusEnum('status').default('uploaded').notNull(), +export const versions = pgTable('versions', { + id: uuid('id').defaultRandom().primaryKey(), + trackId: uuid('track_id') + .references(() => tracks.id, { onDelete: 'cascade' }) + .notNull(), + versionNumber: integer('version_number').notNull(), + label: varchar('label', { length: 100 }), + notes: text('notes'), + status: versionStatusEnum('status').default('uploaded').notNull(), - originalFileName: varchar('original_file_name', { length: 500 }).notNull(), - mimeType: varchar('mime_type', { length: 100 }).notNull(), - fileSize: bigint('file_size', { mode: 'number' }).notNull(), - duration: real('duration'), - sampleRate: integer('sample_rate'), - bitDepth: integer('bit_depth'), + parentVersionId: uuid('parent_version_id').references((): AnyPgColumn => versions.id, { + onDelete: 'set null', + }), + branchLabel: varchar('branch_label', { length: 100 }), - originalFileKey: text('original_file_key').notNull(), - streamFileKey: text('stream_file_key'), - waveformDataKey: text('waveform_data_key'), + originalFileName: varchar('original_file_name', { length: 500 }).notNull(), + mimeType: varchar('mime_type', { length: 100 }).notNull(), + fileSize: bigint('file_size', { mode: 'number' }).notNull(), + duration: real('duration'), + sampleRate: integer('sample_rate'), + bitDepth: integer('bit_depth'), - createdById: uuid('created_by_id') - .references(() => users.id) - .notNull(), - createdAt: timestamp('created_at').defaultNow().notNull(), - }, - (table) => [unique().on(table.trackId, table.versionNumber)], -); + originalFileKey: text('original_file_key').notNull(), + streamFileKey: text('stream_file_key'), + waveformDataKey: text('waveform_data_key'), + + createdById: uuid('created_by_id') + .references(() => users.id) + .notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), +}); diff --git a/packages/shared/src/validation/track.ts b/packages/shared/src/validation/track.ts index d2ba0f2..75036b9 100644 --- a/packages/shared/src/validation/track.ts +++ b/packages/shared/src/validation/track.ts @@ -24,9 +24,27 @@ export const createVersionSchema = z.object({ originalFileName: z.string().min(1), mimeType: z.string().min(1), fileSize: z.number().int().positive(), + parentVersionId: z.string().uuid().optional(), + branchLabel: z.string().max(100).optional(), +}); + +export const createShareLinkSchema = z.object({ + expiresAt: z.string().datetime().optional(), + allowComments: z.boolean().optional(), + allowDownload: z.boolean().optional(), + password: z.string().min(1).max(255).optional(), +}); + +export const guestCommentSchema = z.object({ + body: z.string().min(1).max(5000), + timestampSeconds: z.number().nonnegative().optional(), + parentId: z.string().uuid().optional(), + guestName: z.string().min(1).max(100), }); export type CreateTrackInput = z.infer; export type UpdateTrackInput = z.infer; export type RequestUploadUrlInput = z.infer; export type CreateVersionInput = z.infer; +export type CreateShareLinkInput = z.infer; +export type GuestCommentInput = z.infer;