fixed migrations

This commit is contained in:
2025-11-10 22:51:01 +01:00
parent 7a10cd7f5d
commit edd1d34900
18 changed files with 1111 additions and 90 deletions

View File

@@ -25,7 +25,8 @@ export const audioFile = sqliteTable('audio_file', {
duration: real('duration'),
fileSize: integer('file_size'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
deletedAt: integer('deleted_at', { mode: 'timestamp' }) // Soft delete for audio files
deletedAt: integer('deleted_at', { mode: 'timestamp' }), // Soft delete for audio files
tags: text('tags').notNull().default('[]') // JSON array of tags for admin-only classification
});
export const inviteLink = sqliteTable('invite_link', {
@@ -35,7 +36,8 @@ export const inviteLink = sqliteTable('invite_link', {
isUsed: integer('is_used', { mode: 'boolean' }).notNull().default(false),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
usedAt: integer('used_at', { mode: 'timestamp' }),
deletedAt: integer('deleted_at', { mode: 'timestamp' })
deletedAt: integer('deleted_at', { mode: 'timestamp' }),
tags: text('tags').notNull().default('[]')
});
export const participant = sqliteTable('participant', {

View File

@@ -0,0 +1,53 @@
export function parseStoredTags(value) {
if (!value) return [];
try {
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
if (!Array.isArray(parsed)) return [];
const seen = new Set();
const clean = [];
for (const rawTag of parsed) {
const value = (typeof rawTag === 'string' ? rawTag : String(rawTag ?? '')).trim();
if (!value) continue;
const key = value.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
clean.push(value);
}
return clean;
} catch (err) {
console.warn('Failed to parse stored tags', err);
return [];
}
}
export function normalizeTagsInput(rawValue) {
if (!rawValue) return [];
const source = Array.isArray(rawValue)
? rawValue.join(',')
: String(rawValue);
const seen = new Set();
const tags = [];
for (const fragment of source.split(',')) {
const value = fragment.trim();
if (!value) continue;
const key = value.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
tags.push(value);
}
return tags;
}
export function serializeTags(tags) {
return JSON.stringify(Array.isArray(tags) ? tags : []);
}
export function matchesInviteTags(audioTags, inviteTags) {
if (!inviteTags || inviteTags.length === 0) return true;
if (!audioTags || audioTags.length === 0) return false;
const audioSet = new Set(audioTags.map((tag) => tag.toLowerCase()));
return inviteTags.some((tag) => audioSet.has(tag.toLowerCase()));
}

View File

@@ -2,9 +2,14 @@ import { fail } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js';
import { inviteLink, participant } from '$lib/server/db/schema.js';
import { eq, isNull } from 'drizzle-orm';
import { parseStoredTags, normalizeTagsInput, serializeTags } from '$lib/server/tag-utils.js';
export async function load() {
const invites = await db.select().from(inviteLink).where(isNull(inviteLink.deletedAt));
const rows = await db.select().from(inviteLink).where(isNull(inviteLink.deletedAt));
const invites = rows.map((invite) => ({
...invite,
tags: parseStoredTags(invite.tags)
}));
return {
invites
};
@@ -125,5 +130,49 @@ export const actions = {
message: 'Failed to delete invite link'
});
}
},
updateTags: async ({ request }) => {
const data = await request.formData();
const inviteId = data.get('inviteId');
const rawTags = data.get('tags') ?? '';
const removeTag = data.get('removeTag');
if (!inviteId) {
return fail(400, {
error: 'Missing invite id'
});
}
const records = await db
.select({ tags: inviteLink.tags })
.from(inviteLink)
.where(eq(inviteLink.id, inviteId))
.limit(1);
if (records.length === 0) {
return fail(404, { error: 'Invite link not found' });
}
let tags;
if (removeTag) {
const current = parseStoredTags(records[0].tags);
tags = current.filter(
(tag) => tag.toLowerCase() !== String(removeTag).trim().toLowerCase()
);
} else {
tags = normalizeTagsInput(rawTags);
}
try {
await db
.update(inviteLink)
.set({ tags: serializeTags(tags) })
.where(eq(inviteLink.id, inviteId));
return { updatedTags: true, inviteId, tags };
} catch (error) {
console.error('Error updating invite tags:', error);
return fail(500, { error: 'Failed to update tags' });
}
}
};
};

View File

@@ -148,16 +148,21 @@
>
Created
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Link
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Tags (admin)
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Actions
</th>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
@@ -200,6 +205,50 @@
</button>
</div>
</td>
<td class="px-6 py-4 text-sm text-gray-500">
<div class="flex flex-wrap gap-2">
{#if invite.tags?.length}
{#each invite.tags as tag (tag)}
<form
method="POST"
action="?/updateTags"
class="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700"
use:enhance
>
<input type="hidden" name="inviteId" value={invite.id} />
<input type="hidden" name="removeTag" value={tag} />
<span>{tag}</span>
<button
type="submit"
class="text-gray-500 hover:text-gray-800"
title={`Remove ${tag}`}
>
×
</button>
</form>
{/each}
{:else}
<span class="text-xs uppercase tracking-wide text-gray-400">No tags</span>
{/if}
</div>
<form method="POST" action="?/updateTags" class="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center">
<input type="hidden" name="inviteId" value={invite.id} />
<input
type="text"
name="tags"
value={invite.tags?.join(', ') ?? ''}
placeholder="beta, cohort-a"
class="w-full rounded-md border-gray-300 px-2 py-1 text-xs focus:border-indigo-500 focus:ring-indigo-500"
title="Comma-separated list. Leave blank to remove all tags"
/>
<button
type="submit"
class="inline-flex items-center justify-center rounded bg-gray-800 px-2 py-1 text-xs font-medium text-white hover:bg-gray-700"
>
Save
</button>
</form>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<form method="POST" action="?/delete" class="inline" use:enhance>
<input type="hidden" name="inviteId" value={invite.id} />
@@ -219,7 +268,7 @@
</tr>
{:else}
<tr>
<td colspan="5" class="px-6 py-4 text-center text-sm text-gray-500">
<td colspan="6" class="px-6 py-4 text-center text-sm text-gray-500">
No invite links created yet.
</td>
</tr>
@@ -228,4 +277,4 @@
</table>
</div>
</div>
</div>
</div>

View File

@@ -2,6 +2,7 @@ import { fail } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js';
import { audioFile } from '$lib/server/db/schema.js';
import { eq, isNull } from 'drizzle-orm';
import { parseStoredTags, normalizeTagsInput, serializeTags } from '$lib/server/tag-utils.js';
import { uploadToS3, deleteFromS3, generateAudioS3Key } from '$lib/server/s3.js';
import { exec } from 'child_process';
import { promisify } from 'util';
@@ -45,17 +46,23 @@ async function getAudioDuration(buffer) {
}
export async function load() {
const audioFiles = await db.select({
const rows = await db.select({
id: audioFile.id,
filename: audioFile.filename,
contentType: audioFile.contentType,
duration: audioFile.duration,
fileSize: audioFile.fileSize,
createdAt: audioFile.createdAt
createdAt: audioFile.createdAt,
tags: audioFile.tags
})
.from(audioFile)
.where(isNull(audioFile.deletedAt)); // Only show active audio files
const audioFiles = rows.map((audio) => ({
...audio,
tags: parseStoredTags(audio.tags)
}));
return {
audioFiles
};
@@ -196,4 +203,47 @@ export const actions = {
return fail(500, { error: 'Failed to rename audio file' });
}
}
,
updateTags: async ({ request }) => {
const data = await request.formData();
const audioFileId = data.get('audioFileId');
const rawTags = data.get('tags') ?? '';
const removeTag = data.get('removeTag');
if (!audioFileId) {
return fail(400, { error: 'Missing audio file id' });
}
const records = await db
.select({ tags: audioFile.tags })
.from(audioFile)
.where(eq(audioFile.id, audioFileId))
.limit(1);
if (records.length === 0) {
return fail(404, { error: 'Audio file not found' });
}
let tags;
if (removeTag) {
const currentTags = parseStoredTags(records[0].tags);
tags = currentTags.filter(
(tag) => tag.toLowerCase() !== String(removeTag).trim().toLowerCase()
);
} else {
tags = normalizeTagsInput(rawTags);
}
try {
await db
.update(audioFile)
.set({ tags: serializeTags(tags) })
.where(eq(audioFile.id, audioFileId));
return { updatedTags: true, audioFileId, tags };
} catch (error) {
console.error('Error updating audio tags:', error);
return fail(500, { error: 'Failed to update tags' });
}
}
};

View File

@@ -366,16 +366,21 @@
>
Duration
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Uploaded
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Tags (admin only)
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Actions
</th>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
@@ -416,6 +421,50 @@
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
{new Date(audio.createdAt).toLocaleDateString()}
</td>
<td class="px-6 py-4 text-sm text-gray-500">
<div class="flex flex-wrap gap-2">
{#if audio.tags?.length}
{#each audio.tags as tag (tag)}
<form
method="POST"
action="?/updateTags"
class="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700"
use:enhance
>
<input type="hidden" name="audioFileId" value={audio.id} />
<input type="hidden" name="removeTag" value={tag} />
<span>{tag}</span>
<button
type="submit"
class="text-gray-500 hover:text-gray-800"
title={`Remove ${tag}`}
>
×
</button>
</form>
{/each}
{:else}
<span class="text-xs uppercase tracking-wide text-gray-400">No tags</span>
{/if}
</div>
<form method="POST" action="?/updateTags" class="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center">
<input type="hidden" name="audioFileId" value={audio.id} />
<input
type="text"
name="tags"
value={audio.tags?.join(', ') ?? ''}
placeholder="rock, jazz, intro"
class="w-full rounded-md border-gray-300 px-2 py-1 text-xs focus:border-indigo-500 focus:ring-indigo-500"
title="Comma-separated list. Leave blank to remove all tags"
/>
<button
type="submit"
class="inline-flex items-center justify-center rounded bg-gray-800 px-2 py-1 text-xs font-medium text-white hover:bg-gray-700"
>
Save
</button>
</form>
</td>
<td class="space-x-2 px-6 py-4 text-sm font-medium whitespace-nowrap">
{#if editingAudioId === audio.id}
<div class="flex items-center space-x-2">
@@ -469,7 +518,7 @@
</tr>
{:else}
<tr>
<td colspan="6" class="px-6 py-4 text-center text-sm text-gray-500">
<td colspan="7" class="px-6 py-4 text-center text-sm text-gray-500">
No audio files uploaded yet.
</td>
</tr>

View File

@@ -1,17 +1,60 @@
import { error } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js';
import { audioFile } from '$lib/server/db/schema.js';
import { audioFile, inviteLink, participant } from '$lib/server/db/schema.js';
import { eq, isNull, and } from 'drizzle-orm';
import { getFromS3, getFromS3WithRange } from '$lib/server/s3.js';
import { parseStoredTags, matchesInviteTags } from '$lib/server/tag-utils.js';
export async function GET({ params, request }) {
export async function GET({ params, request, url, cookies }) {
const fileId = params.id;
const token = url.searchParams.get('token');
if (!token) {
throw error(400, 'Missing token');
}
const participantId = cookies.get(`participant-${token}`);
if (!participantId) {
throw error(403, 'Unauthorized');
}
const participants = await db
.select({ inviteToken: participant.inviteToken })
.from(participant)
.where(
and(
eq(participant.id, participantId),
eq(participant.inviteToken, token),
isNull(participant.deletedAt)
)
);
if (participants.length === 0) {
throw error(403, 'Unauthorized');
}
const invites = await db
.select({ tags: inviteLink.tags })
.from(inviteLink)
.where(
and(
eq(inviteLink.token, token),
isNull(inviteLink.deletedAt)
)
);
if (invites.length === 0) {
throw error(404, 'Invite not found');
}
const inviteTags = parseStoredTags(invites[0].tags);
// Get file metadata from database (only s3Key and contentType needed)
const files = await db.select({
s3Key: audioFile.s3Key,
contentType: audioFile.contentType,
fileSize: audioFile.fileSize
fileSize: audioFile.fileSize,
tags: audioFile.tags
})
.from(audioFile)
.where(and(
@@ -24,6 +67,11 @@ export async function GET({ params, request }) {
}
const file = files[0];
const audioTags = parseStoredTags(file.tags);
if (!matchesInviteTags(audioTags, inviteTags)) {
throw error(403, 'Unauthorized');
}
// Check if file has S3 key (new files) or fall back to error for old blob-based files
if (!file.s3Key) {

View File

@@ -1,7 +1,8 @@
import { error } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js';
import { inviteLink, participant, audioFile, rating } from '$lib/server/db/schema.js';
import { eq, isNull, and } from 'drizzle-orm';
import { eq, isNull, and, inArray } from 'drizzle-orm';
import { parseStoredTags, matchesInviteTags } from '$lib/server/tag-utils.js';
export async function load({ url, cookies }) {
const token = url.searchParams.get('token');
@@ -21,7 +22,8 @@ export async function load({ url, cookies }) {
throw error(404, 'Invite link not found or has been deleted');
}
const invite = invites[0];
const { tags: inviteTagString, ...safeInvite } = invites[0];
const inviteTags = parseStoredTags(inviteTagString);
let participantId = cookies.get(`participant-${token}`);
let isExistingParticipant = false;
@@ -66,49 +68,71 @@ export async function load({ url, cookies }) {
});
}
const audioFiles = await db.select({
const audioRows = await db.select({
id: audioFile.id,
filename: audioFile.filename,
contentType: audioFile.contentType,
duration: audioFile.duration,
fileSize: audioFile.fileSize,
createdAt: audioFile.createdAt
createdAt: audioFile.createdAt,
tags: audioFile.tags
})
.from(audioFile)
.where(isNull(audioFile.deletedAt)); // Only show active audio files
const filteredAudio = audioRows
.map((audio) => ({
data: {
id: audio.id,
filename: audio.filename,
contentType: audio.contentType,
duration: audio.duration,
fileSize: audio.fileSize,
createdAt: audio.createdAt
},
tags: parseStoredTags(audio.tags)
}))
.filter(({ tags }) => matchesInviteTags(tags, inviteTags))
.map(({ data }) => data);
const allowedAudioIds = filteredAudio.map((file) => file.id);
// Get completed ratings for this participant (only active, non-deleted ratings for active audio files)
const completedRatings = await db
.select({
audioFileId: rating.audioFileId
})
.from(rating)
.innerJoin(audioFile, and(
eq(rating.audioFileId, audioFile.id),
isNull(audioFile.deletedAt) // Only count ratings for active audio files
))
.where(
and(
eq(rating.participantId, participantId),
eq(rating.isCompleted, true),
isNull(rating.deletedAt) // Only count active ratings
)
);
let completedRatings = [];
if (allowedAudioIds.length > 0) {
completedRatings = await db
.select({
audioFileId: rating.audioFileId
})
.from(rating)
.innerJoin(audioFile, and(
eq(rating.audioFileId, audioFile.id),
isNull(audioFile.deletedAt) // Only count ratings for active audio files
))
.where(
and(
eq(rating.participantId, participantId),
eq(rating.isCompleted, true),
isNull(rating.deletedAt), // Only count active ratings
inArray(rating.audioFileId, allowedAudioIds)
)
);
}
const completedAudioIds = new Set(completedRatings.map(r => r.audioFileId));
// Add completion status to audio files
const audioFilesWithStatus = audioFiles.map(file => ({
const audioFilesWithStatus = filteredAudio.map(file => ({
...file,
isCompleted: completedAudioIds.has(file.id)
}));
return {
invite,
invite: safeInvite,
participantId,
audioFiles: audioFilesWithStatus,
token,
completedCount: completedRatings.length,
totalCount: audioFiles.length
totalCount: filteredAudio.length
};
}

View File

@@ -1,7 +1,64 @@
import { redirect, error } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js';
import { audioFile, rating, participantProgress, participant, overallRating } from '$lib/server/db/schema.js';
import { audioFile, rating, participantProgress, participant, overallRating, inviteLink } from '$lib/server/db/schema.js';
import { eq, and, isNull } from 'drizzle-orm';
import { parseStoredTags, matchesInviteTags } from '$lib/server/tag-utils.js';
async function ensureParticipantAccess(participantId, audioFileId) {
const participantRecords = await db
.select({ inviteToken: participant.inviteToken })
.from(participant)
.where(
and(
eq(participant.id, participantId),
isNull(participant.deletedAt)
)
)
.limit(1);
if (participantRecords.length === 0) {
return { allowed: false, reason: 'Participant not found' };
}
const inviteRecords = await db
.select({ tags: inviteLink.tags })
.from(inviteLink)
.where(
and(
eq(inviteLink.token, participantRecords[0].inviteToken),
isNull(inviteLink.deletedAt)
)
)
.limit(1);
if (inviteRecords.length === 0) {
return { allowed: false, reason: 'Invite not found' };
}
const inviteTags = parseStoredTags(inviteRecords[0].tags);
const audioRecords = await db
.select({ id: audioFile.id, tags: audioFile.tags })
.from(audioFile)
.where(
and(
eq(audioFile.id, audioFileId),
isNull(audioFile.deletedAt)
)
)
.limit(1);
if (audioRecords.length === 0) {
return { allowed: false, reason: 'Audio file not found' };
}
const audioTags = parseStoredTags(audioRecords[0].tags);
if (!matchesInviteTags(audioTags, inviteTags)) {
return { allowed: false, reason: 'Forbidden' };
}
return { allowed: true };
}
export async function load({ params, url, cookies }) {
const audioId = params.id;
@@ -16,6 +73,22 @@ export async function load({ params, url, cookies }) {
redirect(302, `/participate?token=${token}`);
}
const inviteRecords = await db
.select({ tags: inviteLink.tags })
.from(inviteLink)
.where(
and(
eq(inviteLink.token, token),
isNull(inviteLink.deletedAt)
)
);
if (inviteRecords.length === 0) {
throw error(404, 'Invite link not found or has been deleted');
}
const inviteTags = parseStoredTags(inviteRecords[0].tags);
// Verify participant exists and isn't soft-deleted
const participants = await db
.select()
@@ -37,7 +110,8 @@ export async function load({ params, url, cookies }) {
contentType: audioFile.contentType,
duration: audioFile.duration,
fileSize: audioFile.fileSize,
createdAt: audioFile.createdAt
createdAt: audioFile.createdAt,
tags: audioFile.tags
})
.from(audioFile)
.where(and(
@@ -49,6 +123,13 @@ export async function load({ params, url, cookies }) {
throw error(404, 'Audio file not found');
}
const [audioRecord] = audioFiles;
const audioTags = parseStoredTags(audioRecord.tags);
if (!matchesInviteTags(audioTags, inviteTags)) {
throw error(403, 'You do not have access to this audio file');
}
const progressData = await db
.select({
id: participantProgress.id,
@@ -77,7 +158,14 @@ export async function load({ params, url, cookies }) {
));
return {
audioFile: audioFiles[0],
audioFile: {
id: audioRecord.id,
filename: audioRecord.filename,
contentType: audioRecord.contentType,
duration: audioRecord.duration,
fileSize: audioRecord.fileSize,
createdAt: audioRecord.createdAt
},
participantId,
token,
progress,
@@ -100,6 +188,11 @@ export const actions = {
}
try {
const access = await ensureParticipantAccess(participantId, audioFileId);
if (!access.allowed) {
return { error: access.reason || 'You do not have access to this audio file' };
}
const ratingHistory = JSON.parse(ratingHistoryStr);
console.log('Rating history length:', ratingHistory.length);
console.log('Rating history:', ratingHistory);
@@ -185,6 +278,11 @@ export const actions = {
}
try {
const access = await ensureParticipantAccess(participantId, audioFileId);
if (!access.allowed) {
return { error: access.reason || 'You do not have access to this audio file' };
}
// Soft delete the active rating for this participant and audio file
await db.update(rating)
.set({ deletedAt: new Date() })

View File

@@ -2,7 +2,9 @@
import { onMount, onDestroy } from 'svelte';
import OverallRatingModal from '$lib/components/OverallRatingModal.svelte';
export let data;
export let data;
let audioApiUrl = '';
$: audioApiUrl = `/api/audio/${data.audioFile.id}?token=${data.token}`;
let audio;
let isPlaying = false;
@@ -31,7 +33,7 @@
currentTime = 0;
console.log('Component mounted, audio file:', data.audioFile);
console.log('Audio API URL:', `/api/audio/${data.audioFile.id}`);
console.log('Audio API URL:', audioApiUrl);
// Always reset completion tracking - require full listening every time
hasListenedToEnd = false;
@@ -69,7 +71,7 @@
if (typeof window === 'undefined') return;
try {
const response = await fetch(`/api/audio/${data.audioFile.id}`);
const response = await fetch(audioApiUrl);
const arrayBuffer = await response.arrayBuffer();
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
@@ -370,7 +372,7 @@
<div class="mb-8">
<audio
bind:this={audio}
src="/api/audio/{data.audioFile.id}"
src={audioApiUrl}
on:timeupdate={handleTimeUpdate}
on:loadedmetadata={handleLoadedMetadata}
on:play={handlePlay}