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 -