diff --git a/drizzle/0005_redesign_ratings.sql b/drizzle/0005_redesign_ratings.sql deleted file mode 100644 index 01d54d9..0000000 --- a/drizzle/0005_redesign_ratings.sql +++ /dev/null @@ -1,31 +0,0 @@ --- Redesign ratings table for single JSON timeseries entry with soft deletes -DROP TABLE IF EXISTS rating_new; -CREATE TABLE rating_new ( - id TEXT PRIMARY KEY, - participant_id TEXT NOT NULL REFERENCES participant(id), - audio_file_id TEXT NOT NULL REFERENCES audio_file(id), - final_value REAL NOT NULL, - timeseries_data TEXT NOT NULL, - created_at INTEGER NOT NULL, - deleted_at INTEGER DEFAULT NULL -); - --- Copy existing data if any (transform to new format) -INSERT INTO rating_new (id, participant_id, audio_file_id, final_value, timeseries_data, created_at, deleted_at) -SELECT - id, - participant_id, - audio_file_id, - value as final_value, - '[{"timestamp":' || timestamp || ',"value":' || value || ',"time":' || (created_at * 1000) || '}]' as timeseries_data, - created_at, - CASE WHEN is_completed = 1 THEN NULL ELSE strftime('%s', 'now') * 1000 END as deleted_at -FROM rating -WHERE is_completed = 1; -- Only keep completed ratings, mark others as deleted - --- Drop old table and rename -DROP TABLE rating; -ALTER TABLE rating_new RENAME TO rating; - --- Create unique index for active ratings only -CREATE UNIQUE INDEX participant_audio_active ON rating (participant_id, audio_file_id) WHERE deleted_at IS NULL; \ No newline at end of file diff --git a/drizzle/0005_windy_gauntlet.sql b/drizzle/0005_windy_gauntlet.sql new file mode 100644 index 0000000..57b95d1 --- /dev/null +++ b/drizzle/0005_windy_gauntlet.sql @@ -0,0 +1,2 @@ +-- Legacy placeholder migration retained for journal consistency. +SELECT 1; diff --git a/drizzle/0006_tearful_crystal.sql b/drizzle/0006_tearful_crystal.sql new file mode 100644 index 0000000..57b95d1 --- /dev/null +++ b/drizzle/0006_tearful_crystal.sql @@ -0,0 +1,2 @@ +-- Legacy placeholder migration retained for journal consistency. +SELECT 1; diff --git a/drizzle/0007_colossal_betty_brant.sql b/drizzle/0007_colossal_betty_brant.sql index dff11ba..a7e0b1d 100644 --- a/drizzle/0007_colossal_betty_brant.sql +++ b/drizzle/0007_colossal_betty_brant.sql @@ -1,3 +1,3 @@ DROP INDEX `participant_audio_completed`;--> statement-breakpoint ALTER TABLE `rating` ADD `timeseries_data` text;--> statement-breakpoint -ALTER TABLE `rating` ADD `deleted_at` integer; \ No newline at end of file +ALTER TABLE `rating` ADD `deleted_at` integer; diff --git a/drizzle/0009_admin_tags.sql b/drizzle/0009_admin_tags.sql new file mode 100644 index 0000000..1fc9acf --- /dev/null +++ b/drizzle/0009_admin_tags.sql @@ -0,0 +1,15 @@ +CREATE TABLE `overall_rating` ( + `id` text PRIMARY KEY NOT NULL, + `participant_id` text NOT NULL, + `audio_file_id` text NOT NULL, + `value` real NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + `deleted_at` integer, + FOREIGN KEY (`participant_id`) REFERENCES `participant`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`audio_file_id`) REFERENCES `audio_file`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +ALTER TABLE `audio_file` ADD `tags` text DEFAULT '[]' NOT NULL; +--> statement-breakpoint +ALTER TABLE `invite_link` ADD `tags` text DEFAULT '[]' NOT NULL; diff --git a/drizzle/db.sqlite b/drizzle/db.sqlite index e69de29..b85e58d 100644 Binary files a/drizzle/db.sqlite and b/drizzle/db.sqlite differ diff --git a/drizzle/meta/0009_snapshot.json b/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..3b20d35 --- /dev/null +++ b/drizzle/meta/0009_snapshot.json @@ -0,0 +1,602 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "662a64ad-8681-48f3-93fe-e0e07aaa5cb6", + "prevId": "90bc9682-1832-405b-9b16-394f8fa6eacb", + "tables": { + "audio_file": { + "name": "audio_file", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "blob", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'null'" + }, + "s3_key": { + "name": "s3_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invite_link": { + "name": "invite_link", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "participant_name": { + "name": "participant_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_used": { + "name": "is_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + } + }, + "indexes": { + "invite_link_token_unique": { + "name": "invite_link_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "overall_rating": { + "name": "overall_rating", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "audio_file_id": { + "name": "audio_file_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "overall_rating_participant_id_participant_id_fk": { + "name": "overall_rating_participant_id_participant_id_fk", + "tableFrom": "overall_rating", + "tableTo": "participant", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "overall_rating_audio_file_id_audio_file_id_fk": { + "name": "overall_rating_audio_file_id_audio_file_id_fk", + "tableFrom": "overall_rating", + "tableTo": "audio_file", + "columnsFrom": [ + "audio_file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "participant": { + "name": "participant", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "invite_token": { + "name": "invite_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "participant_invite_token_invite_link_token_fk": { + "name": "participant_invite_token_invite_link_token_fk", + "tableFrom": "participant", + "tableTo": "invite_link", + "columnsFrom": [ + "invite_token" + ], + "columnsTo": [ + "token" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "participant_progress": { + "name": "participant_progress", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "audio_file_id": { + "name": "audio_file_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_completed": { + "name": "is_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "last_position": { + "name": "last_position", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "max_reached_time": { + "name": "max_reached_time", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "participant_progress_participant_id_participant_id_fk": { + "name": "participant_progress_participant_id_participant_id_fk", + "tableFrom": "participant_progress", + "tableTo": "participant", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "participant_progress_audio_file_id_audio_file_id_fk": { + "name": "participant_progress_audio_file_id_audio_file_id_fk", + "tableFrom": "participant_progress", + "tableTo": "audio_file", + "columnsFrom": [ + "audio_file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rating": { + "name": "rating", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "audio_file_id": { + "name": "audio_file_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_completed": { + "name": "is_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "timeseries_data": { + "name": "timeseries_data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "rating_participant_id_participant_id_fk": { + "name": "rating_participant_id_participant_id_fk", + "tableFrom": "rating", + "tableTo": "participant", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "rating_audio_file_id_audio_file_id_fk": { + "name": "rating_audio_file_id_audio_file_id_fk", + "tableFrom": "rating", + "tableTo": "audio_file", + "columnsFrom": [ + "audio_file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "age": { + "name": "age", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_username_unique": { + "name": "user_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 2f51ee4..563f770 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1753195639650, "tag": "0008_wild_texas_twister", "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1762809580759, + "tag": "0009_admin_tags", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/lib/server/db/schema.js b/src/lib/server/db/schema.js index 1ee22ad..f5a8a59 100644 --- a/src/lib/server/db/schema.js +++ b/src/lib/server/db/schema.js @@ -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', { diff --git a/src/lib/server/tag-utils.js b/src/lib/server/tag-utils.js new file mode 100644 index 0000000..3d36839 --- /dev/null +++ b/src/lib/server/tag-utils.js @@ -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())); +} diff --git a/src/routes/admin/+page.server.js b/src/routes/admin/+page.server.js index 6b4ab62..760fc8c 100644 --- a/src/routes/admin/+page.server.js +++ b/src/routes/admin/+page.server.js @@ -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' }); + } } -}; \ No newline at end of file +}; diff --git a/src/routes/admin/+page.svelte b/src/routes/admin/+page.svelte index aa86fab..0d4cb8d 100644 --- a/src/routes/admin/+page.svelte +++ b/src/routes/admin/+page.svelte @@ -148,16 +148,21 @@ > Created - + Link - - + + + Tags (admin) + + Actions - + @@ -200,6 +205,50 @@ + +
+ {#if invite.tags?.length} + {#each invite.tags as tag (tag)} +
+ + + {tag} + +
+ {/each} + {:else} + No tags + {/if} +
+
+ + + +
+
@@ -219,7 +268,7 @@ {:else} - + No invite links created yet. @@ -228,4 +277,4 @@ - \ No newline at end of file + diff --git a/src/routes/admin/audio/+page.server.js b/src/routes/admin/audio/+page.server.js index ff50bbf..53cba18 100644 --- a/src/routes/admin/audio/+page.server.js +++ b/src/routes/admin/audio/+page.server.js @@ -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' }); + } + } }; diff --git a/src/routes/admin/audio/+page.svelte b/src/routes/admin/audio/+page.svelte index 6f3b772..c52895e 100644 --- a/src/routes/admin/audio/+page.svelte +++ b/src/routes/admin/audio/+page.svelte @@ -366,16 +366,21 @@ > Duration - + Uploaded - - + + + Tags (admin only) + + Actions - + @@ -416,6 +421,50 @@ {new Date(audio.createdAt).toLocaleDateString()} + +
+ {#if audio.tags?.length} + {#each audio.tags as tag (tag)} + + + + {tag} + + + {/each} + {:else} + No tags + {/if} +
+
+ + + +
+ {#if editingAudioId === audio.id}
@@ -469,7 +518,7 @@ {:else} - + No audio files uploaded yet. diff --git a/src/routes/api/audio/[id]/+server.js b/src/routes/api/audio/[id]/+server.js index afd5ba7..1dc3a9d 100644 --- a/src/routes/api/audio/[id]/+server.js +++ b/src/routes/api/audio/[id]/+server.js @@ -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) { diff --git a/src/routes/participate/+page.server.js b/src/routes/participate/+page.server.js index 28e4809..7be59a2 100644 --- a/src/routes/participate/+page.server.js +++ b/src/routes/participate/+page.server.js @@ -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 }; } diff --git a/src/routes/participate/audio/[id]/+page.server.js b/src/routes/participate/audio/[id]/+page.server.js index 8ce17f3..c128c19 100644 --- a/src/routes/participate/audio/[id]/+page.server.js +++ b/src/routes/participate/audio/[id]/+page.server.js @@ -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() }) diff --git a/src/routes/participate/audio/[id]/+page.svelte b/src/routes/participate/audio/[id]/+page.svelte index d79e2e8..18c044e 100644 --- a/src/routes/participate/audio/[id]/+page.svelte +++ b/src/routes/participate/audio/[id]/+page.svelte @@ -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 @@