fixed migrations
This commit is contained in:
@@ -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;
|
||||
2
drizzle/0005_windy_gauntlet.sql
Normal file
2
drizzle/0005_windy_gauntlet.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Legacy placeholder migration retained for journal consistency.
|
||||
SELECT 1;
|
||||
2
drizzle/0006_tearful_crystal.sql
Normal file
2
drizzle/0006_tearful_crystal.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Legacy placeholder migration retained for journal consistency.
|
||||
SELECT 1;
|
||||
@@ -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;
|
||||
ALTER TABLE `rating` ADD `deleted_at` integer;
|
||||
|
||||
15
drizzle/0009_admin_tags.sql
Normal file
15
drizzle/0009_admin_tags.sql
Normal file
@@ -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;
|
||||
Binary file not shown.
602
drizzle/meta/0009_snapshot.json
Normal file
602
drizzle/meta/0009_snapshot.json
Normal file
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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', {
|
||||
|
||||
53
src/lib/server/tag-utils.js
Normal file
53
src/lib/server/tag-utils.js
Normal 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()));
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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() })
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user