Compare commits
2 Commits
ccd7ed3a93
...
c949d6b829
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c949d6b829 | ||
|
|
afcb818dd4 |
45
AGENTS.md
Normal file
45
AGENTS.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Music Hub
|
||||||
|
|
||||||
|
Webapp für Label-Kollaboration. Stack: SvelteKit + Hono + Postgres.
|
||||||
|
|
||||||
|
## Aktueller Stand
|
||||||
|
|
||||||
|
<!-- Zuletzt aktualisiert: 2026-04-13 via /save -->
|
||||||
|
|
||||||
|
**Sprint / Phase:** STEM-Feature + Bugfixes
|
||||||
|
|
||||||
|
**Zuletzt implementiert:**
|
||||||
|
- STEM-Support: Multi-File-Upload, ZIP-Download, StemList-Tab in Track-View
|
||||||
|
- Presigned URL fix: `ContentLength` entfernt (S3-Upload-Fehler)
|
||||||
|
- macOS Multi-Select fix: `accept="audio/*"` statt Extensions
|
||||||
|
- `/simplify`-Cleanup: async zip(), Null-Check, formatFileSize, Typ-Dedup
|
||||||
|
|
||||||
|
**Als nächstes:**
|
||||||
|
- STEM-Upload testen (Deploy läuft, noch nicht vom User bestätigt)
|
||||||
|
- RESEND_API_KEY setzen → echter E-Mail-Versand
|
||||||
|
- DB `is_public` nach Tests wieder deaktivieren
|
||||||
|
|
||||||
|
**Offene Punkte:**
|
||||||
|
- Upload-Fehler könnte noch S3-CORS sein (noch nicht ausgeschlossen)
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
`docs/decisions/` — Architecture Decision Records für nicht-offensichtliche Entscheidungen.
|
||||||
|
Template: `docs/templates/adr.md`
|
||||||
|
Anlegen wenn: Alternative verworfen, Constraint akzeptiert, Richtungsentscheidung getroffen.
|
||||||
|
|
||||||
|
## Specs
|
||||||
|
|
||||||
|
`specs/` — ein File pro Sprint oder Feature, bevor Code geschrieben wird.
|
||||||
|
Template: `docs/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.
|
||||||
45
CLAUDE.md
45
CLAUDE.md
@@ -1,45 +0,0 @@
|
|||||||
# Music Hub
|
|
||||||
|
|
||||||
Webapp für Label-Kollaboration. Stack: SvelteKit + Hono + Postgres.
|
|
||||||
|
|
||||||
## Aktueller Stand
|
|
||||||
|
|
||||||
<!-- Zuletzt aktualisiert: 2026-04-13 via /save -->
|
|
||||||
|
|
||||||
**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.
|
|
||||||
@@ -65,37 +65,9 @@ const app = new Hono<AppEnv>()
|
|||||||
})
|
})
|
||||||
.onError((err, c) => {
|
.onError((err, c) => {
|
||||||
console.error('Unhandled error:', err);
|
console.error('Unhandled error:', err);
|
||||||
return c.json({ error: err.message }, 500);
|
return c.json({ error: 'Internal server error' }, 500);
|
||||||
})
|
})
|
||||||
.get('/health', (c) => c.json({ status: 'ok' }))
|
.get('/health', (c) => c.json({ status: 'ok' }))
|
||||||
.get('/migrate', async (c) => {
|
|
||||||
try {
|
|
||||||
const fs = await import('fs');
|
|
||||||
const pathMod = await import('path');
|
|
||||||
const { sql: dsql } = await import('drizzle-orm');
|
|
||||||
const folder = pathMod.resolve(process.cwd(), 'packages/db/src/migrations');
|
|
||||||
const journal = JSON.parse(fs.readFileSync(pathMod.join(folder, 'meta', '_journal.json'), 'utf8'));
|
|
||||||
const results: string[] = [];
|
|
||||||
for (const entry of journal.entries) {
|
|
||||||
const sqlFile = pathMod.join(folder, `${entry.tag}.sql`);
|
|
||||||
if (!fs.existsSync(sqlFile)) { results.push(`skip: ${entry.tag} (not found)`); continue; }
|
|
||||||
const rawSql = fs.readFileSync(sqlFile, 'utf8');
|
|
||||||
const statements = rawSql.split('--> statement-breakpoint').map((s: string) => s.trim()).filter(Boolean);
|
|
||||||
for (const stmt of statements) {
|
|
||||||
try {
|
|
||||||
await db.execute(dsql.raw(stmt));
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err.message?.includes('already exists') || err.message?.includes('duplicate')) continue;
|
|
||||||
results.push(`error in ${entry.tag}: ${err.message?.slice(0, 150)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
results.push(`ok: ${entry.tag}`);
|
|
||||||
}
|
|
||||||
return c.json({ status: 'ok', results });
|
|
||||||
} catch (err: any) {
|
|
||||||
return c.json({ status: 'error', message: err.message }, 500);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.basePath('/api/v1')
|
.basePath('/api/v1')
|
||||||
.route('/auth', authRoutes)
|
.route('/auth', authRoutes)
|
||||||
.route('/projects', projectRoutes)
|
.route('/projects', projectRoutes)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ async function createSession(c: any, db: any, userId: string) {
|
|||||||
setCookie(c, 'session', sessionToken, {
|
setCookie(c, 'session', sessionToken, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
path: '/',
|
path: '/',
|
||||||
maxAge: 30 * 24 * 60 * 60,
|
maxAge: 30 * 24 * 60 * 60,
|
||||||
});
|
});
|
||||||
@@ -64,11 +65,12 @@ export const authRoutes = new Hono<AppEnv>()
|
|||||||
const db = c.get('db');
|
const db = c.get('db');
|
||||||
|
|
||||||
const token = generateToken();
|
const token = generateToken();
|
||||||
|
const tokenHash = await hashToken(token);
|
||||||
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 min
|
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 min
|
||||||
|
|
||||||
await db.insert(magicLinks).values({
|
await db.insert(magicLinks).values({
|
||||||
email,
|
email,
|
||||||
token,
|
token: tokenHash,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,10 +83,11 @@ export const authRoutes = new Hono<AppEnv>()
|
|||||||
const { token } = c.req.valid('json');
|
const { token } = c.req.valid('json');
|
||||||
const db = c.get('db');
|
const db = c.get('db');
|
||||||
|
|
||||||
|
const tokenHash = await hashToken(token);
|
||||||
const [link] = await db
|
const [link] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(magicLinks)
|
.from(magicLinks)
|
||||||
.where(eq(magicLinks.token, token))
|
.where(eq(magicLinks.token, tokenHash))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!link || link.expiresAt < new Date() || link.usedAt) {
|
if (!link || link.expiresAt < new Date() || link.usedAt) {
|
||||||
|
|||||||
@@ -158,15 +158,29 @@ export const commentRoutes = new Hono<AppEnv>()
|
|||||||
// Resolve comment
|
// Resolve comment
|
||||||
.post('/:id/resolve', async (c) => {
|
.post('/:id/resolve', async (c) => {
|
||||||
const db = c.get('db');
|
const db = c.get('db');
|
||||||
|
const userId = c.get('userId');
|
||||||
const commentId = c.req.param('id');
|
const commentId = c.req.param('id');
|
||||||
|
|
||||||
|
const [comment] = await db.select().from(comments).where(eq(comments.id, commentId)).limit(1);
|
||||||
|
if (!comment) return c.json({ error: 'Not found' }, 404);
|
||||||
|
|
||||||
|
const [version] = await db.select().from(versions).where(eq(versions.id, comment.versionId)).limit(1);
|
||||||
|
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 && !membership.canApprove)) {
|
||||||
|
return c.json({ error: 'Forbidden' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(comments)
|
.update(comments)
|
||||||
.set({ resolvedAt: new Date() })
|
.set({ resolvedAt: new Date() })
|
||||||
.where(eq(comments.id, commentId))
|
.where(eq(comments.id, commentId))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (!updated) return c.json({ error: 'Not found' }, 404);
|
|
||||||
|
|
||||||
return c.json({ comment: updated });
|
return c.json({ comment: updated });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ export const projectRoutes = new Hono<AppEnv>()
|
|||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(projectMembers)
|
.update(projectMembers)
|
||||||
.set({ role: newRole, ...defaults })
|
.set({ role: newRole, ...defaults })
|
||||||
.where(eq(projectMembers.id, memberId))
|
.where(and(eq(projectMembers.id, memberId), eq(projectMembers.projectId, projectId)))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
return c.json({ member: updated });
|
return c.json({ member: updated });
|
||||||
@@ -294,7 +294,7 @@ export const projectRoutes = new Hono<AppEnv>()
|
|||||||
return c.json({ error: 'Forbidden' }, 403);
|
return c.json({ error: 'Forbidden' }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.delete(projectMembers).where(eq(projectMembers.id, memberId));
|
await db.delete(projectMembers).where(and(eq(projectMembers.id, memberId), eq(projectMembers.projectId, projectId)));
|
||||||
return c.json({ message: 'Member removed' });
|
return c.json({ message: 'Member removed' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,23 @@ export const versionRoutes = new Hono<AppEnv>()
|
|||||||
const [track] = await db.select().from(tracks).where(eq(tracks.id, trackId)).limit(1);
|
const [track] = await db.select().from(tracks).where(eq(tracks.id, trackId)).limit(1);
|
||||||
if (!track) return c.json({ error: 'Not found' }, 404);
|
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 expectedPrefix = `projects/${track.projectId}/tracks/${trackId}/`;
|
||||||
|
if (!input.fileKey.startsWith(expectedPrefix)) {
|
||||||
|
return c.json({ error: 'Forbidden' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
// Get next version number
|
// Get next version number
|
||||||
const [latest] = await db
|
const [latest] = await db
|
||||||
.select({ maxVersion: sql<number>`coalesce(max(${versions.versionNumber}), 0)` })
|
.select({ maxVersion: sql<number>`coalesce(max(${versions.versionNumber}), 0)` })
|
||||||
@@ -269,16 +286,20 @@ export const versionRoutes = new Hono<AppEnv>()
|
|||||||
// Get stream URL
|
// Get stream URL
|
||||||
.get('/:id/stream-url', async (c) => {
|
.get('/:id/stream-url', async (c) => {
|
||||||
const db = c.get('db');
|
const db = c.get('db');
|
||||||
|
const userId = c.get('userId');
|
||||||
const versionId = c.req.param('id');
|
const versionId = c.req.param('id');
|
||||||
|
|
||||||
const [version] = await db
|
const [version] = await db.select().from(versions).where(eq(versions.id, versionId)).limit(1);
|
||||||
.select()
|
|
||||||
.from(versions)
|
|
||||||
.where(eq(versions.id, versionId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!version) return c.json({ error: 'Not found' }, 404);
|
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 key = version.streamFileKey || version.originalFileKey;
|
const key = version.streamFileKey || version.originalFileKey;
|
||||||
const url = await createDownloadUrl(key);
|
const url = await createDownloadUrl(key);
|
||||||
|
|
||||||
@@ -288,16 +309,20 @@ export const versionRoutes = new Hono<AppEnv>()
|
|||||||
// Get download URL
|
// Get download URL
|
||||||
.get('/:id/download-url', async (c) => {
|
.get('/:id/download-url', async (c) => {
|
||||||
const db = c.get('db');
|
const db = c.get('db');
|
||||||
|
const userId = c.get('userId');
|
||||||
const versionId = c.req.param('id');
|
const versionId = c.req.param('id');
|
||||||
|
|
||||||
const [version] = await db
|
const [version] = await db.select().from(versions).where(eq(versions.id, versionId)).limit(1);
|
||||||
.select()
|
|
||||||
.from(versions)
|
|
||||||
.where(eq(versions.id, versionId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!version) return c.json({ error: 'Not found' }, 404);
|
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 url = await createDownloadUrl(version.originalFileKey);
|
const url = await createDownloadUrl(version.originalFileKey);
|
||||||
return c.json({ url });
|
return c.json({ url });
|
||||||
})
|
})
|
||||||
@@ -305,17 +330,19 @@ export const versionRoutes = new Hono<AppEnv>()
|
|||||||
// Get waveform data
|
// Get waveform data
|
||||||
.get('/:id/waveform', async (c) => {
|
.get('/:id/waveform', async (c) => {
|
||||||
const db = c.get('db');
|
const db = c.get('db');
|
||||||
|
const userId = c.get('userId');
|
||||||
const versionId = c.req.param('id');
|
const versionId = c.req.param('id');
|
||||||
|
|
||||||
const [version] = await db
|
const [version] = await db.select().from(versions).where(eq(versions.id, versionId)).limit(1);
|
||||||
.select()
|
if (!version || !version.waveformDataKey) return c.json({ error: 'Not found' }, 404);
|
||||||
.from(versions)
|
|
||||||
.where(eq(versions.id, versionId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!version || !version.waveformDataKey) {
|
const [track] = await db.select().from(tracks).where(eq(tracks.id, version.trackId)).limit(1);
|
||||||
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 url = await createDownloadUrl(version.waveformDataKey);
|
const url = await createDownloadUrl(version.waveformDataKey);
|
||||||
return c.json({ url });
|
return c.json({ url });
|
||||||
|
|||||||
@@ -124,7 +124,7 @@
|
|||||||
accept="audio/*"
|
accept="audio/*"
|
||||||
multiple
|
multiple
|
||||||
onchange={handleFileSelect}
|
onchange={handleFileSelect}
|
||||||
hidden
|
style="position:absolute;width:1px;height:1px;opacity:0;pointer-events:none;"
|
||||||
/>
|
/>
|
||||||
<div class="dropzone-content">
|
<div class="dropzone-content">
|
||||||
<span class="icon"><Icon name="upload" size={24} /></span>
|
<span class="icon"><Icon name="upload" size={24} /></span>
|
||||||
|
|||||||
21
docs/templates/adr.md
vendored
Normal file
21
docs/templates/adr.md
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# ADR-[Nummer]: [Titel]
|
||||||
|
|
||||||
|
**Datum:** [YYYY-MM-DD]
|
||||||
|
**Status:** Proposed | Accepted | Deprecated | Superseded by ADR-[N]
|
||||||
|
|
||||||
|
## Kontext
|
||||||
|
|
||||||
|
Was war die Situation, warum musste eine Entscheidung getroffen werden?
|
||||||
|
|
||||||
|
## Entscheidung
|
||||||
|
|
||||||
|
Was wurde entschieden?
|
||||||
|
|
||||||
|
## Alternativen die verworfen wurden
|
||||||
|
|
||||||
|
- **[Alternative A]** — warum nicht
|
||||||
|
- **[Alternative B]** — warum nicht
|
||||||
|
|
||||||
|
## Konsequenzen
|
||||||
|
|
||||||
|
Was wird durch diese Entscheidung einfacher, was schwieriger?
|
||||||
29
docs/templates/spec.md
vendored
Normal file
29
docs/templates/spec.md
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# [Feature- oder Sprint-Name]
|
||||||
|
|
||||||
|
**Status:** Draft | Active | Done
|
||||||
|
**Repo:** [repo]
|
||||||
|
**Erstellt:** [YYYY-MM-DD]
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Ein Satz.
|
||||||
|
|
||||||
|
## Warum
|
||||||
|
|
||||||
|
Warum jetzt, warum das.
|
||||||
|
|
||||||
|
## In Scope
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Erfolgskriterien
|
||||||
|
|
||||||
|
- [ ]
|
||||||
|
|
||||||
|
## Implementierungsnotizen
|
||||||
|
|
||||||
|
<!-- Schlüsselentscheidungen, Constraints, offene Fragen -->
|
||||||
Reference in New Issue
Block a user