Compare commits
5 Commits
46ba0fe0e1
...
9cbef20644
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cbef20644 | |||
| ca5fedd65c | |||
| 6f62256881 | |||
| edd1d34900 | |||
| 7a10cd7f5d |
@@ -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;
|
||||||
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;
|
||||||
1
drizzle/0010_dual_overall_questions.sql
Normal file
1
drizzle/0010_dual_overall_questions.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `overall_rating` ADD `temporal_value` real DEFAULT 0 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
610
drizzle/meta/0010_snapshot.json
Normal file
610
drizzle/meta/0010_snapshot.json
Normal file
@@ -0,0 +1,610 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "c49d536a-0a0c-4c62-aed0-7b4fcb36d414",
|
||||||
|
"prevId": "662a64ad-8681-48f3-93fe-e0e07aaa5cb6",
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"temporal_value": {
|
||||||
|
"name": "temporal_value",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"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,20 @@
|
|||||||
"when": 1753195639650,
|
"when": 1753195639650,
|
||||||
"tag": "0008_wild_texas_twister",
|
"tag": "0008_wild_texas_twister",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 9,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1762809580759,
|
||||||
|
"tag": "0009_admin_tags",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1762812000000,
|
||||||
|
"tag": "0010_dual_overall_questions",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
18
package-lock.json
generated
18
package-lock.json
generated
@@ -16,7 +16,8 @@
|
|||||||
"@sveltejs/adapter-node": "^5.2.13",
|
"@sveltejs/adapter-node": "^5.2.13",
|
||||||
"chart.js": "^4.5.0",
|
"chart.js": "^4.5.0",
|
||||||
"drizzle-orm": "^0.40.0",
|
"drizzle-orm": "^0.40.0",
|
||||||
"exceljs": "^4.4.0"
|
"exceljs": "^4.4.0",
|
||||||
|
"knuth-shuffle-seeded": "^1.0.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^1.2.5",
|
"@eslint/compat": "^1.2.5",
|
||||||
@@ -6462,6 +6463,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/knuth-shuffle-seeded": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/knuth-shuffle-seeded/-/knuth-shuffle-seeded-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-9pFH0SplrfyKyojCLxZfMcvkhf5hH0d+UwR9nTVJ/DDQJGuzcXjTwB7TP7sDfehSudlGGaOLblmEWqv04ERVWg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"seed-random": "~2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lazystream": {
|
"node_modules/lazystream": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
|
||||||
@@ -7750,6 +7760,12 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/seed-random": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/seed-random/-/seed-random-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-34EQV6AAHQGhoc0tn/96a9Fsi6v2xdqe/dMUwljGRaFOzR3EgRmECvD0O8vi8X+/uQ50LGHfkNu/Eue5TPKZkQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.7.2",
|
"version": "7.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
"@sveltejs/adapter-node": "^5.2.13",
|
"@sveltejs/adapter-node": "^5.2.13",
|
||||||
"chart.js": "^4.5.0",
|
"chart.js": "^4.5.0",
|
||||||
"drizzle-orm": "^0.40.0",
|
"drizzle-orm": "^0.40.0",
|
||||||
"exceljs": "^4.4.0"
|
"exceljs": "^4.4.0",
|
||||||
|
"knuth-shuffle-seeded": "^1.0.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
48
src/lib/components/Modal.svelte
Normal file
48
src/lib/components/Modal.svelte
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<script>
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
export let isOpen = false;
|
||||||
|
export let title = '';
|
||||||
|
export let maxWidth = 'max-w-md';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
function handleBackdropClick(event) {
|
||||||
|
if (event.target === event.currentTarget) {
|
||||||
|
dispatch('close');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event) {
|
||||||
|
if (event.key === 'Escape' && isOpen) {
|
||||||
|
dispatch('close');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={handleKeydown} />
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50 transition-opacity"
|
||||||
|
on:click={handleBackdropClick}
|
||||||
|
on:keydown={handleKeydown}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="modal-title"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div class="relative top-20 mx-auto p-5 border w-11/12 {maxWidth} shadow-lg rounded-md bg-white">
|
||||||
|
<div class="mt-3">
|
||||||
|
{#if title}
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-4" id="modal-title">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
{/if}
|
||||||
|
<div>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
135
src/lib/components/OverallRatingModal.svelte
Normal file
135
src/lib/components/OverallRatingModal.svelte
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<script>
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import Modal from './Modal.svelte';
|
||||||
|
|
||||||
|
export let isOpen = false;
|
||||||
|
export let audioFilename = '';
|
||||||
|
export let isSubmitting = false;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
let temporalRating = 50;
|
||||||
|
let overallRating = 50;
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
if (isSubmitting) return; // Prevent closing while submitting
|
||||||
|
dispatch('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
dispatch('submit', { overallRating, temporalRating });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRatingChange() {
|
||||||
|
// Optional: add any real-time feedback here
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal {isOpen} title="Overall Performance Rating" maxWidth="max-w-lg" on:close={handleClose}>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-indigo-100 mb-4">
|
||||||
|
<i class="fas fa-star text-indigo-600 text-xl"></i>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
You've completed rating: <strong>{audioFilename}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h4 class="text-center text-lg font-medium text-gray-900">
|
||||||
|
How well do you think the two performers instantiated the temporal relationship prescribed in the score?
|
||||||
|
</h4>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
bind:value={temporalRating}
|
||||||
|
on:input={handleRatingChange}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
class="slider h-3 w-full cursor-pointer appearance-none rounded-lg bg-gray-200 {isSubmitting ? 'opacity-50' : ''}"
|
||||||
|
/>
|
||||||
|
<div class="flex justify-between text-sm text-gray-600">
|
||||||
|
<span>Not well (0)</span>
|
||||||
|
<span class="font-semibold text-indigo-600">Rating: {temporalRating}</span>
|
||||||
|
<span>Perfectly (100)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h4 class="text-center text-lg font-medium text-gray-900">
|
||||||
|
How good was this performance overall?
|
||||||
|
</h4>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
bind:value={overallRating}
|
||||||
|
on:input={handleRatingChange}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
class="slider h-3 w-full cursor-pointer appearance-none rounded-lg bg-gray-200 {isSubmitting ? 'opacity-50' : ''}"
|
||||||
|
/>
|
||||||
|
<div class="flex justify-between text-sm text-gray-600">
|
||||||
|
<span>Poor (0)</span>
|
||||||
|
<span class="font-semibold text-indigo-600">Rating: {overallRating}</span>
|
||||||
|
<span>Excellent (100)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex space-x-3 justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={handleClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={handleSubmit}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
|
||||||
|
>
|
||||||
|
{#if isSubmitting}
|
||||||
|
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||||
|
Submitting...
|
||||||
|
{:else}
|
||||||
|
Submit Overall Rating
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.slider::-webkit-slider-thumb {
|
||||||
|
appearance: none;
|
||||||
|
height: 24px;
|
||||||
|
width: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #4f46e5;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::-moz-range-thumb {
|
||||||
|
height: 24px;
|
||||||
|
width: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #4f46e5;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider:disabled::-webkit-slider-thumb {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider:disabled::-moz-range-thumb {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -25,7 +25,8 @@ export const audioFile = sqliteTable('audio_file', {
|
|||||||
duration: real('duration'),
|
duration: real('duration'),
|
||||||
fileSize: integer('file_size'),
|
fileSize: integer('file_size'),
|
||||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
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', {
|
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),
|
isUsed: integer('is_used', { mode: 'boolean' }).notNull().default(false),
|
||||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||||
usedAt: integer('used_at', { mode: 'timestamp' }),
|
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', {
|
export const participant = sqliteTable('participant', {
|
||||||
@@ -86,6 +88,7 @@ export const overallRating = sqliteTable('overall_rating', {
|
|||||||
audioFileId: text('audio_file_id')
|
audioFileId: text('audio_file_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => audioFile.id),
|
.references(() => audioFile.id),
|
||||||
|
temporalValue: real('temporal_value').notNull().default(0),
|
||||||
value: real('value').notNull(), // 0-100 rating value
|
value: real('value').notNull(), // 0-100 rating value
|
||||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
|||||||
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 { db } from '$lib/server/db/index.js';
|
||||||
import { inviteLink, participant } from '$lib/server/db/schema.js';
|
import { inviteLink, participant } from '$lib/server/db/schema.js';
|
||||||
import { eq, isNull } from 'drizzle-orm';
|
import { eq, isNull } from 'drizzle-orm';
|
||||||
|
import { parseStoredTags, normalizeTagsInput, serializeTags } from '$lib/server/tag-utils.js';
|
||||||
|
|
||||||
export async function load() {
|
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 {
|
return {
|
||||||
invites
|
invites
|
||||||
};
|
};
|
||||||
@@ -125,5 +130,49 @@ export const actions = {
|
|||||||
message: 'Failed to delete invite link'
|
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
|
Created
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||||
>
|
>
|
||||||
Link
|
Link
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
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
|
Actions
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 bg-white">
|
<tbody class="divide-y divide-gray-200 bg-white">
|
||||||
@@ -200,6 +205,50 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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="tag1, tag2, tag3"
|
||||||
|
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">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<form method="POST" action="?/delete" class="inline" use:enhance>
|
<form method="POST" action="?/delete" class="inline" use:enhance>
|
||||||
<input type="hidden" name="inviteId" value={invite.id} />
|
<input type="hidden" name="inviteId" value={invite.id} />
|
||||||
@@ -219,7 +268,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{:else}
|
{:else}
|
||||||
<tr>
|
<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.
|
No invite links created yet.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { fail } from '@sveltejs/kit';
|
|||||||
import { db } from '$lib/server/db/index.js';
|
import { db } from '$lib/server/db/index.js';
|
||||||
import { audioFile } from '$lib/server/db/schema.js';
|
import { audioFile } from '$lib/server/db/schema.js';
|
||||||
import { eq, isNull } from 'drizzle-orm';
|
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 { uploadToS3, deleteFromS3, generateAudioS3Key } from '$lib/server/s3.js';
|
||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
@@ -45,17 +46,23 @@ async function getAudioDuration(buffer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function load() {
|
export async function load() {
|
||||||
const audioFiles = await db.select({
|
const rows = await db.select({
|
||||||
id: audioFile.id,
|
id: audioFile.id,
|
||||||
filename: audioFile.filename,
|
filename: audioFile.filename,
|
||||||
contentType: audioFile.contentType,
|
contentType: audioFile.contentType,
|
||||||
duration: audioFile.duration,
|
duration: audioFile.duration,
|
||||||
fileSize: audioFile.fileSize,
|
fileSize: audioFile.fileSize,
|
||||||
createdAt: audioFile.createdAt
|
createdAt: audioFile.createdAt,
|
||||||
|
tags: audioFile.tags
|
||||||
})
|
})
|
||||||
.from(audioFile)
|
.from(audioFile)
|
||||||
.where(isNull(audioFile.deletedAt)); // Only show active audio files
|
.where(isNull(audioFile.deletedAt)); // Only show active audio files
|
||||||
|
|
||||||
|
const audioFiles = rows.map((audio) => ({
|
||||||
|
...audio,
|
||||||
|
tags: parseStoredTags(audio.tags)
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
audioFiles
|
audioFiles
|
||||||
};
|
};
|
||||||
@@ -196,4 +203,47 @@ export const actions = {
|
|||||||
return fail(500, { error: 'Failed to rename audio file' });
|
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
|
Duration
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||||
>
|
>
|
||||||
Uploaded
|
Uploaded
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
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
|
Actions
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 bg-white">
|
<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">
|
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
|
||||||
{new Date(audio.createdAt).toLocaleDateString()}
|
{new Date(audio.createdAt).toLocaleDateString()}
|
||||||
</td>
|
</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="tag1, tag2, tag3"
|
||||||
|
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">
|
<td class="space-x-2 px-6 py-4 text-sm font-medium whitespace-nowrap">
|
||||||
{#if editingAudioId === audio.id}
|
{#if editingAudioId === audio.id}
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
@@ -469,7 +518,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{:else}
|
{:else}
|
||||||
<tr>
|
<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.
|
No audio files uploaded yet.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -62,7 +62,14 @@ export async function load() {
|
|||||||
// Get overall ratings
|
// Get overall ratings
|
||||||
const overallRatings = await db
|
const overallRatings = await db
|
||||||
.select({
|
.select({
|
||||||
overallRating,
|
overallRating: {
|
||||||
|
id: overallRating.id,
|
||||||
|
participantId: overallRating.participantId,
|
||||||
|
audioFileId: overallRating.audioFileId,
|
||||||
|
value: overallRating.value,
|
||||||
|
temporalValue: overallRating.temporalValue,
|
||||||
|
createdAt: overallRating.createdAt
|
||||||
|
},
|
||||||
participant,
|
participant,
|
||||||
audioFile: {
|
audioFile: {
|
||||||
id: audioFile.id,
|
id: audioFile.id,
|
||||||
@@ -158,31 +165,45 @@ export async function load() {
|
|||||||
|
|
||||||
overallRatingsMap[audioId][participantId] = {
|
overallRatingsMap[audioId][participantId] = {
|
||||||
value: entry.overallRating.value,
|
value: entry.overallRating.value,
|
||||||
|
temporalValue: entry.overallRating.temporalValue,
|
||||||
createdAt: entry.overallRating.createdAt,
|
createdAt: entry.overallRating.createdAt,
|
||||||
participantName
|
participantName
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ensure the participant exists in timeseriesData
|
// Ensure the participant exists in timeseriesData
|
||||||
if (timeseriesData[audioId] && timeseriesData[audioId].participants[participantId]) {
|
if (timeseriesData[audioId] && timeseriesData[audioId].participants[participantId]) {
|
||||||
timeseriesData[audioId].participants[participantId].overallRating = entry.overallRating.value;
|
timeseriesData[audioId].participants[participantId].overallResponses = {
|
||||||
|
temporal: entry.overallRating.temporalValue,
|
||||||
|
overall: entry.overallRating.value,
|
||||||
|
createdAt: entry.overallRating.createdAt
|
||||||
|
};
|
||||||
|
timeseriesData[audioId].participants[participantId].isCompleted = true;
|
||||||
} else if (timeseriesData[audioId]) {
|
} else if (timeseriesData[audioId]) {
|
||||||
// Participant has overall rating but no timeseries data yet
|
// Participant has overall rating but no timeseries data yet
|
||||||
timeseriesData[audioId].participants[participantId] = {
|
timeseriesData[audioId].participants[participantId] = {
|
||||||
name: participantName,
|
name: participantName,
|
||||||
ratings: [],
|
ratings: [],
|
||||||
isCompleted: false,
|
isCompleted: true,
|
||||||
overallRating: entry.overallRating.value
|
overallResponses: {
|
||||||
|
temporal: entry.overallRating.temporalValue,
|
||||||
|
overall: entry.overallRating.value,
|
||||||
|
createdAt: entry.overallRating.createdAt
|
||||||
|
}
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// Neither audio nor participant exists in timeseriesData yet
|
// Neither audio nor participant exists in timeseriesData yet
|
||||||
timeseriesData[audioId] = {
|
timeseriesData[audioId] = {
|
||||||
audioFile: entry.audioFile,
|
audioFile: entry.audioFile,
|
||||||
participants: {
|
participants: {
|
||||||
[participantId]: {
|
[participantId]: {
|
||||||
name: participantName,
|
name: participantName,
|
||||||
ratings: [],
|
ratings: [],
|
||||||
isCompleted: false,
|
isCompleted: true,
|
||||||
overallRating: entry.overallRating.value
|
overallResponses: {
|
||||||
|
temporal: entry.overallRating.temporalValue,
|
||||||
|
overall: entry.overallRating.value,
|
||||||
|
createdAt: entry.overallRating.createdAt
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -181,7 +181,7 @@
|
|||||||
<th
|
<th
|
||||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||||
>
|
>
|
||||||
Overall Rating
|
Post-Listening Ratings
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||||
@@ -203,8 +203,10 @@
|
|||||||
{@const minTimestamp = participantData.ratings.length > 0 ? Math.min(...participantData.ratings.map(r => r.timestamp)) : 0}
|
{@const minTimestamp = participantData.ratings.length > 0 ? Math.min(...participantData.ratings.map(r => r.timestamp)) : 0}
|
||||||
{@const maxTimestamp = participantData.ratings.length > 0 ? Math.max(...participantData.ratings.map(r => r.timestamp)) : 0}
|
{@const maxTimestamp = participantData.ratings.length > 0 ? Math.max(...participantData.ratings.map(r => r.timestamp)) : 0}
|
||||||
{@const durationCovered = maxTimestamp - minTimestamp}
|
{@const durationCovered = maxTimestamp - minTimestamp}
|
||||||
{@const lastUpdated = participantData.ratings.length > 0 ?
|
{@const lastTimeseriesUpdate = participantData.ratings.length > 0 ?
|
||||||
new Date(Math.max(...participantData.ratings.map(r => new Date(r.createdAt).getTime()))) : null}
|
new Date(Math.max(...participantData.ratings.map(r => new Date(r.createdAt).getTime()))) : null}
|
||||||
|
{@const overallResponses = participantData.overallResponses}
|
||||||
|
{@const lastUpdated = lastTimeseriesUpdate ?? (overallResponses ? new Date(overallResponses.createdAt) : null)}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-900">
|
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-900">
|
||||||
{participantData.name}
|
{participantData.name}
|
||||||
@@ -238,10 +240,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
|
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
|
||||||
{#if participantData.overallRating !== undefined}
|
{#if overallResponses}
|
||||||
<span class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800">
|
<div class="flex flex-col text-xs text-gray-700">
|
||||||
{participantData.overallRating}/100
|
<span class="font-medium text-gray-900">Temporal: <span class="text-blue-700">{overallResponses.temporal ?? '-'} / 100</span></span>
|
||||||
</span>
|
<span class="font-medium text-gray-900">Overall: <span class="text-indigo-700">{overallResponses.overall ?? '-'} / 100</span></span>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-gray-400">-</span>
|
<span class="text-gray-400">-</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,17 +1,60 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { db } from '$lib/server/db/index.js';
|
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 { eq, isNull, and } from 'drizzle-orm';
|
||||||
import { getFromS3, getFromS3WithRange } from '$lib/server/s3.js';
|
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 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)
|
// Get file metadata from database (only s3Key and contentType needed)
|
||||||
const files = await db.select({
|
const files = await db.select({
|
||||||
s3Key: audioFile.s3Key,
|
s3Key: audioFile.s3Key,
|
||||||
contentType: audioFile.contentType,
|
contentType: audioFile.contentType,
|
||||||
fileSize: audioFile.fileSize
|
fileSize: audioFile.fileSize,
|
||||||
|
tags: audioFile.tags
|
||||||
})
|
})
|
||||||
.from(audioFile)
|
.from(audioFile)
|
||||||
.where(and(
|
.where(and(
|
||||||
@@ -24,6 +67,11 @@ export async function GET({ params, request }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const file = files[0];
|
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
|
// Check if file has S3 key (new files) or fall back to error for old blob-based files
|
||||||
if (!file.s3Key) {
|
if (!file.s3Key) {
|
||||||
|
|||||||
@@ -28,7 +28,14 @@ export async function GET() {
|
|||||||
// Get overall ratings
|
// Get overall ratings
|
||||||
const overallRatings = await db
|
const overallRatings = await db
|
||||||
.select({
|
.select({
|
||||||
overallRating,
|
overallRating: {
|
||||||
|
id: overallRating.id,
|
||||||
|
participantId: overallRating.participantId,
|
||||||
|
audioFileId: overallRating.audioFileId,
|
||||||
|
value: overallRating.value,
|
||||||
|
temporalValue: overallRating.temporalValue,
|
||||||
|
createdAt: overallRating.createdAt
|
||||||
|
},
|
||||||
participant,
|
participant,
|
||||||
audioFile,
|
audioFile,
|
||||||
inviteLink
|
inviteLink
|
||||||
@@ -96,14 +103,23 @@ export async function GET() {
|
|||||||
|
|
||||||
// Ensure the participant exists in timeseriesData
|
// Ensure the participant exists in timeseriesData
|
||||||
if (timeseriesData[audioId] && timeseriesData[audioId].participants[participantId]) {
|
if (timeseriesData[audioId] && timeseriesData[audioId].participants[participantId]) {
|
||||||
timeseriesData[audioId].participants[participantId].overallRating = entry.overallRating.value;
|
timeseriesData[audioId].participants[participantId].overallResponses = {
|
||||||
|
temporal: entry.overallRating.temporalValue,
|
||||||
|
overall: entry.overallRating.value,
|
||||||
|
createdAt: entry.overallRating.createdAt
|
||||||
|
};
|
||||||
|
timeseriesData[audioId].participants[participantId].isCompleted = true;
|
||||||
} else if (timeseriesData[audioId]) {
|
} else if (timeseriesData[audioId]) {
|
||||||
// Participant has overall rating but no timeseries data yet
|
// Participant has overall rating but no timeseries data yet
|
||||||
timeseriesData[audioId].participants[participantId] = {
|
timeseriesData[audioId].participants[participantId] = {
|
||||||
name: participantName,
|
name: participantName,
|
||||||
ratings: [],
|
ratings: [],
|
||||||
isCompleted: false,
|
isCompleted: true,
|
||||||
overallRating: entry.overallRating.value
|
overallResponses: {
|
||||||
|
temporal: entry.overallRating.temporalValue,
|
||||||
|
overall: entry.overallRating.value,
|
||||||
|
createdAt: entry.overallRating.createdAt
|
||||||
|
}
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// Neither audio nor participant exists in timeseriesData yet
|
// Neither audio nor participant exists in timeseriesData yet
|
||||||
@@ -113,8 +129,12 @@ export async function GET() {
|
|||||||
[participantId]: {
|
[participantId]: {
|
||||||
name: participantName,
|
name: participantName,
|
||||||
ratings: [],
|
ratings: [],
|
||||||
isCompleted: false,
|
isCompleted: true,
|
||||||
overallRating: entry.overallRating.value
|
overallResponses: {
|
||||||
|
temporal: entry.overallRating.temporalValue,
|
||||||
|
overall: entry.overallRating.value,
|
||||||
|
createdAt: entry.overallRating.createdAt
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -132,6 +152,7 @@ export async function GET() {
|
|||||||
'Status',
|
'Status',
|
||||||
'Data Points Count',
|
'Data Points Count',
|
||||||
'Duration Covered (seconds)',
|
'Duration Covered (seconds)',
|
||||||
|
'Temporal Rating (0-100)',
|
||||||
'Overall Rating (0-100)',
|
'Overall Rating (0-100)',
|
||||||
'Timeseries Data (timestamp:value pairs)',
|
'Timeseries Data (timestamp:value pairs)',
|
||||||
'Last Updated'
|
'Last Updated'
|
||||||
@@ -155,8 +176,10 @@ export async function GET() {
|
|||||||
Math.max(...participantData.ratings.map(r => r.timestamp)) : 0;
|
Math.max(...participantData.ratings.map(r => r.timestamp)) : 0;
|
||||||
const durationCovered = maxTimestamp - minTimestamp;
|
const durationCovered = maxTimestamp - minTimestamp;
|
||||||
|
|
||||||
const lastUpdated = participantData.ratings.length > 0 ?
|
const overallResponses = participantData.overallResponses;
|
||||||
|
const lastTimeseriesUpdate = participantData.ratings.length > 0 ?
|
||||||
new Date(Math.max(...participantData.ratings.map(r => new Date(r.createdAt).getTime()))) : null;
|
new Date(Math.max(...participantData.ratings.map(r => new Date(r.createdAt).getTime()))) : null;
|
||||||
|
const lastUpdated = lastTimeseriesUpdate ?? (overallResponses ? new Date(overallResponses.createdAt) : null);
|
||||||
|
|
||||||
// Use the stored timeseries data from the rating record
|
// Use the stored timeseries data from the rating record
|
||||||
const timeseriesData = participantData.ratings.find(r => r.isCompleted && r.timeseriesData)?.timeseriesData ||
|
const timeseriesData = participantData.ratings.find(r => r.isCompleted && r.timeseriesData)?.timeseriesData ||
|
||||||
@@ -168,7 +191,8 @@ export async function GET() {
|
|||||||
participantData.isCompleted ? 'Completed' : 'In Progress',
|
participantData.isCompleted ? 'Completed' : 'In Progress',
|
||||||
participantData.ratings.length,
|
participantData.ratings.length,
|
||||||
durationCovered.toFixed(2),
|
durationCovered.toFixed(2),
|
||||||
participantData.overallRating !== undefined ? participantData.overallRating : '-',
|
overallResponses?.temporal ?? '-',
|
||||||
|
overallResponses?.overall ?? '-',
|
||||||
timeseriesData,
|
timeseriesData,
|
||||||
lastUpdated ? lastUpdated.toLocaleString() : '-'
|
lastUpdated ? lastUpdated.toLocaleString() : '-'
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { db } from '$lib/server/db/index.js';
|
import { db } from '$lib/server/db/index.js';
|
||||||
import { inviteLink, participant, audioFile, rating } from '$lib/server/db/schema.js';
|
import { inviteLink, participant, audioFile, rating, overallRating } 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';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
import shuffle from 'knuth-shuffle-seeded';
|
||||||
|
|
||||||
export async function load({ url, cookies }) {
|
export async function load({ url, cookies }) {
|
||||||
const token = url.searchParams.get('token');
|
const token = url.searchParams.get('token');
|
||||||
@@ -21,7 +24,8 @@ export async function load({ url, cookies }) {
|
|||||||
throw error(404, 'Invite link not found or has been deleted');
|
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 participantId = cookies.get(`participant-${token}`);
|
||||||
let isExistingParticipant = false;
|
let isExistingParticipant = false;
|
||||||
@@ -66,49 +70,93 @@ export async function load({ url, cookies }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const audioFiles = await db.select({
|
const audioRows = await db.select({
|
||||||
id: audioFile.id,
|
id: audioFile.id,
|
||||||
filename: audioFile.filename,
|
filename: audioFile.filename,
|
||||||
contentType: audioFile.contentType,
|
contentType: audioFile.contentType,
|
||||||
duration: audioFile.duration,
|
duration: audioFile.duration,
|
||||||
fileSize: audioFile.fileSize,
|
fileSize: audioFile.fileSize,
|
||||||
createdAt: audioFile.createdAt
|
createdAt: audioFile.createdAt,
|
||||||
|
tags: audioFile.tags
|
||||||
})
|
})
|
||||||
.from(audioFile)
|
.from(audioFile)
|
||||||
.where(isNull(audioFile.deletedAt)); // Only show active audio files
|
.where(isNull(audioFile.deletedAt)); // Only show active audio files
|
||||||
|
|
||||||
// Get completed ratings for this participant (only active, non-deleted ratings for active audio files)
|
const baseAudioList = audioRows
|
||||||
const completedRatings = await db
|
.map((audio) => ({
|
||||||
.select({
|
data: {
|
||||||
audioFileId: rating.audioFileId
|
id: audio.id,
|
||||||
})
|
filename: audio.filename,
|
||||||
.from(rating)
|
contentType: audio.contentType,
|
||||||
.innerJoin(audioFile, and(
|
duration: audio.duration,
|
||||||
eq(rating.audioFileId, audioFile.id),
|
fileSize: audio.fileSize,
|
||||||
isNull(audioFile.deletedAt) // Only count ratings for active audio files
|
createdAt: audio.createdAt
|
||||||
))
|
},
|
||||||
.where(
|
tags: parseStoredTags(audio.tags)
|
||||||
and(
|
}))
|
||||||
eq(rating.participantId, participantId),
|
.filter(({ tags }) => matchesInviteTags(tags, inviteTags))
|
||||||
eq(rating.isCompleted, true),
|
.map(({ data }) => data);
|
||||||
isNull(rating.deletedAt) // Only count active ratings
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const completedAudioIds = new Set(completedRatings.map(r => r.audioFileId));
|
const shuffleSeed = participantId || token;
|
||||||
|
const filteredAudio = baseAudioList.length > 0
|
||||||
|
? shuffle([...baseAudioList], shuffleSeed)
|
||||||
|
: baseAudioList;
|
||||||
|
|
||||||
// Add completion status to audio files
|
const allowedAudioIds = filteredAudio.map((file) => file.id);
|
||||||
const audioFilesWithStatus = audioFiles.map(file => ({
|
|
||||||
|
const displayContinuousRating = env.DISPLAY_CONT_RATING !== 'false';
|
||||||
|
let completedRatings = [];
|
||||||
|
if (allowedAudioIds.length > 0) {
|
||||||
|
if (displayContinuousRating) {
|
||||||
|
completedRatings = await db
|
||||||
|
.select({
|
||||||
|
audioFileId: rating.audioFileId
|
||||||
|
})
|
||||||
|
.from(rating)
|
||||||
|
.innerJoin(audioFile, and(
|
||||||
|
eq(rating.audioFileId, audioFile.id),
|
||||||
|
isNull(audioFile.deletedAt)
|
||||||
|
))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(rating.participantId, participantId),
|
||||||
|
eq(rating.isCompleted, true),
|
||||||
|
isNull(rating.deletedAt),
|
||||||
|
inArray(rating.audioFileId, allowedAudioIds)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
completedRatings = await db
|
||||||
|
.select({ audioFileId: overallRating.audioFileId })
|
||||||
|
.from(overallRating)
|
||||||
|
.innerJoin(audioFile, and(
|
||||||
|
eq(overallRating.audioFileId, audioFile.id),
|
||||||
|
isNull(audioFile.deletedAt)
|
||||||
|
))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(overallRating.participantId, participantId),
|
||||||
|
isNull(overallRating.deletedAt),
|
||||||
|
inArray(overallRating.audioFileId, allowedAudioIds)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const completedAudioIds = new Set(completedRatings.map((r) => r.audioFileId));
|
||||||
|
|
||||||
|
const audioFilesWithStatus = filteredAudio.map(file => ({
|
||||||
...file,
|
...file,
|
||||||
isCompleted: completedAudioIds.has(file.id)
|
isCompleted: completedAudioIds.has(file.id)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
invite,
|
invite: safeInvite,
|
||||||
participantId,
|
participantId,
|
||||||
audioFiles: audioFilesWithStatus,
|
audioFiles: audioFilesWithStatus,
|
||||||
token,
|
token,
|
||||||
completedCount: completedRatings.length,
|
completedCount: completedRatings.length,
|
||||||
totalCount: audioFiles.length
|
totalCount: filteredAudio.length,
|
||||||
|
displayContinuousRating
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script>
|
<script>
|
||||||
export let data;
|
export let data;
|
||||||
|
|
||||||
|
const showContinuousRating = data?.displayContinuousRating ?? true;
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen bg-gray-50 py-8">
|
<div class="min-h-screen bg-gray-50 py-8">
|
||||||
@@ -11,8 +13,12 @@
|
|||||||
<p class="text-lg text-gray-600">Hello, {data.invite.participantName}!</p>
|
<p class="text-lg text-gray-600">Hello, {data.invite.participantName}!</p>
|
||||||
{/if}
|
{/if}
|
||||||
<p class="mt-4 text-gray-600">
|
<p class="mt-4 text-gray-600">
|
||||||
Thank you for participating in our study. You will listen to audio files and rate them using
|
Thank you for participating in our study. You will listen to audio files
|
||||||
a slider. Click "Submit Rating" when you're finished rating each file.
|
{#if showContinuousRating}
|
||||||
|
and rate them using a slider. Click "Submit Rating" when you're finished rating each file.
|
||||||
|
{:else}
|
||||||
|
and rate them.
|
||||||
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -50,8 +56,11 @@
|
|||||||
<h2 class="mb-4 text-xl font-semibold text-gray-900">Instructions</h2>
|
<h2 class="mb-4 text-xl font-semibold text-gray-900">Instructions</h2>
|
||||||
<ul class="list-inside list-disc space-y-2 text-gray-700">
|
<ul class="list-inside list-disc space-y-2 text-gray-700">
|
||||||
<li>Click on any audio file below to start listening and rating</li>
|
<li>Click on any audio file below to start listening and rating</li>
|
||||||
<li>Use the slider to rate the audio while listening (move it as your opinion changes)</li>
|
{#if showContinuousRating}
|
||||||
<li>Click "Submit Rating" when you're finished to save your rating</li>
|
<li>Use the slider to rate the audio while listening (move it as your opinion changes)</li>
|
||||||
|
<li>Click "Submit Rating" when you're finished to save your rating</li>
|
||||||
|
{:else}
|
||||||
|
{/if}
|
||||||
<li>If you've already rated a file, click "View" to see it again or submit a new rating to replace the old one</li>
|
<li>If you've already rated a file, click "View" to see it again or submit a new rating to replace the old one</li>
|
||||||
<li>You can come back later to rate remaining files</li>
|
<li>You can come back later to rate remaining files</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,7 +1,65 @@
|
|||||||
import { redirect, error } from '@sveltejs/kit';
|
import { redirect, error } from '@sveltejs/kit';
|
||||||
import { db } from '$lib/server/db/index.js';
|
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 { eq, and, isNull } from 'drizzle-orm';
|
||||||
|
import { parseStoredTags, matchesInviteTags } from '$lib/server/tag-utils.js';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
|
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 }) {
|
export async function load({ params, url, cookies }) {
|
||||||
const audioId = params.id;
|
const audioId = params.id;
|
||||||
@@ -16,6 +74,24 @@ export async function load({ params, url, cookies }) {
|
|||||||
redirect(302, `/participate?token=${token}`);
|
redirect(302, `/participate?token=${token}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const displayContinuousRating = env.DISPLAY_CONT_RATING !== 'false';
|
||||||
|
|
||||||
|
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
|
// Verify participant exists and isn't soft-deleted
|
||||||
const participants = await db
|
const participants = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -37,7 +113,8 @@ export async function load({ params, url, cookies }) {
|
|||||||
contentType: audioFile.contentType,
|
contentType: audioFile.contentType,
|
||||||
duration: audioFile.duration,
|
duration: audioFile.duration,
|
||||||
fileSize: audioFile.fileSize,
|
fileSize: audioFile.fileSize,
|
||||||
createdAt: audioFile.createdAt
|
createdAt: audioFile.createdAt,
|
||||||
|
tags: audioFile.tags
|
||||||
})
|
})
|
||||||
.from(audioFile)
|
.from(audioFile)
|
||||||
.where(and(
|
.where(and(
|
||||||
@@ -49,6 +126,13 @@ export async function load({ params, url, cookies }) {
|
|||||||
throw error(404, 'Audio file not found');
|
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
|
const progressData = await db
|
||||||
.select({
|
.select({
|
||||||
id: participantProgress.id,
|
id: participantProgress.id,
|
||||||
@@ -76,12 +160,30 @@ export async function load({ params, url, cookies }) {
|
|||||||
isNull(rating.deletedAt)
|
isNull(rating.deletedAt)
|
||||||
));
|
));
|
||||||
|
|
||||||
|
const existingOverallRatings = await db
|
||||||
|
.select()
|
||||||
|
.from(overallRating)
|
||||||
|
.where(and(
|
||||||
|
eq(overallRating.participantId, participantId),
|
||||||
|
eq(overallRating.audioFileId, audioId),
|
||||||
|
isNull(overallRating.deletedAt)
|
||||||
|
));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
audioFile: audioFiles[0],
|
audioFile: {
|
||||||
|
id: audioRecord.id,
|
||||||
|
filename: audioRecord.filename,
|
||||||
|
contentType: audioRecord.contentType,
|
||||||
|
duration: audioRecord.duration,
|
||||||
|
fileSize: audioRecord.fileSize,
|
||||||
|
createdAt: audioRecord.createdAt
|
||||||
|
},
|
||||||
participantId,
|
participantId,
|
||||||
token,
|
token,
|
||||||
progress,
|
progress,
|
||||||
existingRatings
|
existingRatings,
|
||||||
|
existingOverallRatings,
|
||||||
|
displayContinuousRating
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +202,11 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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);
|
const ratingHistory = JSON.parse(ratingHistoryStr);
|
||||||
console.log('Rating history length:', ratingHistory.length);
|
console.log('Rating history length:', ratingHistory.length);
|
||||||
console.log('Rating history:', ratingHistory);
|
console.log('Rating history:', ratingHistory);
|
||||||
@@ -185,6 +292,11 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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
|
// Soft delete the active rating for this participant and audio file
|
||||||
await db.update(rating)
|
await db.update(rating)
|
||||||
.set({ deletedAt: new Date() })
|
.set({ deletedAt: new Date() })
|
||||||
@@ -259,8 +371,9 @@ export const actions = {
|
|||||||
const participantId = data.get('participantId');
|
const participantId = data.get('participantId');
|
||||||
const audioFileId = data.get('audioFileId');
|
const audioFileId = data.get('audioFileId');
|
||||||
const overallRatingValue = parseFloat(data.get('overallRating'));
|
const overallRatingValue = parseFloat(data.get('overallRating'));
|
||||||
|
const temporalRatingValue = parseFloat(data.get('temporalRating'));
|
||||||
|
|
||||||
if (!participantId || !audioFileId || isNaN(overallRatingValue)) {
|
if (!participantId || !audioFileId || isNaN(overallRatingValue) || isNaN(temporalRatingValue)) {
|
||||||
return { error: 'Invalid overall rating data' };
|
return { error: 'Invalid overall rating data' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,6 +399,7 @@ export const actions = {
|
|||||||
id: overallRatingId,
|
id: overallRatingId,
|
||||||
participantId,
|
participantId,
|
||||||
audioFileId,
|
audioFileId,
|
||||||
|
temporalValue: temporalRatingValue,
|
||||||
value: overallRatingValue,
|
value: overallRatingValue,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import OverallRatingModal from '$lib/components/OverallRatingModal.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}`;
|
||||||
|
const showContinuousRating = data?.displayContinuousRating ?? true;
|
||||||
|
const existingOverallRatings = data?.existingOverallRatings ?? [];
|
||||||
|
let hasSubmittedOverallRating = existingOverallRatings.length > 0;
|
||||||
|
|
||||||
let audio;
|
let audio;
|
||||||
let isPlaying = false;
|
let isPlaying = false;
|
||||||
@@ -31,7 +36,7 @@
|
|||||||
currentTime = 0;
|
currentTime = 0;
|
||||||
|
|
||||||
console.log('Component mounted, audio file:', data.audioFile);
|
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
|
// Always reset completion tracking - require full listening every time
|
||||||
hasListenedToEnd = false;
|
hasListenedToEnd = false;
|
||||||
@@ -69,7 +74,7 @@
|
|||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/audio/${data.audioFile.id}`);
|
const response = await fetch(audioApiUrl);
|
||||||
const arrayBuffer = await response.arrayBuffer();
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||||
@@ -233,6 +238,9 @@
|
|||||||
hasListenedToEnd = true;
|
hasListenedToEnd = true;
|
||||||
isAudioCompleted = true;
|
isAudioCompleted = true;
|
||||||
maxReachedTime = duration;
|
maxReachedTime = duration;
|
||||||
|
if (!showContinuousRating && !hasSubmittedOverallRating) {
|
||||||
|
showOverallRatingModal = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRatingChange() {
|
function handleRatingChange() {
|
||||||
@@ -288,13 +296,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleOverallRatingSubmit(event) {
|
async function handleOverallRatingSubmit(event) {
|
||||||
const { overallRating } = event.detail;
|
const { overallRating, temporalRating } = event.detail;
|
||||||
isSubmittingOverallRating = true;
|
isSubmittingOverallRating = true;
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('participantId', data.participantId);
|
formData.append('participantId', data.participantId);
|
||||||
formData.append('audioFileId', data.audioFile.id);
|
formData.append('audioFileId', data.audioFile.id);
|
||||||
formData.append('overallRating', overallRating.toString());
|
formData.append('overallRating', overallRating.toString());
|
||||||
|
formData.append('temporalRating', temporalRating.toString());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('?/saveOverallRating', {
|
const response = await fetch('?/saveOverallRating', {
|
||||||
@@ -315,9 +324,15 @@
|
|||||||
alert('Error saving overall rating. Please try again.');
|
alert('Error saving overall rating. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
isSubmittingOverallRating = false;
|
isSubmittingOverallRating = false;
|
||||||
|
hasSubmittedOverallRating = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openOverallRatingModal() {
|
||||||
|
if (!isAudioCompleted || isSubmittingOverallRating) return;
|
||||||
|
showOverallRatingModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
function handleOverallRatingClose() {
|
function handleOverallRatingClose() {
|
||||||
if (isSubmittingOverallRating) return; // Prevent closing while submitting
|
if (isSubmittingOverallRating) return; // Prevent closing while submitting
|
||||||
showOverallRatingModal = false;
|
showOverallRatingModal = false;
|
||||||
@@ -362,15 +377,19 @@
|
|||||||
|
|
||||||
<div class="rounded-lg bg-white p-8 shadow">
|
<div class="rounded-lg bg-white p-8 shadow">
|
||||||
<h1 class="mb-2 text-2xl font-bold text-gray-900">{data.audioFile.filename}</h1>
|
<h1 class="mb-2 text-2xl font-bold text-gray-900">{data.audioFile.filename}</h1>
|
||||||
<p class="mb-8 text-gray-600">
|
<p class="mb-8 text-gray-600">
|
||||||
Listen to the audio and move the slider to rate it continuously
|
{#if showContinuousRating}
|
||||||
</p>
|
Listen to the audio and move the slider to rate it continuously
|
||||||
|
{:else}
|
||||||
|
Listen closely to the entire audio clip. Continuous slider ratings are disabled; you'll answer two quick questions after listening.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
|
||||||
<!-- Audio Player -->
|
<!-- Audio Player -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<audio
|
<audio
|
||||||
bind:this={audio}
|
bind:this={audio}
|
||||||
src="/api/audio/{data.audioFile.id}"
|
src={audioApiUrl}
|
||||||
on:timeupdate={handleTimeUpdate}
|
on:timeupdate={handleTimeUpdate}
|
||||||
on:loadedmetadata={handleLoadedMetadata}
|
on:loadedmetadata={handleLoadedMetadata}
|
||||||
on:play={handlePlay}
|
on:play={handlePlay}
|
||||||
@@ -476,80 +495,112 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Rating Slider -->
|
<!-- Rating Slider -->
|
||||||
<div class="space-y-6">
|
{#if showContinuousRating}
|
||||||
{#if data.existingRatings && data.existingRatings.length > 0}
|
<div class="space-y-6">
|
||||||
<div class="rounded-md border border-amber-200 bg-amber-50 p-4">
|
{#if data.existingRatings && data.existingRatings.length > 0}
|
||||||
<p class="font-medium text-amber-800">⚠️ You have already submitted a rating for this audio file</p>
|
<div class="rounded-md border border-amber-200 bg-amber-50 p-4">
|
||||||
<p class="text-sm text-amber-700 mt-1">
|
<p class="font-medium text-amber-800">⚠️ You have already submitted a rating for this audio file</p>
|
||||||
To redo your rating: listen to the full audio clip again, adjust the slider as needed, and click "Update Rating".
|
<p class="text-sm text-amber-700 mt-1">
|
||||||
This will replace your previous rating completely.
|
To redo your rating: listen to the full audio clip again, adjust the slider as needed, and click "Update Rating".
|
||||||
</p>
|
This will replace your previous rating completely.
|
||||||
</div>
|
</p>
|
||||||
{/if}
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 class="mb-4 text-lg font-medium text-gray-900">How well do you think the performers manage to follow the temporal coordination requirements?</h3>
|
<h3 class="mb-4 text-lg font-medium text-gray-900">How well do you think the performers manage to follow the temporal coordination requirements?</h3>
|
||||||
<p class="mb-4 text-sm text-gray-600">
|
<p class="mb-4 text-sm text-gray-600">
|
||||||
Move the slider as you listen to rate the audio continuously. You must listen to the full audio clip before you can submit your rating.
|
Move the slider as you listen to rate the audio continuously. You must listen to the full audio clip before you can submit your rating.
|
||||||
</p>
|
</p>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="0"
|
min="0"
|
||||||
max="100"
|
max="100"
|
||||||
bind:value={ratingValue}
|
bind:value={ratingValue}
|
||||||
on:input={handleRatingChange}
|
on:input={handleRatingChange}
|
||||||
class="slider h-3 w-full cursor-pointer appearance-none rounded-lg bg-gray-200"
|
class="slider h-3 w-full cursor-pointer appearance-none rounded-lg bg-gray-200"
|
||||||
/>
|
/>
|
||||||
<div class="flex justify-between text-sm text-gray-600">
|
<div class="flex justify-between text-sm text-gray-600">
|
||||||
<span>Not at all (0)</span>
|
<span>Not at all (0)</span>
|
||||||
<span class="font-semibold">Current Rating: {ratingValue}</span>
|
<span class="font-semibold">Current Rating: {ratingValue}</span>
|
||||||
<span>Perfectly (100)</span>
|
<span>Perfectly (100)</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Save Rating Button -->
|
<!-- Save Rating Button -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<button
|
<button
|
||||||
on:click={saveRating}
|
on:click={saveRating}
|
||||||
disabled={!hasRatingChanged || isSavingRating || !isAudioCompleted}
|
disabled={!hasRatingChanged || isSavingRating || !isAudioCompleted}
|
||||||
class="flex items-center justify-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white {(!hasRatingChanged || isSavingRating || !isAudioCompleted) ? 'bg-gray-400 cursor-not-allowed' : 'bg-indigo-600 hover:bg-indigo-700'} transition-colors"
|
class="flex items-center justify-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white {(!hasRatingChanged || isSavingRating || !isAudioCompleted) ? 'bg-gray-400 cursor-not-allowed' : 'bg-indigo-600 hover:bg-indigo-700'} transition-colors"
|
||||||
>
|
>
|
||||||
{#if isSavingRating}
|
{#if isSavingRating}
|
||||||
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||||||
Saving...
|
Saving...
|
||||||
{:else}
|
|
||||||
{#if data.existingRatings && data.existingRatings.length > 0}
|
|
||||||
Update Rating
|
|
||||||
{:else}
|
{:else}
|
||||||
Submit Rating
|
{#if data.existingRatings && data.existingRatings.length > 0}
|
||||||
|
Update Rating
|
||||||
|
{:else}
|
||||||
|
Submit Rating
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
</button>
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="flex flex-col items-end space-y-1">
|
<div class="flex flex-col items-end space-y-1">
|
||||||
{#if hasRatingChanged}
|
{#if hasRatingChanged}
|
||||||
<p class="text-sm text-amber-600">
|
<p class="text-sm text-amber-600">
|
||||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||||
You have unsaved changes
|
You have unsaved changes
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !isAudioCompleted}
|
{#if !isAudioCompleted}
|
||||||
<p class="text-sm text-red-600">
|
<p class="text-sm text-red-600">
|
||||||
<i class="fas fa-play-circle mr-1"></i>
|
<i class="fas fa-play-circle mr-1"></i>
|
||||||
Must listen to full audio clip to submit
|
Must listen to full audio clip to submit
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if data.progress?.isCompleted}
|
||||||
|
<div class="rounded-md border border-blue-200 bg-blue-50 p-4">
|
||||||
|
<p class="font-medium text-blue-800">✓ You have finished listening to this audio file</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
{#if data.progress?.isCompleted}
|
<div class="mt-10 rounded-lg border border-blue-200 bg-blue-50 p-6 space-y-4">
|
||||||
<div class="rounded-md border border-blue-200 bg-blue-50 p-4">
|
{#if existingOverallRatings.length > 0 || hasSubmittedOverallRating}
|
||||||
<p class="font-medium text-blue-800">✓ You have finished listening to this audio file</p>
|
<p class="text-sm text-blue-800 font-medium">
|
||||||
</div>
|
You have already answered the required questions for this clip.
|
||||||
{/if}
|
</p>
|
||||||
</div>
|
<a
|
||||||
|
href="/participate?token={data.token}"
|
||||||
|
class="inline-flex items-center justify-center rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
|
||||||
|
>
|
||||||
|
Return to Dashboard
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-blue-800 font-medium">
|
||||||
|
After fully listening to the clip, please answer two quick questions.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
on:click={openOverallRatingModal}
|
||||||
|
disabled={!isAudioCompleted || isSubmittingOverallRating}
|
||||||
|
class="inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium text-white {(!isAudioCompleted || isSubmittingOverallRating) ? 'bg-gray-500 cursor-not-allowed' : 'bg-indigo-600 hover:bg-indigo-700'}"
|
||||||
|
>
|
||||||
|
Answer Questions
|
||||||
|
</button>
|
||||||
|
{#if !isAudioCompleted}
|
||||||
|
<p class="text-xs text-blue-700">
|
||||||
|
Listen to the entire clip to enable the questions.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user