diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..baf81cf --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,45 @@ +# Music Hub + +Webapp für Label-Kollaboration. Stack: SvelteKit + Hono + Postgres. + +## Aktueller Stand + + + +**Sprint / Phase:** Deploy + erster Klienten-Test + +**Zuletzt implementiert:** +- App live auf hub.mydrugismusic.com (Registrierung, Login funktionieren) +- Coolify-Deploy via Webhook-Script (kein UI nötig, im Memory dokumentiert) +- DATABASE_URL auf public port umgestellt (interner Coolify-Hostname war nicht erreichbar) +- README geschrieben und gepusht + +**Als nächstes:** +- RESEND_API_KEY setzen → echter E-Mail-Versand +- App-Bugs fixen (User: „man kann quasi nichts machen außer Profil/Karte") +- DB `is_public` nach Tests wieder deaktivieren + +**Offene Punkte:** +- Interner Coolify-Netzwerkfehler (API→DB via UUID-Hostname) ungeklärt + +## Decisions + +`docs/decisions/` — Architecture Decision Records für nicht-offensichtliche Entscheidungen. +Template: `~/.claude/templates/adr.md` +Anlegen wenn: Alternative verworfen, Constraint akzeptiert, Richtungsentscheidung getroffen. + +## Specs + +`specs/` — ein File pro Sprint oder Feature, bevor Code geschrieben wird. +Template: `~/.claude/templates/spec.md` + +Konvention: +- Neues Sprint/Feature → erst `specs/sprint-N.md` oder `specs/feature-name.md` anlegen +- Kanban-Task verlinkt auf die Spec-Datei +- Aktive Spec steht im `## Aktueller Stand` + +## Kanban + +Board-ID: `cfddb658-6f5b-4d36-b311-369307a5fc51` + +Konvention: Bei Session-Start `get-board-info` aufrufen und offene Tasks zeigen. Aktive Tasks nach In Progress ziehen, erledigte nach Done. diff --git a/apps/api/package.json b/apps/api/package.json index 316f8a3..95b218a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -14,6 +14,7 @@ "@music-hub/db": "workspace:*", "@music-hub/shared": "workspace:*", "drizzle-orm": "^0.44", + "fflate": "^0.8.2", "hono": "^4", "resend": "^6.10.0" } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index bfcae6b..ebda7c3 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -11,6 +11,7 @@ import { shareRoutes } from './routes/share.js'; import { uploadRoutes } from './routes/uploads.js'; import { activityRoutes } from './routes/activity.js'; import { onboardingRoutes } from './routes/onboarding.js'; +import { stemRoutes } from './routes/stems.js'; import type { AppEnv } from './types.js'; const db = createDb(process.env.DATABASE_URL!); @@ -104,7 +105,8 @@ const app = new Hono() .route('/share', shareRoutes) .route('/uploads', uploadRoutes) .route('/activity', activityRoutes) - .route('/onboarding', onboardingRoutes); + .route('/onboarding', onboardingRoutes) + .route('/stems', stemRoutes); const port = parseInt(process.env.PORT || '3000'); console.log(`Music Hub API running on port ${port}`); diff --git a/apps/api/src/routes/stems.ts b/apps/api/src/routes/stems.ts new file mode 100644 index 0000000..10768c9 --- /dev/null +++ b/apps/api/src/routes/stems.ts @@ -0,0 +1,164 @@ +import { Hono } from 'hono'; +import { zValidator } from '@hono/zod-validator'; +import { eq, and, asc } from 'drizzle-orm'; +import { requestStemUploadUrlSchema, createStemSchema } from '@music-hub/shared'; +import { tracks, stems, projectMembers } from '@music-hub/db'; +import { requireAuth } from '../middleware/auth.js'; +import { createUploadUrl, getObjectBuffer, deleteObject } from '../storage/s3.js'; +import { zipSync } from 'fflate'; +import type { AppEnv } from '../types.js'; + +export const stemRoutes = new Hono() + .use('*', requireAuth) + + // List stems 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 trackStems = await db + .select() + .from(stems) + .where(eq(stems.trackId, trackId)) + .orderBy(asc(stems.sortOrder), asc(stems.createdAt)); + + return c.json({ stems: trackStems }); + }) + + // Request presigned upload URL + .post('/track/:trackId/upload-url', zValidator('json', requestStemUploadUrlSchema), 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 stemId = crypto.randomUUID(); + const fileKey = `projects/${track.projectId}/tracks/${trackId}/stems/${stemId}/${fileName}`; + const uploadUrl = await createUploadUrl(fileKey, mimeType, fileSize); + + return c.json({ uploadUrl, fileKey, stemId }); + }) + + // Register stem after upload + .post('/track/:trackId', zValidator('json', createStemSchema), 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); + + 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 [stem] = await db + .insert(stems) + .values({ + trackId, + name: input.name, + originalFileName: input.originalFileName, + mimeType: input.mimeType, + fileSize: input.fileSize, + fileKey: input.fileKey, + createdById: userId, + }) + .returning(); + + return c.json({ stem }, 201); + }) + + // Delete stem + .delete('/:id', async (c) => { + const db = c.get('db'); + const userId = c.get('userId'); + const stemId = c.req.param('id'); + + const [stem] = await db.select().from(stems).where(eq(stems.id, stemId)).limit(1); + if (!stem) return c.json({ error: 'Not found' }, 404); + + const [track] = await db.select().from(tracks).where(eq(tracks.id, stem.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.role !== 'owner' && stem.createdById !== userId)) { + return c.json({ error: 'Forbidden' }, 403); + } + + await deleteObject(stem.fileKey); + await db.delete(stems).where(eq(stems.id, stemId)); + return c.json({ message: 'Stem deleted' }); + }) + + // Download all stems as ZIP + .get('/track/:trackId/download-zip', async (c) => { + const db = c.get('db'); + const userId = c.get('userId'); + const trackId = c.req.param('trackId'); + + const [track] = await db.select().from(tracks).where(eq(tracks.id, trackId)).limit(1); + if (!track) return c.json({ error: 'Not found' }, 404); + + const [membership] = await db + .select() + .from(projectMembers) + .where(and(eq(projectMembers.projectId, track.projectId), eq(projectMembers.userId, userId))) + .limit(1); + if (!membership) return c.json({ error: 'Forbidden' }, 403); + + const trackStems = await db + .select() + .from(stems) + .where(eq(stems.trackId, trackId)) + .orderBy(asc(stems.sortOrder), asc(stems.createdAt)); + + if (trackStems.length === 0) return c.json({ error: 'No stems found' }, 404); + + // Download all files and build ZIP + const files: Record = {}; + await Promise.all( + trackStems.map(async (stem) => { + const buf = await getObjectBuffer(stem.fileKey); + files[stem.originalFileName] = buf; + }), + ); + + const zipped = zipSync(files); + const zipName = `${track.name.replace(/[^a-z0-9]/gi, '-').toLowerCase()}-stems.zip`; + + return new Response(zipped, { + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="${zipName}"`, + 'Content-Length': String(zipped.length), + }, + }); + }); diff --git a/apps/api/src/storage/s3.ts b/apps/api/src/storage/s3.ts index bef8878..e12067c 100644 --- a/apps/api/src/storage/s3.ts +++ b/apps/api/src/storage/s3.ts @@ -40,6 +40,12 @@ export async function createDownloadUrl(key: string, expiresIn = 3600): Promise< return getSignedUrl(s3, command, { expiresIn }); } +export async function getObjectBuffer(key: string): Promise { + const command = new GetObjectCommand({ Bucket: bucket, Key: key }); + const response = await s3.send(command); + return response.Body!.transformToByteArray(); +} + export async function deleteObject(key: string): Promise { const command = new DeleteObjectCommand({ Bucket: bucket, diff --git a/apps/web/src/lib/components/audio/StemUploadDropzone.svelte b/apps/web/src/lib/components/audio/StemUploadDropzone.svelte new file mode 100644 index 0000000..73b7955 --- /dev/null +++ b/apps/web/src/lib/components/audio/StemUploadDropzone.svelte @@ -0,0 +1,256 @@ + + +
+
!uploading && document.getElementById(`stem-input-${trackId}`)?.click()} + onkeydown={(e) => e.key === 'Enter' && !uploading && document.getElementById(`stem-input-${trackId}`)?.click()} + > + +
+ +

STEMs hier ablegen oder klicken

+ Mehrere Dateien gleichzeitig möglich · WAV, FLAC, AIFF · max 500 MB +
+
+ + {#if files.length > 0} +
+ {#each files as f} +
+ {f.name} + {#if f.error} + {f.error} + {:else} +
+
+
+ {f.progress}% + {/if} +
+ {/each} +
+ {/if} + + {#if globalError} +

{globalError}

+ {/if} +
+ + diff --git a/apps/web/src/lib/components/ui/Icon.svelte b/apps/web/src/lib/components/ui/Icon.svelte index c72535e..790418c 100644 --- a/apps/web/src/lib/components/ui/Icon.svelte +++ b/apps/web/src/lib/components/ui/Icon.svelte @@ -7,7 +7,7 @@ | 'download' | 'upload' | 'share' | 'plus' | 'check' | 'x' | 'close' | 'chevron-down' | 'chevron-right' | 'more' | 'home' | 'panel' | 'panel-off' | 'git-branch' | 'arrow-up' | 'compare' | 'comment' | 'lock' | 'link' - | 'settings' | 'logout' | 'list' | 'graph' | 'menu' | 'search'; + | 'settings' | 'logout' | 'list' | 'graph' | 'menu' | 'search' | 'music'; let { name, @@ -134,6 +134,10 @@ {:else if name === 'search'} + {:else if name === 'music'} + + + {/if} diff --git a/apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/+page.svelte b/apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/+page.svelte index 8d0f386..3873a80 100644 --- a/apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/+page.svelte +++ b/apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/+page.svelte @@ -23,6 +23,7 @@ import VersionGraph from './components/VersionGraph.svelte'; import ShareModal from './components/ShareModal.svelte'; import CommentSection from './components/CommentSection.svelte'; + import StemList from './components/StemList.svelte'; type Version = { id: string; @@ -85,7 +86,9 @@ let branchFromId = $state(null); let branchLabelInput = $state(''); let shareOpen = $state(false); - let panelTab = $state<'versions' | 'comments'>('versions'); + type Stem = { id: string; name: string; originalFileName: string; mimeType: string; fileSize: number; createdAt: string; createdById: string }; + let stems = $state([]); + let panelTab = $state<'versions' | 'comments' | 'stems'>('versions'); let panelOpen = $state(true); let editVersionOpen = $state(false); let editVersionLabel = $state(''); @@ -98,11 +101,12 @@ onMount(async () => { try { - const [projectRes, trackVersions, tracksRes, treeRes] = await Promise.all([ + const [projectRes, trackVersions, tracksRes, treeRes, stemsRes] = await Promise.all([ api.get<{ project: { name: string }; role: string }>(`/projects/${projectId}`), api.get<{ versions: Version[] }>(`/versions/track/${trackId}`), api.get<{ tracks: { id: string; name: string; coverUrl: string | null; status: TrackStatus; section: string | null }[] }>(`/tracks/project/${projectId}`), api.get<{ nodes: GraphNode[] }>(`/versions/track/${trackId}/tree`), + api.get<{ stems: Stem[] }>(`/stems/track/${trackId}`), ]); projectName = projectRes.project.name; @@ -114,6 +118,7 @@ trackSection = t?.section ?? null; versions = trackVersions.versions; graphNodes = treeRes.nodes; + stems = stemsRes.stems; if (versions.length > 0) await selectVersion(versions[0]); } finally { @@ -502,6 +507,9 @@ +
@@ -519,6 +527,14 @@ onBranch={canUpload ? startBranch : undefined} /> {/if} + {:else if panelTab === 'stems'} + {:else if selectedVersion} + import { api } from '$lib/api/client.js'; + import { toastSuccess } from '$lib/stores/toast.js'; + import Icon from '$lib/components/ui/Icon.svelte'; + import Button from '$lib/components/ui/Button.svelte'; + import StemUploadDropzone from '$lib/components/audio/StemUploadDropzone.svelte'; + + type Stem = { + id: string; + name: string; + originalFileName: string; + mimeType: string; + fileSize: number; + createdAt: string; + createdById: string; + }; + + let { + trackId, + stems = $bindable([]), + canUpload, + currentUserId, + role, + }: { + trackId: string; + stems: Stem[]; + canUpload: boolean; + currentUserId: string | null; + role: string; + } = $props(); + + let showUpload = $state(false); + let deleting = $state(null); + + function formatSize(bytes: number) { + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } + + async function loadStems() { + const res = await api.get<{ stems: Stem[] }>(`/stems/track/${trackId}`); + stems = res.stems; + } + + async function downloadZip() { + const res = await fetch(`/api/v1/stems/track/${trackId}/download-zip`, { + credentials: 'include', + }); + if (!res.ok) return; + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = res.headers.get('content-disposition')?.match(/filename="(.+?)"/)?.[1] ?? 'stems.zip'; + a.click(); + URL.revokeObjectURL(url); + } + + async function deleteStem(id: string, name: string) { + if (!confirm(`Stem "${name}" wirklich löschen?`)) return; + deleting = id; + try { + await api.delete(`/stems/${id}`); + stems = stems.filter((s) => s.id !== id); + toastSuccess('Stem gelöscht'); + } finally { + deleting = null; + } + } + + +
+
+ {#if stems.length > 0} + + {/if} + {#if canUpload} + + {/if} +
+ + {#if showUpload} +
+ { + await loadStems(); + toastSuccess('STEMs hochgeladen'); + showUpload = false; + }} + /> +
+ {/if} + + {#if stems.length === 0 && !showUpload} +

Noch keine STEMs hochgeladen.

+ {:else} +
    + {#each stems as stem (stem.id)} +
  • + +
    + {stem.name} + {stem.originalFileName} · {formatSize(stem.fileSize)} +
    + {#if role === 'owner' || stem.createdById === currentUserId} + + {/if} +
  • + {/each} +
+ {/if} +
+ + diff --git a/bun.lock b/bun.lock index 47b332f..e86b1ef 100644 --- a/bun.lock +++ b/bun.lock @@ -20,6 +20,7 @@ "@music-hub/db": "workspace:*", "@music-hub/shared": "workspace:*", "drizzle-orm": "^0.44", + "fflate": "^0.8.2", "hono": "^4", "resend": "^6.10.0", }, @@ -474,6 +475,8 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], diff --git a/packages/db/src/migrations/0006_brown_lily_hollister.sql b/packages/db/src/migrations/0006_brown_lily_hollister.sql new file mode 100644 index 0000000..1dbf719 --- /dev/null +++ b/packages/db/src/migrations/0006_brown_lily_hollister.sql @@ -0,0 +1,15 @@ +CREATE TABLE "stems" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "track_id" uuid NOT NULL, + "name" varchar(255) NOT NULL, + "original_file_name" varchar(500) NOT NULL, + "mime_type" varchar(100) NOT NULL, + "file_size" bigint NOT NULL, + "file_key" text NOT NULL, + "sort_order" integer DEFAULT 0 NOT NULL, + "created_by_id" uuid NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "stems" ADD CONSTRAINT "stems_track_id_tracks_id_fk" FOREIGN KEY ("track_id") REFERENCES "public"."tracks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "stems" ADD CONSTRAINT "stems_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0006_snapshot.json b/packages/db/src/migrations/meta/0006_snapshot.json new file mode 100644 index 0000000..68915bf --- /dev/null +++ b/packages/db/src/migrations/meta/0006_snapshot.json @@ -0,0 +1,1030 @@ +{ + "id": "fbe53e3c-91a1-40a5-8c04-4f58c63c7f92", + "prevId": "4cc81b0e-e487-4b6f-8f35-a4cff42c2de6", + "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 + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "artist": { + "name": "artist", + "type": "varchar(255)", + "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.stems": { + "name": "stems", + "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 + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "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 + }, + "file_key": { + "name": "file_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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()" + } + }, + "indexes": {}, + "foreignKeys": { + "stems_track_id_tracks_id_fk": { + "name": "stems_track_id_tracks_id_fk", + "tableFrom": "stems", + "tableTo": "tracks", + "columnsFrom": [ + "track_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "stems_created_by_id_users_id_fk": { + "name": "stems_created_by_id_users_id_fk", + "tableFrom": "stems", + "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 + }, + "cover_image_url": { + "name": "cover_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "track_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'in_progress'" + }, + "section": { + "name": "section", + "type": "varchar(100)", + "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.track_status": { + "name": "track_status", + "schema": "public", + "values": [ + "sketch", + "in_progress", + "final", + "released" + ] + }, + "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 5c9bd19..571a469 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1776012912970, "tag": "0005_rare_triathlon", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1776094119472, + "tag": "0006_brown_lily_hollister", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/tracks.ts b/packages/db/src/schema/tracks.ts index f573df6..7a55c92 100644 --- a/packages/db/src/schema/tracks.ts +++ b/packages/db/src/schema/tracks.ts @@ -77,3 +77,20 @@ export const versions = pgTable('versions', { .notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), }); + +export const stems = pgTable('stems', { + id: uuid('id').defaultRandom().primaryKey(), + trackId: uuid('track_id') + .references(() => tracks.id, { onDelete: 'cascade' }) + .notNull(), + name: varchar('name', { length: 255 }).notNull(), + originalFileName: varchar('original_file_name', { length: 500 }).notNull(), + mimeType: varchar('mime_type', { length: 100 }).notNull(), + fileSize: bigint('file_size', { mode: 'number' }).notNull(), + fileKey: text('file_key').notNull(), + sortOrder: integer('sort_order').default(0).notNull(), + 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 7ea2fef..6765582 100644 --- a/packages/shared/src/validation/track.ts +++ b/packages/shared/src/validation/track.ts @@ -53,6 +53,20 @@ export const updateVersionSchema = z.object({ branchLabel: z.string().max(100).nullable().optional(), }); +export const requestStemUploadUrlSchema = z.object({ + fileName: z.string().min(1), + mimeType: z.string().min(1), + fileSize: z.number().int().positive().max(MAX_FILE_SIZE), +}); + +export const createStemSchema = z.object({ + fileKey: z.string().min(1), + name: z.string().min(1).max(255), + originalFileName: z.string().min(1), + mimeType: z.string().min(1), + fileSize: z.number().int().positive(), +}); + export const createShareLinkSchema = z.object({ expiresAt: z.string().datetime().optional(), allowComments: z.boolean().optional(), @@ -75,3 +89,5 @@ export type UpdateVersionInput = z.infer; export type CreateShareLinkInput = z.infer; export type CoverUploadInput = z.infer; export type GuestCommentInput = z.infer; +export type RequestStemUploadUrlInput = z.infer; +export type CreateStemInput = z.infer;