first working demo

This commit is contained in:
2025-07-22 17:03:36 +02:00
parent f58e9ef5a2
commit b084910dc0
52 changed files with 11455 additions and 138 deletions

26
.dockerignore Normal file
View File

@@ -0,0 +1,26 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# SQLite
*.db

View File

@@ -1,5 +1,9 @@
# Replace with your DB credentials!
DATABASE_URL="libsql://db-name-user.turso.io" DATABASE_URL="libsql://db-name-user.turso.io"
DATABASE_AUTH_TOKEN="" DATABASE_AUTH_TOKEN=""
# A local DB can also be used in dev as well
# DATABASE_URL="file:local.db" AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_ENDPOINT_URL_S3=
AWS_ENDPOINT_URL_IAM=
AWS_REGION=
BUCKET_NAME

47
Dockerfile Normal file
View File

@@ -0,0 +1,47 @@
# syntax = docker/dockerfile:1
# Adjust NODE_VERSION as desired
ARG NODE_VERSION=22.12.0
FROM node:${NODE_VERSION}-slim AS base
LABEL fly_launch_runtime="SvelteKit"
# SvelteKit app lives here
WORKDIR /app
# Set production environment
ENV NODE_ENV="production"
# Throw-away build stage to reduce size of final image
FROM base AS build
# Install packages needed to build node modules
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y build-essential node-gyp pkg-config python-is-python3
# Install node modules
COPY .npmrc package-lock.json package.json ./
RUN npm ci --include=dev
# Copy application code
COPY . .
# Build application
RUN npm run build
# Remove development dependencies
RUN npm prune --omit=dev
# Final stage for app image
FROM base
# Copy built application
COPY --from=build /app/build /app/build
COPY --from=build /app/node_modules /app/node_modules
COPY --from=build /app/package.json /app
# Start the server by default, this can be overwritten at runtime
EXPOSE 3000
CMD [ "node", "./build/index.js" ]

View File

@@ -19,6 +19,7 @@ The admin backend should be password protected. The username and password are st
- Create invite links for participants - Create invite links for participants
- View existing invite links - View existing invite links
- View participant ratings - View participant ratings
- Upload and manage audio files which are saved as blobs in the database.
### User frontend ### User frontend

View File

@@ -2,13 +2,19 @@ import { defineConfig } from 'drizzle-kit';
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set'); if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
const isLocalDb = process.env.DATABASE_URL.startsWith('file:');
export default defineConfig({ export default defineConfig({
schema: './src/lib/server/db/schema.js', schema: './src/lib/server/db/schema.js',
dialect: 'turso', dialect: isLocalDb ? 'sqlite' : 'turso',
dbCredentials: { dbCredentials: isLocalDb
authToken: process.env.DATABASE_AUTH_TOKEN, ? {
url: process.env.DATABASE_URL url: process.env.DATABASE_URL
}, }
: {
authToken: process.env.DATABASE_AUTH_TOKEN,
url: process.env.DATABASE_URL
},
verbose: true, verbose: true,
strict: true strict: true
}); });

View File

@@ -0,0 +1,64 @@
CREATE TABLE `audio_file` (
`id` text PRIMARY KEY NOT NULL,
`filename` text NOT NULL,
`content_type` text NOT NULL,
`data` blob NOT NULL,
`duration` real,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `invite_link` (
`id` text PRIMARY KEY NOT NULL,
`token` text NOT NULL,
`participant_name` text,
`is_used` integer DEFAULT false NOT NULL,
`created_at` integer NOT NULL,
`used_at` integer
);
--> statement-breakpoint
CREATE UNIQUE INDEX `invite_link_token_unique` ON `invite_link` (`token`);--> statement-breakpoint
CREATE TABLE `participant` (
`id` text PRIMARY KEY NOT NULL,
`invite_token` text NOT NULL,
`session_id` text,
`created_at` integer NOT NULL,
FOREIGN KEY (`invite_token`) REFERENCES `invite_link`(`token`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `participant_progress` (
`id` text PRIMARY KEY NOT NULL,
`participant_id` text NOT NULL,
`audio_file_id` text NOT NULL,
`is_completed` integer DEFAULT false NOT NULL,
`last_position` real DEFAULT 0,
`updated_at` integer NOT NULL,
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
CREATE TABLE `rating` (
`id` text PRIMARY KEY NOT NULL,
`participant_id` text NOT NULL,
`audio_file_id` text NOT NULL,
`timestamp` real NOT NULL,
`value` real NOT NULL,
`created_at` integer NOT NULL,
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
CREATE TABLE `session` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`expires_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `user` (
`id` text PRIMARY KEY NOT NULL,
`age` integer,
`username` text NOT NULL,
`password_hash` text NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `user_username_unique` ON `user` (`username`);

View File

@@ -0,0 +1,17 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_audio_file` (
`id` text PRIMARY KEY NOT NULL,
`filename` text NOT NULL,
`content_type` text NOT NULL,
`data` blob DEFAULT 'null',
`s3_key` text,
`duration` real,
`file_size` integer,
`created_at` integer NOT NULL
);
--> statement-breakpoint
INSERT INTO `__new_audio_file`("id", "filename", "content_type", "data", "s3_key", "duration", "file_size", "created_at") SELECT "id", "filename", "content_type", "data", "s3_key", "duration", "file_size", "created_at" FROM `audio_file`;--> statement-breakpoint
DROP TABLE `audio_file`;--> statement-breakpoint
ALTER TABLE `__new_audio_file` RENAME TO `audio_file`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE UNIQUE INDEX `participant_audio_unique` ON `rating` (`participant_id`,`audio_file_id`);

View File

@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS `participant_audio_unique`;--> statement-breakpoint
ALTER TABLE `invite_link` ADD `deleted_at` integer;--> statement-breakpoint
ALTER TABLE `participant` ADD `deleted_at` integer;

View File

@@ -0,0 +1,2 @@
ALTER TABLE `rating` ADD `is_completed` integer DEFAULT false NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX `participant_audio_completed` ON `rating` (`participant_id`,`audio_file_id`,`is_completed`);

View File

@@ -0,0 +1 @@
ALTER TABLE `participant_progress` ADD `max_reached_time` real DEFAULT 0;

View File

@@ -0,0 +1,31 @@
-- 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;

View File

@@ -0,0 +1,3 @@
DROP INDEX `participant_audio_completed`;--> statement-breakpoint
ALTER TABLE `rating` ADD `timeseries_data` text;--> statement-breakpoint
ALTER TABLE `rating` ADD `deleted_at` integer;

View File

@@ -0,0 +1 @@
ALTER TABLE `audio_file` ADD `deleted_at` integer;

0
drizzle/db.sqlite Normal file
View File

View File

@@ -0,0 +1,434 @@
{
"version": "6",
"dialect": "sqlite",
"id": "29dcbecc-3e5b-4e89-a4b5-49c97e069665",
"prevId": "00000000-0000-0000-0000-000000000000",
"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": true,
"autoincrement": false
},
"duration": {
"name": "duration",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"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
}
},
"indexes": {
"invite_link_token_unique": {
"name": "invite_link_token_unique",
"columns": [
"token"
],
"isUnique": true
}
},
"foreignKeys": {},
"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
}
},
"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
},
"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
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"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": {}
}
}

View File

@@ -0,0 +1,458 @@
{
"version": "6",
"dialect": "sqlite",
"id": "78968f6f-568d-4be6-b4da-369936bac11b",
"prevId": "29dcbecc-3e5b-4e89-a4b5-49c97e069665",
"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
}
},
"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
}
},
"indexes": {
"invite_link_token_unique": {
"name": "invite_link_token_unique",
"columns": [
"token"
],
"isUnique": true
}
},
"foreignKeys": {},
"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
}
},
"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
},
"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
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"participant_audio_unique": {
"name": "participant_audio_unique",
"columns": [
"participant_id",
"audio_file_id"
],
"isUnique": true
}
},
"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": {}
}
}

View File

@@ -0,0 +1,463 @@
{
"version": "6",
"dialect": "sqlite",
"id": "bfca0ef3-67c7-49d6-a67d-176c0dd5f2ee",
"prevId": "78968f6f-568d-4be6-b4da-369936bac11b",
"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
}
},
"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
}
},
"indexes": {
"invite_link_token_unique": {
"name": "invite_link_token_unique",
"columns": [
"token"
],
"isUnique": true
}
},
"foreignKeys": {},
"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
},
"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
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"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": {}
}
}

View File

@@ -0,0 +1,481 @@
{
"version": "6",
"dialect": "sqlite",
"id": "b4bcff19-04e0-4677-b2e8-4b617111c885",
"prevId": "bfca0ef3-67c7-49d6-a67d-176c0dd5f2ee",
"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
}
},
"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
}
},
"indexes": {
"invite_link_token_unique": {
"name": "invite_link_token_unique",
"columns": [
"token"
],
"isUnique": true
}
},
"foreignKeys": {},
"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
},
"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
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"participant_audio_completed": {
"name": "participant_audio_completed",
"columns": [
"participant_id",
"audio_file_id",
"is_completed"
],
"isUnique": true
}
},
"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": {}
}
}

View File

@@ -0,0 +1,489 @@
{
"version": "6",
"dialect": "sqlite",
"id": "3f8e600b-73f2-4fea-ac12-f94d975c1e30",
"prevId": "b4bcff19-04e0-4677-b2e8-4b617111c885",
"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
}
},
"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
}
},
"indexes": {
"invite_link_token_unique": {
"name": "invite_link_token_unique",
"columns": [
"token"
],
"isUnique": true
}
},
"foreignKeys": {},
"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
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"participant_audio_completed": {
"name": "participant_audio_completed",
"columns": [
"participant_id",
"audio_file_id",
"is_completed"
],
"isUnique": true
}
},
"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": {}
}
}

View File

@@ -0,0 +1,488 @@
{
"version": "6",
"dialect": "sqlite",
"id": "2688ac5d-3ede-406f-9fbc-9c7dd2026e27",
"prevId": "3f8e600b-73f2-4fea-ac12-f94d975c1e30",
"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
}
},
"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
}
},
"indexes": {
"invite_link_token_unique": {
"name": "invite_link_token_unique",
"columns": [
"token"
],
"isUnique": true
}
},
"foreignKeys": {},
"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
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"participant_audio_completed": {
"name": "participant_audio_completed",
"columns": [
"participant_id",
"audio_file_id"
],
"isUnique": true
}
},
"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": {}
}
}

View File

@@ -0,0 +1,496 @@
{
"version": "6",
"dialect": "sqlite",
"id": "8c621437-b949-4f04-bbd2-699a82f49581",
"prevId": "2688ac5d-3ede-406f-9fbc-9c7dd2026e27",
"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
}
},
"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
}
},
"indexes": {
"invite_link_token_unique": {
"name": "invite_link_token_unique",
"columns": [
"token"
],
"isUnique": true
}
},
"foreignKeys": {},
"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
},
"timeseries_data": {
"name": "timeseries_data",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_completed": {
"name": "is_completed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"participant_audio_completed": {
"name": "participant_audio_completed",
"columns": [
"participant_id",
"audio_file_id",
"is_completed"
],
"isUnique": true
}
},
"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": {}
}
}

View File

@@ -0,0 +1,493 @@
{
"version": "6",
"dialect": "sqlite",
"id": "bfb1d3f3-aa49-4d72-b784-c4ba2965cdd8",
"prevId": "8c621437-b949-4f04-bbd2-699a82f49581",
"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
}
},
"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
}
},
"indexes": {
"invite_link_token_unique": {
"name": "invite_link_token_unique",
"columns": [
"token"
],
"isUnique": true
}
},
"foreignKeys": {},
"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": {}
}
}

View File

@@ -0,0 +1,500 @@
{
"version": "6",
"dialect": "sqlite",
"id": "90bc9682-1832-405b-9b16-394f8fa6eacb",
"prevId": "bfb1d3f3-aa49-4d72-b784-c4ba2965cdd8",
"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
}
},
"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
}
},
"indexes": {
"invite_link_token_unique": {
"name": "invite_link_token_unique",
"columns": [
"token"
],
"isUnique": true
}
},
"foreignKeys": {},
"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": {}
}
}

View File

@@ -0,0 +1,69 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1753098085007,
"tag": "0000_perfect_night_nurse",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1753138132433,
"tag": "0001_easy_khan",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1753138993591,
"tag": "0002_yummy_kate_bishop",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1753139485031,
"tag": "0003_free_master_chief",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1753172882000,
"tag": "0004_curvy_hemingway",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1753174114025,
"tag": "0005_windy_gauntlet",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1753174248422,
"tag": "0006_tearful_crystal",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1753194638208,
"tag": "0007_colossal_betty_brant",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1753195639650,
"tag": "0008_wild_texas_twister",
"breakpoints": true
}
]
}

22
fly.toml Normal file
View File

@@ -0,0 +1,22 @@
# fly.toml app configuration file generated for taptapp on 2025-07-22T09:07:23+02:00
#
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
#
app = 'taptapp'
primary_region = 'fra'
[build]
[http_service]
internal_port = 3000
force_https = true
auto_stop_machines = 'stop'
auto_start_machines = true
min_machines_running = 0
processes = ['app']
[[vm]]
memory = '1gb'
cpu_kind = 'shared'
cpus = 1

54
migrate-to-s3.js Normal file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/env node
import { db } from './src/lib/server/db/index.js';
import { audioFile } from './src/lib/server/db/schema.js';
import { uploadToS3, generateAudioS3Key } from './src/lib/server/s3.js';
import { eq, isNull } from 'drizzle-orm';
async function migrateExistingFilesToS3() {
console.log('Starting migration of existing audio files to S3...');
try {
// Find all files that have blob data but no S3 key
const filesToMigrate = await db.select().from(audioFile).where(isNull(audioFile.s3Key));
console.log(`Found ${filesToMigrate.length} files to migrate`);
if (filesToMigrate.length === 0) {
console.log('No files need migration. All files are already using S3.');
return;
}
for (const file of filesToMigrate) {
console.log(`Migrating ${file.filename}...`);
try {
// Generate S3 key
const s3Key = generateAudioS3Key(file.id, file.filename);
// Upload blob data to S3
await uploadToS3(s3Key, file.data, file.contentType);
// Update database record with S3 key
await db.update(audioFile)
.set({ s3Key })
.where(eq(audioFile.id, file.id));
console.log(`✓ Migrated ${file.filename} to S3: ${s3Key}`);
} catch (error) {
console.error(`✗ Failed to migrate ${file.filename}:`, error);
throw error; // Stop migration on first error
}
}
console.log(`\n✅ Successfully migrated ${filesToMigrate.length} files to S3`);
console.log('You can now safely run the database schema migration to remove the blob column.');
} catch (error) {
console.error('Migration failed:', error);
process.exit(1);
}
}
// Run migration
migrateExistingFilesToS3();

3722
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,7 @@
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.2.5", "@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0", "@eslint/js": "^9.18.0",
"@flydotio/dockerfile": "^0.7.10",
"@sveltejs/adapter-auto": "^6.0.0", "@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/kit": "^2.22.0", "@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0", "@sveltejs/vite-plugin-svelte": "^6.0.0",
@@ -37,10 +38,14 @@
"vite": "^7.0.4" "vite": "^7.0.4"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
"@libsql/client": "^0.14.0", "@libsql/client": "^0.14.0",
"@node-rs/argon2": "^2.0.2", "@node-rs/argon2": "^2.0.2",
"@oslojs/crypto": "^1.0.1", "@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0", "@oslojs/encoding": "^1.1.0",
"drizzle-orm": "^0.40.0" "@sveltejs/adapter-node": "^5.2.13",
"chart.js": "^4.5.0",
"drizzle-orm": "^0.40.0",
"exceljs": "^4.4.0"
} }
} }

View File

@@ -4,6 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" /> <link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">

View File

@@ -4,12 +4,27 @@ import { createClient } from '@libsql/client';
import * as schema from './schema'; import * as schema from './schema';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set'); let _db = null;
if (!dev && !env.DATABASE_AUTH_TOKEN) throw new Error('DATABASE_AUTH_TOKEN is not set');
const client = createClient({ function initializeDatabase() {
url: env.DATABASE_URL, if (!_db) {
authToken: env.DATABASE_AUTH_TOKEN if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
if (!dev && !env.DATABASE_AUTH_TOKEN) throw new Error('DATABASE_AUTH_TOKEN is not set');
const client = createClient({
url: env.DATABASE_URL,
authToken: env.DATABASE_AUTH_TOKEN
});
_db = drizzle(client, { schema });
}
return _db;
}
// Create a proxy to make db behave like the original drizzle instance
export const db = new Proxy({}, {
get(target, prop) {
const database = initializeDatabase();
return database[prop];
}
}); });
export const db = drizzle(client, { schema });

View File

@@ -1,4 +1,5 @@
import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'; import { sqliteTable, integer, text, blob, real, uniqueIndex } from 'drizzle-orm/sqlite-core';
import { isNull } from 'drizzle-orm';
export const user = sqliteTable('user', { export const user = sqliteTable('user', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
@@ -14,3 +15,65 @@ export const session = sqliteTable('session', {
.references(() => user.id), .references(() => user.id),
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull() expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull()
}); });
export const audioFile = sqliteTable('audio_file', {
id: text('id').primaryKey(),
filename: text('filename').notNull(),
contentType: text('content_type').notNull(),
data: blob('data').default(null), // Temporarily keep for migration
s3Key: text('s3_key'), // Temporarily optional for migration
duration: real('duration'),
fileSize: integer('file_size'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
deletedAt: integer('deleted_at', { mode: 'timestamp' }) // Soft delete for audio files
});
export const inviteLink = sqliteTable('invite_link', {
id: text('id').primaryKey(),
token: text('token').notNull().unique(),
participantName: text('participant_name'),
isUsed: integer('is_used', { mode: 'boolean' }).notNull().default(false),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
usedAt: integer('used_at', { mode: 'timestamp' }),
deletedAt: integer('deleted_at', { mode: 'timestamp' })
});
export const participant = sqliteTable('participant', {
id: text('id').primaryKey(),
inviteToken: text('invite_token')
.notNull()
.references(() => inviteLink.token),
sessionId: text('session_id'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
deletedAt: integer('deleted_at', { mode: 'timestamp' })
});
export const rating = sqliteTable('rating', {
id: text('id').primaryKey(),
participantId: text('participant_id')
.notNull()
.references(() => participant.id),
audioFileId: text('audio_file_id')
.notNull()
.references(() => audioFile.id),
timestamp: real('timestamp').notNull(),
value: real('value').notNull(),
isCompleted: integer('is_completed', { mode: 'boolean' }).notNull().default(false),
timeseriesData: text('timeseries_data'), // JSON string of all rating changes (for completed ratings)
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
deletedAt: integer('deleted_at', { mode: 'timestamp' }) // Soft delete for redos
});
export const participantProgress = sqliteTable('participant_progress', {
id: text('id').primaryKey(),
participantId: text('participant_id')
.notNull()
.references(() => participant.id),
audioFileId: text('audio_file_id')
.notNull()
.references(() => audioFile.id),
isCompleted: integer('is_completed', { mode: 'boolean' }).notNull().default(false),
lastPosition: real('last_position').default(0),
maxReachedTime: real('max_reached_time').default(0),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull()
});

157
src/lib/server/s3.js Normal file
View File

@@ -0,0 +1,157 @@
import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
import { env } from '$env/dynamic/private';
let s3Client = null;
let BUCKET_NAME = null;
// Validate required environment variables
function validateEnvVars() {
const required = ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_REGION'];
const missing = required.filter(key => !env[key]);
if (missing.length > 0) {
throw new Error(`Missing required AWS environment variables: ${missing.join(', ')}`);
}
}
// Initialize S3 client lazily
function getS3Client() {
if (!s3Client) {
validateEnvVars();
// Get bucket name from environment or use default
BUCKET_NAME = env.BUCKET_NAME || 'taptapp-audio';
// Create S3 client configuration
const s3Config = {
region: env.AWS_REGION,
credentials: {
accessKeyId: env.AWS_ACCESS_KEY_ID,
secretAccessKey: env.AWS_SECRET_ACCESS_KEY
}
};
// Add endpoint if provided (for S3-compatible services)
if (env.AWS_ENDPOINT_URL_S3) {
s3Config.endpoint = env.AWS_ENDPOINT_URL_S3;
s3Config.forcePathStyle = true; // Required for some S3-compatible services
}
s3Client = new S3Client(s3Config);
console.log('S3 Client initialized:', {
region: env.AWS_REGION,
bucket: BUCKET_NAME,
hasEndpoint: !!env.AWS_ENDPOINT_URL_S3,
endpoint: env.AWS_ENDPOINT_URL_S3 || 'default AWS S3'
});
}
return { client: s3Client, bucket: BUCKET_NAME };
}
/**
* Upload a file to S3
* @param {string} key - The S3 key (path) for the file
* @param {Buffer} buffer - The file buffer
* @param {string} contentType - The content type of the file
* @returns {Promise<string>} - The S3 key of the uploaded file
*/
export async function uploadToS3(key, buffer, contentType) {
if (!key || !buffer || !contentType) {
throw new Error('Missing required parameters for S3 upload');
}
const { client, bucket } = getS3Client();
console.log(`Uploading to S3: ${key} (${buffer.length} bytes, ${contentType})`);
try {
const command = new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: buffer,
ContentType: contentType
});
await client.send(command);
console.log(`✓ Successfully uploaded ${key} to S3`);
return key;
} catch (error) {
console.error(`✗ Failed to upload ${key} to S3:`, error);
throw new Error(`S3 upload failed: ${error.message}`);
}
}
/**
* Get a file from S3
* @param {string} key - The S3 key of the file
* @returns {Promise<{stream: ReadableStream, contentType: string, contentLength: number}>}
*/
export async function getFromS3(key) {
const { client, bucket } = getS3Client();
const command = new GetObjectCommand({
Bucket: bucket,
Key: key
});
const response = await client.send(command);
return {
stream: response.Body,
contentType: response.ContentType,
contentLength: response.ContentLength
};
}
/**
* Get a file from S3 with range support
* @param {string} key - The S3 key of the file
* @param {string} range - The range header value (e.g., "bytes=0-1023")
* @returns {Promise<{stream: ReadableStream, contentType: string, contentLength: number, contentRange: string, acceptRanges: string}>}
*/
export async function getFromS3WithRange(key, range) {
const { client, bucket } = getS3Client();
const command = new GetObjectCommand({
Bucket: bucket,
Key: key,
Range: range
});
const response = await client.send(command);
return {
stream: response.Body,
contentType: response.ContentType,
contentLength: response.ContentLength,
contentRange: response.ContentRange,
acceptRanges: response.AcceptRanges
};
}
/**
* Delete a file from S3
* @param {string} key - The S3 key of the file
*/
export async function deleteFromS3(key) {
const { client, bucket } = getS3Client();
const command = new DeleteObjectCommand({
Bucket: bucket,
Key: key
});
await client.send(command);
}
/**
* Generate an S3 key for an audio file
* @param {string} fileId - The unique file ID
* @param {string} filename - The original filename
* @returns {string} - The S3 key
*/
export function generateAudioS3Key(fileId, filename) {
const extension = filename.split('.').pop();
return `audio/${fileId}.${extension}`;
}

View File

@@ -1,2 +1,50 @@
<h1>Welcome to SvelteKit</h1> <div class="min-h-screen bg-gradient-to-br from-indigo-50 to-white flex flex-col">
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p> <div class="flex-1 flex items-center justify-center px-4">
<div class="w-full max-w-md">
<div class="bg-white rounded-lg shadow-lg p-8">
<div class="text-center mb-8">
<div class="mb-4 text-green-600">
<svg class="mx-auto h-16 w-16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19V6l12-3v13M9 19c0 1.105-.895 2-2 2s-2-.895-2-2 .895-2 2-2 2 .895 2 2zm12-3c0 1.105-.895 2-2 2s-2-.895-2-2 .895-2 2-2 2 .895 2 2z"
></path>
</svg>
</div>
<h2 class="text-2xl font-semibold text-gray-900 mb-2">Join Audio Study</h2>
<p class="text-gray-600 mb-6">Enter your participation token to begin</p>
</div>
<form class="space-y-4" action="/participate" method="get">
<div>
<label for="token" class="block text-sm font-medium text-gray-700 mb-2">
Participation Token
</label>
<input
type="text"
id="token"
name="token"
placeholder="Enter your token here"
class="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
required
/>
</div>
<button
type="submit"
class="w-full bg-indigo-600 text-white py-3 px-4 rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition-colors font-medium"
>
Start Participation
</button>
</form>
</div>
</div>
</div>
<div class="p-4 text-center">
<a href="/admin" class="text-sm text-gray-500 hover:text-gray-700 underline">
Admin Panel
</a>
</div>
</div>

View File

@@ -0,0 +1,13 @@
import { redirect } from '@sveltejs/kit';
export async function load({ cookies, url }) {
const adminSession = cookies.get('admin-session');
if (!adminSession && url.pathname !== '/admin/login') {
redirect(302, '/admin/login');
}
return {
isAuthenticated: !!adminSession
};
}

View File

@@ -0,0 +1,46 @@
<script>
import { page } from '$app/stores';
export let data;
</script>
<div class="min-h-screen bg-gray-50">
{#if data.isAuthenticated && $page.url.pathname !== '/admin/login'}
<nav class="border-b bg-white shadow-sm">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 justify-between">
<div class="flex items-center">
<h1 class="text-xl font-semibold text-gray-900">TaptApp Admin</h1>
</div>
<div class="flex items-center space-x-4">
<a
href="/admin"
class="px-3 py-2 rounded-md text-sm font-medium {$page.url.pathname === '/admin' ? 'bg-gray-200 text-gray-900' : 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'}"
>
Invite Links
</a>
<a
href="/admin/audio"
class="px-3 py-2 rounded-md text-sm font-medium {$page.url.pathname === '/admin/audio' ? 'bg-gray-200 text-gray-900' : 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'}"
>
Audio Files
</a>
<a
href="/admin/ratings"
class="px-3 py-2 rounded-md text-sm font-medium {$page.url.pathname === '/admin/ratings' ? 'bg-gray-200 text-gray-900' : 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'}"
>
Ratings
</a>
<form method="POST" action="/admin/logout" class="inline">
<button type="submit" class="text-red-600 hover:text-red-900">Logout</button>
</form>
</div>
</div>
</div>
</nav>
{/if}
<main class="mx-auto max-w-7xl py-6 sm:px-6 lg:px-8">
<slot />
</main>
</div>

View File

@@ -0,0 +1,129 @@
import { fail } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js';
import { inviteLink, participant } from '$lib/server/db/schema.js';
import { eq, isNull } from 'drizzle-orm';
export async function load() {
const invites = await db.select().from(inviteLink).where(isNull(inviteLink.deletedAt));
return {
invites
};
}
export const actions = {
create: async ({ request }) => {
const data = await request.formData();
const participantName = data.get('participantName');
if (!participantName || participantName.length < 1) {
return fail(400, {
participantName,
missing: true
});
}
// Generate unique token with retry logic
let token;
let id;
let attempts = 0;
const maxAttempts = 5;
while (attempts < maxAttempts) {
// Generate shorter token: 8 random chars + timestamp
const randomPart = crypto.randomUUID().replace(/-/g, '').substring(0, 8);
const timestamp = Date.now().toString(36);
token = `${randomPart}${timestamp}`;
id = crypto.randomUUID();
try {
await db.insert(inviteLink).values({
id,
token,
participantName,
isUsed: false,
createdAt: new Date()
});
return {
success: true,
token
};
} catch (error) {
if (error.code === 'SQLITE_CONSTRAINT_UNIQUE' && attempts < maxAttempts - 1) {
attempts++;
console.log(`Token collision, retrying... (attempt ${attempts + 1})`);
continue;
}
console.error('Error creating invite:', error);
return fail(500, {
participantName,
createError: true
});
}
}
return fail(500, {
participantName,
createError: true
});
},
delete: async ({ request }) => {
const data = await request.formData();
const inviteId = data.get('inviteId');
if (!inviteId) {
return fail(400, {
missing: true
});
}
try {
// First, get the invite token and check if it exists and isn't already soft-deleted
const invites = await db.select({
token: inviteLink.token,
deletedAt: inviteLink.deletedAt
}).from(inviteLink).where(eq(inviteLink.id, inviteId));
if (invites.length === 0) {
return fail(404, {
deleteError: true,
message: 'Invite link not found'
});
}
const invite = invites[0];
if (invite.deletedAt) {
return fail(400, {
deleteError: true,
message: 'Invite link is already deleted'
});
}
const inviteToken = invite.token;
const now = new Date();
// Soft delete the invite link
await db.update(inviteLink)
.set({ deletedAt: now })
.where(eq(inviteLink.id, inviteId));
// Soft delete all participants who used this invite
await db.update(participant)
.set({ deletedAt: now })
.where(eq(participant.inviteToken, inviteToken));
return {
deleted: true,
message: 'Invite link and associated participant data have been archived'
};
} catch (error) {
console.error('Error deleting invite:', error);
return fail(500, {
deleteError: true,
message: 'Failed to delete invite link'
});
}
}
};

View File

@@ -0,0 +1,231 @@
<script>
import { enhance } from '$app/forms';
import { page } from '$app/stores';
import { browser } from '$app/environment';
export let data;
export let form;
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
// Show brief success feedback
const button = event.target;
const originalText = button.textContent;
button.textContent = 'Copied!';
button.classList.add('bg-green-600');
setTimeout(() => {
button.textContent = originalText;
button.classList.remove('bg-green-600');
}, 1500);
}).catch(err => {
console.error('Failed to copy: ', err);
alert('Failed to copy to clipboard');
});
}
function getInviteUrl(token) {
const origin = browser ? window.location.origin : $page.url.origin;
return `${origin}/participate?token=${token}`;
}
function getFullInviteText(token) {
const baseUrl = browser ? window.location.origin : $page.url.origin;
return `Go to ${baseUrl} and enter the token ${token} or click the following link to participate: ${baseUrl}/participate?token=${token}`;
}
function copyFullInvite(token) {
const fullInvite = getFullInviteText(token);
navigator.clipboard.writeText(fullInvite).then(() => {
const button = event.target;
const originalText = button.textContent;
button.textContent = 'Copied!';
button.classList.add('bg-green-600');
setTimeout(() => {
button.textContent = originalText;
button.classList.remove('bg-green-600');
}, 1500);
}).catch(err => {
console.error('Failed to copy: ', err);
alert('Failed to copy to clipboard');
});
}
</script>
<div class="px-4 py-6 sm:px-0">
<div class="mb-8">
<h1 class="mb-2 text-2xl font-bold text-gray-900">Invite Links</h1>
<p class="text-gray-600">Create and manage participant invite links.</p>
</div>
<!-- Create Invite Form -->
<div class="mb-8 rounded-lg bg-white p-6 shadow">
<h2 class="mb-4 text-lg font-medium text-gray-900">Create New Invite</h2>
<form method="POST" action="?/create" use:enhance class="space-y-4">
<div>
<label for="participantName" class="block text-sm font-medium text-gray-700">
Participant Name (Optional)
</label>
<input
type="text"
id="participantName"
name="participantName"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none sm:text-sm"
placeholder="Enter participant name"
value={form?.participantName ?? ''}
/>
{#if form?.missing}
<p class="mt-1 text-sm text-red-600">Participant name is required.</p>
{/if}
{#if form?.createError}
<p class="mt-1 text-sm text-red-600">Error creating invite link.</p>
{/if}
</div>
<button
type="submit"
class="inline-flex justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none"
>
Create Invite Link
</button>
</form>
{#if form?.success}
<div class="mt-4 rounded-md border border-green-200 bg-green-50 p-4">
<h3 class="text-sm font-medium text-green-800">Invite Link Created!</h3>
<div class="mt-2 flex items-center space-x-2">
<input
type="text"
readonly
value={getInviteUrl(form.token)}
class="flex-1 rounded border border-gray-300 bg-white px-2 py-1 text-sm"
/>
<button
on:click={() => copyToClipboard(getInviteUrl(form.token))}
class="rounded bg-green-600 px-3 py-1 text-sm text-white hover:bg-green-700"
>
Copy Link
</button>
<button
on:click={() => copyFullInvite(form.token)}
class="rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700"
>
Copy Full Invite
</button>
</div>
</div>
{/if}
</div>
<!-- Existing Invites -->
<div class="rounded-lg bg-white shadow">
<div class="border-b border-gray-200 px-6 py-4">
<h2 class="text-lg font-medium text-gray-900">Existing Invite Links</h2>
{#if form?.deleteError}
<p class="mt-2 text-sm text-red-600">
{form?.message || 'Error deleting invite link.'}
</p>
{/if}
{#if form?.deleted && form?.message}
<p class="mt-2 text-sm text-green-600">
{form.message}
</p>
{/if}
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Participant
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Status
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Created
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Link
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Actions
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#each data.invites as invite (invite.id)}
<tr>
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-900">
{invite.participantName || 'Unnamed'}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
class="inline-flex rounded-full px-2 text-xs leading-5 font-semibold {invite.isUsed
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'}"
>
{invite.isUsed ? 'Used' : 'Pending'}
</span>
</td>
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
{new Date(invite.createdAt).toLocaleDateString()}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center space-x-2">
<input
type="text"
readonly
value={getInviteUrl(invite.token)}
class="flex-1 rounded border border-gray-300 bg-gray-50 px-2 py-1 text-sm"
/>
<button
on:click={() => copyToClipboard(getInviteUrl(invite.token))}
class="rounded bg-indigo-600 px-2 py-1 text-xs text-white hover:bg-indigo-700"
>
Copy Link
</button>
<button
on:click={() => copyFullInvite(invite.token)}
class="rounded bg-blue-600 px-2 py-1 text-xs text-white hover:bg-blue-700"
>
Copy Full
</button>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<form method="POST" action="?/delete" class="inline" use:enhance>
<input type="hidden" name="inviteId" value={invite.id} />
<button
type="submit"
class="text-red-600 hover:text-red-900 text-sm"
on:click={(e) => {
if (!confirm('Are you sure you want to delete this invite link?')) {
e.preventDefault();
}
}}
>
Delete
</button>
</form>
</td>
</tr>
{:else}
<tr>
<td colspan="5" class="px-6 py-4 text-center text-sm text-gray-500">
No invite links created yet.
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>

View File

@@ -0,0 +1,156 @@
import { fail } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js';
import { audioFile } from '$lib/server/db/schema.js';
import { eq, isNull } from 'drizzle-orm';
import { uploadToS3, deleteFromS3, generateAudioS3Key } from '$lib/server/s3.js';
export async function load() {
const audioFiles = await db.select({
id: audioFile.id,
filename: audioFile.filename,
contentType: audioFile.contentType,
duration: audioFile.duration,
fileSize: audioFile.fileSize,
createdAt: audioFile.createdAt
})
.from(audioFile)
.where(isNull(audioFile.deletedAt)); // Only show active audio files
return {
audioFiles
};
}
export const actions = {
upload: async ({ request }) => {
const data = await request.formData();
const file = data.get('audioFile');
if (!file || file.size === 0) {
return fail(400, {
missing: true
});
}
if (!file.type.startsWith('audio/')) {
return fail(400, {
invalidType: true
});
}
const id = crypto.randomUUID();
const buffer = Buffer.from(await file.arrayBuffer());
const s3Key = generateAudioS3Key(id, file.name);
try {
// Upload to S3 first
await uploadToS3(s3Key, buffer, file.type);
// Then save metadata to database (without blob data)
await db.insert(audioFile).values({
id,
filename: file.name,
contentType: file.type,
s3Key,
duration: null, // Will be updated by client-side after upload
fileSize: file.size,
createdAt: new Date()
});
return {
success: true
};
} catch (error) {
console.error('Error uploading audio file:', error);
return fail(500, {
error: error.message || 'Upload failed'
});
}
},
delete: async ({ request }) => {
const data = await request.formData();
const fileId = data.get('fileId');
if (!fileId) {
return fail(400, {
missing: true
});
}
try {
// Soft delete from database (don't delete from S3 for recovery purposes)
await db
.update(audioFile)
.set({ deletedAt: new Date() })
.where(eq(audioFile.id, fileId));
return {
deleted: true
};
} catch (error) {
console.error('Error deleting audio file:', error);
return fail(500, {
error: true
});
}
},
updateDuration: async ({ request }) => {
const data = await request.formData();
const fileId = data.get('fileId');
const duration = parseFloat(data.get('duration'));
if (!fileId || isNaN(duration)) {
return fail(400, {
missing: true
});
}
try {
await db.update(audioFile)
.set({ duration })
.where(eq(audioFile.id, fileId));
return {
success: true
};
} catch (error) {
console.error('Error updating duration:', error);
return fail(500, {
error: true
});
}
},
renameAudioFile: async ({ request }) => {
const data = await request.formData();
const audioFileId = data.get('audioFileId');
const newFilename = data.get('newFilename');
if (!audioFileId || !newFilename) {
return fail(400, { error: 'Invalid data provided' });
}
// Validate filename
const filename = newFilename.trim();
if (filename.length === 0) {
return fail(400, { error: 'Filename cannot be empty' });
}
if (filename.length > 255) {
return fail(400, { error: 'Filename is too long' });
}
try {
await db
.update(audioFile)
.set({ filename })
.where(eq(audioFile.id, audioFileId));
return { renamed: true, filename };
} catch (error) {
console.error('Error renaming audio file:', error);
return fail(500, { error: 'Failed to rename audio file' });
}
}
};

View File

@@ -0,0 +1,526 @@
<script>
import { enhance } from '$app/forms';
export let data;
export let form;
let isDragOver = false;
let fileInput;
let selectedFiles = [];
let uploadProgress = [];
let isUploading = false;
let fileInputs = []; // Store references to file inputs
let editingAudioId = null;
let newFilename = '';
let isRenaming = false;
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function formatTime(seconds) {
if (!seconds) return '--';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function handleDragOver(e) {
e.preventDefault();
isDragOver = true;
}
function handleDragLeave(e) {
e.preventDefault();
isDragOver = false;
}
function handleDrop(e) {
e.preventDefault();
isDragOver = false;
const files = Array.from(e.dataTransfer.files).filter(file => file.type.startsWith('audio/'));
addFiles(files);
}
function handleFileSelect(e) {
const files = Array.from(e.target.files).filter(file => file.type.startsWith('audio/'));
addFiles(files);
}
function addFiles(files) {
selectedFiles = [...selectedFiles, ...files];
uploadProgress = [...uploadProgress, ...files.map(() => ({ progress: 0, status: 'pending' }))];
// Create file inputs for each file
files.forEach((file, index) => {
const dt = new DataTransfer();
dt.items.add(file);
fileInputs[selectedFiles.length - files.length + index] = dt.files;
});
}
function removeFile(index) {
selectedFiles = selectedFiles.filter((_, i) => i !== index);
uploadProgress = uploadProgress.filter((_, i) => i !== index);
fileInputs = fileInputs.filter((_, i) => i !== index);
}
async function uploadSingleFileByIndex(index) {
uploadProgress[index].status = 'uploading';
uploadProgress[index].progress = 50;
uploadProgress = [...uploadProgress];
const file = selectedFiles[index];
const formData = new FormData();
formData.append('audioFile', file);
console.log('Uploading:', file.name, 'Size:', file.size, 'Type:', file.type);
try {
const response = await fetch('?/upload', {
method: 'POST',
body: formData
});
const responseText = await response.text();
console.log('Response:', response.status, responseText);
if (response.ok && !responseText.includes('missing') && !responseText.includes('error')) {
uploadProgress[index].status = 'completed';
uploadProgress[index].progress = 100;
// Extract duration from uploaded file
extractAndSaveDuration(file);
} else {
uploadProgress[index].status = 'error';
uploadProgress[index].error = 'Upload failed';
}
} catch (error) {
console.error('Upload error:', error);
uploadProgress[index].status = 'error';
uploadProgress[index].error = error.message;
}
uploadProgress = [...uploadProgress];
}
async function uploadFiles() {
if (selectedFiles.length === 0) return;
isUploading = true;
for (let i = 0; i < selectedFiles.length; i++) {
if (uploadProgress[i].status === 'completed') continue;
await uploadSingleFileByIndex(i);
}
isUploading = false;
// Refresh the file list
setTimeout(() => {
window.location.reload();
}, 1000);
}
function clearCompleted() {
const newFiles = [];
const newProgress = [];
selectedFiles.forEach((file, i) => {
if (uploadProgress[i].status !== 'completed') {
newFiles.push(file);
newProgress.push(uploadProgress[i]);
}
});
selectedFiles = newFiles;
uploadProgress = newProgress;
}
async function extractAndSaveDuration(file) {
try {
const audio = new Audio();
const url = URL.createObjectURL(file);
audio.src = url;
await new Promise((resolve, reject) => {
audio.addEventListener('loadedmetadata', () => {
URL.revokeObjectURL(url);
resolve();
});
audio.addEventListener('error', reject);
});
if (audio.duration && !isNaN(audio.duration)) {
// Find the file ID from the most recent upload response
// For now, we'll need to match by filename - this is a limitation
const matchingFile = data.audioFiles.find(af => af.filename === file.name);
if (matchingFile) {
await updateFileDuration(matchingFile.id, audio.duration);
}
}
} catch (error) {
console.error('Error extracting duration:', error);
}
}
async function updateFileDuration(fileId, duration) {
try {
const formData = new FormData();
formData.append('fileId', fileId);
formData.append('duration', duration.toString());
await fetch('?/updateDuration', {
method: 'POST',
body: formData
});
} catch (error) {
console.error('Error updating duration:', error);
}
}
function startEdit(audioFile) {
editingAudioId = audioFile.id;
newFilename = audioFile.filename;
}
function cancelEdit() {
editingAudioId = null;
newFilename = '';
}
async function saveRename(audioFileId) {
if (!newFilename.trim() || isRenaming) return;
isRenaming = true;
const formData = new FormData();
formData.append('audioFileId', audioFileId);
formData.append('newFilename', newFilename.trim());
try {
const response = await fetch('?/renameAudioFile', {
method: 'POST',
body: formData
});
if (response.ok) {
// Refresh the page to show updated data
window.location.reload();
} else {
alert('Error renaming file. Please try again.');
}
} catch (error) {
console.error('Error renaming file:', error);
alert('Error renaming file. Please try again.');
} finally {
isRenaming = false;
}
}
// Handle form response
$: if (form?.renamed) {
editingAudioId = null;
newFilename = '';
}
</script>
<div class="px-4 py-6 sm:px-0">
<div class="mb-8">
<h1 class="mb-2 text-2xl font-bold text-gray-900">Audio Files</h1>
<p class="text-gray-600">Upload and manage audio files for the study.</p>
</div>
<!-- Upload Form -->
<div class="mb-8 rounded-lg bg-white p-6 shadow">
<h2 class="mb-4 text-lg font-medium text-gray-900">Upload Audio Files</h2>
<!-- Drag and Drop Area -->
<div
class="border-2 border-dashed rounded-lg p-8 text-center transition-colors {isDragOver
? 'border-indigo-500 bg-indigo-50'
: 'border-gray-300 hover:border-gray-400'}"
on:dragover={handleDragOver}
on:dragleave={handleDragLeave}
on:drop={handleDrop}
>
<svg
class="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19V6l12-3v13M9 19c0 1.105-.895 2-2 2s-2-.895-2-2 .895-2 2-2 2 .895 2 2zm12-3c0 1.105-.895 2-2 2s-2-.895-2-2 .895-2 2-2 2 .895 2 2z"
></path>
</svg>
<div class="mt-4">
<p class="text-lg font-medium text-gray-900">
Drop audio files here, or
<button
type="button"
class="text-indigo-600 hover:text-indigo-500"
on:click={() => fileInput.click()}
>
browse
</button>
</p>
<p class="mt-2 text-sm text-gray-500">
Supports MP3, WAV, FLAC, AAC, OGG and other audio formats
</p>
</div>
</div>
<!-- Hidden file input -->
<input
bind:this={fileInput}
type="file"
multiple
accept="audio/*"
class="hidden"
on:change={handleFileSelect}
/>
<!-- Selected Files List -->
{#if selectedFiles.length > 0}
<div class="mt-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium text-gray-900">
Selected Files ({selectedFiles.length})
</h3>
<div class="space-x-2">
<button
type="button"
class="text-sm text-gray-600 hover:text-gray-900"
on:click={clearCompleted}
>
Clear Completed
</button>
<button
type="button"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isUploading}
on:click={uploadFiles}
>
{isUploading ? 'Uploading...' : 'Upload All'}
</button>
</div>
</div>
<div class="space-y-3">
{#each selectedFiles as file, index (file.name + index)}
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div class="flex-1">
<p class="text-sm font-medium text-gray-900">{file.name}</p>
<p class="text-xs text-gray-500">
{formatFileSize(file.size)}{file.type}
</p>
<!-- Progress bar -->
{#if uploadProgress[index]?.status === 'uploading'}
<div class="mt-2 w-full bg-gray-200 rounded-full h-2">
<div
class="bg-indigo-600 h-2 rounded-full transition-all duration-300"
style="width: {uploadProgress[index].progress}%"
></div>
</div>
{/if}
<!-- Status messages -->
{#if uploadProgress[index]?.status === 'completed'}
<p class="text-xs text-green-600 mt-1">✓ Upload completed</p>
{:else if uploadProgress[index]?.status === 'error'}
<p class="text-xs text-red-600 mt-1">
{uploadProgress[index].error}
</p>
{:else if uploadProgress[index]?.status === 'uploading'}
<p class="text-xs text-indigo-600 mt-1">⏳ Uploading...</p>
{:else}
<!-- Individual upload button -->
<button
type="button"
class="mt-2 text-xs text-indigo-600 hover:text-indigo-900"
on:click={() => uploadSingleFileByIndex(index)}
>
Upload Now
</button>
{/if}
</div>
<button
type="button"
class="ml-4 text-red-600 hover:text-red-900"
disabled={uploadProgress[index]?.status === 'uploading'}
on:click={() => removeFile(index)}
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
></path>
</svg>
</button>
</div>
{/each}
</div>
</div>
{/if}
{#if form?.success}
<div class="mt-4 rounded-md border border-green-200 bg-green-50 p-4">
<p class="text-sm font-medium text-green-800">Audio file uploaded successfully!</p>
</div>
{/if}
</div>
<!-- Audio Files List -->
<div class="rounded-lg bg-white shadow">
<div class="border-b border-gray-200 px-6 py-4">
<h2 class="text-lg font-medium text-gray-900">Uploaded Audio Files</h2>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Filename
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Type
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Size
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Duration
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Uploaded
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Actions
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#each data.audioFiles as audio (audio.id)}
<tr>
<td class="px-6 py-4 text-sm text-gray-900">
{#if editingAudioId === audio.id}
<div class="flex items-center space-x-2">
<input
type="text"
bind:value={newFilename}
on:keydown={(e) => {
if (e.key === 'Enter') saveRename(audio.id);
if (e.key === 'Escape') cancelEdit();
}}
class="flex-1 rounded-md border-gray-300 px-3 py-2 text-sm focus:border-indigo-500 focus:ring-indigo-500"
placeholder="Enter new filename..."
autofocus
/>
</div>
{:else}
<div class="font-medium max-w-xs">
<div class="truncate" title={audio.filename}>
{audio.filename}
</div>
</div>
{/if}
</td>
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
{audio.contentType}
</td>
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
{formatFileSize(audio.fileSize || 0)}
</td>
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
{audio.duration ? formatTime(audio.duration) : 'Loading...'}
</td>
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
{new Date(audio.createdAt).toLocaleDateString()}
</td>
<td class="space-x-2 px-6 py-4 text-sm font-medium whitespace-nowrap">
{#if editingAudioId === audio.id}
<div class="flex items-center space-x-2">
<button
on:click={() => saveRename(audio.id)}
disabled={!newFilename.trim() || isRenaming}
class="inline-flex items-center rounded bg-indigo-600 px-2.5 py-1.5 text-xs font-medium text-white shadow-sm hover:bg-indigo-500 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{#if isRenaming}
<div class="animate-spin rounded-full h-3 w-3 border-b-2 border-white mr-1"></div>
{/if}
Save
</button>
<button
on:click={cancelEdit}
disabled={isRenaming}
class="inline-flex items-center rounded bg-gray-600 px-2.5 py-1.5 text-xs font-medium text-white shadow-sm hover:bg-gray-500 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
Cancel
</button>
</div>
{:else}
<button
on:click={() => startEdit(audio)}
class="text-indigo-600 hover:text-indigo-900"
>
Rename
</button>
<a
href="/admin/audio/{audio.id}/download"
class="text-indigo-600 hover:text-indigo-900"
>
Download
</a>
<form method="POST" action="?/delete" class="inline" use:enhance>
<input type="hidden" name="fileId" value={audio.id} />
<button
type="submit"
class="text-red-600 hover:text-red-900"
on:click={(e) => {
if (!confirm('Are you sure you want to delete this audio file?')) {
e.preventDefault();
}
}}
>
Delete
</button>
</form>
{/if}
</td>
</tr>
{:else}
<tr>
<td colspan="6" class="px-6 py-4 text-center text-sm text-gray-500">
No audio files uploaded yet.
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>

View File

@@ -0,0 +1,37 @@
import { error } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js';
import { audioFile } from '$lib/server/db/schema.js';
import { eq } from 'drizzle-orm';
import { getFromS3 } from '$lib/server/s3.js';
export async function GET({ params }) {
const fileId = params.id;
// Get file metadata from database
const files = await db.select({
s3Key: audioFile.s3Key,
contentType: audioFile.contentType,
filename: audioFile.filename
}).from(audioFile).where(eq(audioFile.id, fileId));
if (files.length === 0) {
throw error(404, 'Audio file not found');
}
const file = files[0];
try {
// Fetch file from S3
const s3Response = await getFromS3(file.s3Key);
return new Response(s3Response.stream, {
headers: {
'Content-Type': s3Response.contentType || file.contentType,
'Content-Disposition': `attachment; filename="${file.filename}"`
}
});
} catch (s3Error) {
console.error('Error fetching from S3 for download:', s3Error);
throw error(500, 'Failed to download audio file');
}
}

View File

@@ -0,0 +1,37 @@
import { fail, redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
export const actions = {
default: async ({ request, cookies }) => {
const ADMIN_USERNAME = env.ADMIN_USERNAME || 'admin';
const ADMIN_PASSWORD = env.ADMIN_PASSWORD || 'admin123';
const data = await request.formData();
const username = data.get('username');
const password = data.get('password');
if (!username || !password) {
return fail(400, {
username,
missing: true
});
}
if (username !== ADMIN_USERNAME || password !== ADMIN_PASSWORD) {
return fail(400, {
username,
incorrect: true
});
}
cookies.set('admin-session', 'authenticated', {
path: '/',
httpOnly: true,
secure: false,
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 7
});
redirect(302, '/admin');
}
};

View File

@@ -0,0 +1,60 @@
<script>
import { enhance } from '$app/forms';
export let form;
</script>
<div class="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 sm:px-6 lg:px-8">
<div class="w-full max-w-md space-y-8">
<div>
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">Admin Login</h2>
<p class="mt-2 text-center text-sm text-gray-600">
Sign in to access the TaptApp admin panel
</p>
</div>
<form class="mt-8 space-y-6" method="POST" use:enhance>
<div class="-space-y-px rounded-md shadow-sm">
<div>
<label for="username" class="sr-only">Username</label>
<input
id="username"
name="username"
type="text"
autocomplete="username"
required
class="relative block w-full appearance-none rounded-none rounded-t-md border border-gray-300 px-3 py-2 text-gray-900 placeholder-gray-500 focus:z-10 focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none sm:text-sm"
placeholder="Username"
value={form?.username ?? ''}
/>
</div>
<div>
<label for="password" class="sr-only">Password</label>
<input
id="password"
name="password"
type="password"
autocomplete="current-password"
required
class="relative block w-full appearance-none rounded-none rounded-b-md border border-gray-300 px-3 py-2 text-gray-900 placeholder-gray-500 focus:z-10 focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none sm:text-sm"
placeholder="Password"
/>
</div>
</div>
{#if form?.missing}
<p class="text-sm text-red-600">Please fill in all fields.</p>
{/if}
{#if form?.incorrect}
<p class="text-sm text-red-600">Invalid username or password.</p>
{/if}
<div>
<button
type="submit"
class="group relative flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none"
>
Sign in
</button>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,8 @@
import { redirect } from '@sveltejs/kit';
export const actions = {
default: async ({ cookies }) => {
cookies.delete('admin-session', { path: '/' });
redirect(302, '/admin/login');
}
};

View File

@@ -0,0 +1,106 @@
import { db } from '$lib/server/db/index.js';
import { rating, participant, audioFile, inviteLink } from '$lib/server/db/schema.js';
import { eq, isNull, and, desc } from 'drizzle-orm';
export async function load() {
// Only get active (non-deleted) completed ratings with timeseries data
const ratings = await db
.select({
rating,
participant,
audioFile,
inviteLink
})
.from(rating)
.innerJoin(participant, and(
eq(rating.participantId, participant.id),
isNull(participant.deletedAt)
))
.innerJoin(audioFile, and(
eq(rating.audioFileId, audioFile.id),
isNull(audioFile.deletedAt) // Only show ratings for active audio files
))
.innerJoin(inviteLink, and(
eq(participant.inviteToken, inviteLink.token),
isNull(inviteLink.deletedAt)
))
.where(and(
eq(rating.isCompleted, true),
isNull(rating.deletedAt) // Only active ratings
))
.orderBy(desc(rating.createdAt));
const ratingStats = await db
.select()
.from(rating)
.innerJoin(participant, and(
eq(rating.participantId, participant.id),
isNull(participant.deletedAt)
))
.where(and(
eq(rating.isCompleted, true),
isNull(rating.deletedAt) // Only active ratings
));
// Group ratings by audio file and participant for individual timeseries visualization
const timeseriesData = {};
ratings.forEach(entry => {
const audioId = entry.audioFile.id;
const participantId = entry.participant.id;
const participantName = entry.inviteLink.participantName || 'Unnamed';
if (!timeseriesData[audioId]) {
timeseriesData[audioId] = {
audioFile: entry.audioFile,
participants: {}
};
}
if (!timeseriesData[audioId].participants[participantId]) {
timeseriesData[audioId].participants[participantId] = {
name: participantName,
ratings: [],
isCompleted: true // All entries here are completed
};
}
// Parse the JSON timeseries data
let timeseriesPoints = [];
try {
if (entry.rating.timeseriesData) {
timeseriesPoints = JSON.parse(entry.rating.timeseriesData);
}
} catch (error) {
console.error('Error parsing timeseries data:', error);
// Fallback to single point from main record
timeseriesPoints = [{
timestamp: entry.rating.timestamp,
value: entry.rating.value,
time: new Date(entry.rating.createdAt).getTime()
}];
}
// Add all timeseries points
timeseriesPoints.forEach(point => {
timeseriesData[audioId].participants[participantId].ratings.push({
timestamp: point.timestamp,
value: point.value,
isCompleted: true, // All points from completed ratings
createdAt: entry.rating.createdAt
});
});
});
// Sort ratings by timestamp for each participant
Object.values(timeseriesData).forEach(audioData => {
Object.values(audioData.participants).forEach(participantData => {
participantData.ratings.sort((a, b) => a.timestamp - b.timestamp);
});
});
return {
ratings,
totalRatings: ratingStats.length,
timeseriesData
};
}

View File

@@ -0,0 +1,267 @@
<script>
import { onMount } from 'svelte';
export let data;
let Chart;
let chartInstance;
let selectedSubmission = null;
onMount(async () => {
// Import Chart.js dynamically
const { default: ChartJS } = await import('chart.js/auto');
Chart = ChartJS;
});
function viewSubmission(audioId, participantId) {
selectedSubmission = { audioId, participantId };
// Wait for DOM update then create chart
setTimeout(() => {
createTimeseriesChart();
}, 10);
}
function createTimeseriesChart() {
if (!selectedSubmission || !Chart) return;
const canvas = document.getElementById('timeseries-viewer');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const audioData = data.timeseriesData[selectedSubmission.audioId];
const participantData = audioData.participants[selectedSubmission.participantId];
if (!participantData) return;
const datasets = [{
label: `${participantData.name} - Rating Timeline`,
data: participantData.ratings.map(rating => ({
x: rating.timestamp,
y: rating.value
})),
borderColor: 'rgb(99, 102, 241)',
backgroundColor: 'rgb(99, 102, 241, 0.1)',
fill: false,
tension: 0.1,
pointRadius: 3,
pointHoverRadius: 5
}];
const title = `${participantData.name} - ${audioData.audioFile.filename}`;
if (chartInstance) {
chartInstance.destroy();
}
chartInstance = new Chart(ctx, {
type: 'line',
data: { datasets },
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: title
},
legend: {
display: false
}
},
scales: {
x: {
type: 'linear',
position: 'bottom',
title: {
display: true,
text: 'Audio Timestamp (seconds)'
},
ticks: {
callback: function(value) {
return formatTime(value);
}
}
},
y: {
title: {
display: true,
text: 'Rating Value'
},
min: 0,
max: 100
}
},
interaction: {
intersect: false,
mode: 'index'
}
}
});
}
function formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
</script>
<div class="px-4 py-6 sm:px-0">
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="mb-2 text-2xl font-bold text-gray-900">Participant Ratings</h1>
<p class="text-gray-600">View and analyze participant ratings data with timeseries visualization.</p>
</div>
<div>
<a
href="/api/export/timeseries"
class="inline-flex items-center rounded-md bg-green-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-green-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
download
>
<svg class="-ml-0.5 mr-1.5 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
Export Excel
</a>
</div>
</div>
<!-- Timeseries Viewer -->
<div class="mb-8 rounded-lg bg-white shadow">
<div class="border-b border-gray-200 px-6 py-4">
<h2 class="text-lg font-medium text-gray-900">Timeseries Viewer</h2>
</div>
<div class="p-6">
{#if selectedSubmission}
<div class="h-96">
<canvas id="timeseries-viewer"></canvas>
</div>
{:else}
<div class="h-96 flex items-center justify-center bg-gray-50 rounded-lg">
<p class="text-gray-500">Select a submission from the table below to view its timeseries</p>
</div>
{/if}
</div>
</div>
<div class="rounded-lg bg-white shadow">
<div class="border-b border-gray-200 px-6 py-4">
<h2 class="text-lg font-medium text-gray-900">Participant Submissions</h2>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Participant
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Audio File
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Status
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Data Points
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Duration Covered
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Last Updated
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Actions
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#each Object.entries(data.timeseriesData) as [audioId, audioData]}
{#each Object.entries(audioData.participants) as [participantId, participantData]}
{@const avgRating = participantData.ratings.length > 0 ?
Math.round((participantData.ratings.reduce((sum, r) => sum + r.value, 0) / participantData.ratings.length) * 10) / 10 : 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 durationCovered = maxTimestamp - minTimestamp}
{@const lastUpdated = participantData.ratings.length > 0 ?
new Date(Math.max(...participantData.ratings.map(r => new Date(r.createdAt).getTime()))) : null}
<tr>
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-900">
{participantData.name}
</td>
<td class="px-6 py-4 text-sm text-gray-500 max-w-xs">
<div class="truncate" title="{audioData.audioFile.filename}">
{audioData.audioFile.filename}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
{#if participantData.isCompleted}
<span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
Completed
</span>
{:else}
<span class="inline-flex items-center rounded-full bg-yellow-100 px-2.5 py-0.5 text-xs font-medium text-yellow-800">
In Progress
</span>
{/if}
</td>
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
{participantData.ratings.length}
</td>
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
{#if participantData.ratings.length > 1}
{formatTime(durationCovered)}
{:else if participantData.ratings.length === 1}
<span class="text-gray-400">Single point</span>
{:else}
<span class="text-gray-400">-</span>
{/if}
</td>
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
{#if lastUpdated}
{lastUpdated.toLocaleString()}
{:else}
<span class="text-gray-400">-</span>
{/if}
</td>
<td class="px-6 py-4 text-sm whitespace-nowrap">
{#if participantData.ratings.length > 0}
<button
on:click={() => viewSubmission(audioId, participantId)}
class="inline-flex items-center rounded bg-indigo-600 px-2.5 py-1.5 text-xs font-medium text-white shadow-sm hover:bg-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
View
</button>
{:else}
<span class="text-gray-400">-</span>
{/if}
</td>
</tr>
{/each}
{:else}
<tr>
<td colspan="7" class="px-6 py-4 text-center text-sm text-gray-500">
No ratings collected yet.
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>

View File

@@ -0,0 +1,67 @@
import { error } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js';
import { audioFile } from '$lib/server/db/schema.js';
import { eq, isNull, and } from 'drizzle-orm';
import { getFromS3, getFromS3WithRange } from '$lib/server/s3.js';
export async function GET({ params, request }) {
const fileId = params.id;
// Get file metadata from database (only s3Key and contentType needed)
const files = await db.select({
s3Key: audioFile.s3Key,
contentType: audioFile.contentType,
fileSize: audioFile.fileSize
})
.from(audioFile)
.where(and(
eq(audioFile.id, fileId),
isNull(audioFile.deletedAt) // Only serve active audio files
));
if (files.length === 0) {
throw error(404, 'Audio file not found');
}
const file = files[0];
// Check if file has S3 key (new files) or fall back to error for old blob-based files
if (!file.s3Key) {
console.error(`Audio file ${fileId} missing S3 key - may be an old blob-based file`);
throw error(500, 'Audio file not available - please re-upload');
}
const range = request.headers.get('range');
try {
if (range) {
// Handle range requests for audio streaming
const s3Response = await getFromS3WithRange(file.s3Key, range);
return new Response(s3Response.stream, {
status: 206,
headers: {
'Content-Range': s3Response.contentRange,
'Accept-Ranges': s3Response.acceptRanges || 'bytes',
'Content-Length': s3Response.contentLength.toString(),
'Content-Type': s3Response.contentType || file.contentType || 'audio/mpeg'
}
});
} else {
// Handle regular requests
const s3Response = await getFromS3(file.s3Key);
return new Response(s3Response.stream, {
headers: {
'Content-Type': s3Response.contentType || file.contentType || 'audio/mpeg',
'Content-Length': (s3Response.contentLength || file.fileSize || 0).toString(),
'Accept-Ranges': 'bytes',
'Cache-Control': 'public, max-age=3600'
}
});
}
} catch (s3Error) {
console.error('Error fetching from S3:', s3Error);
throw error(500, 'Failed to fetch audio file');
}
}

View File

@@ -0,0 +1,154 @@
import { db } from '$lib/server/db/index.js';
import { rating, participant, audioFile, inviteLink } from '$lib/server/db/schema.js';
import { eq, isNull, and, desc } from 'drizzle-orm';
import ExcelJS from 'exceljs';
export async function GET() {
try {
const ratings = await db
.select({
rating,
participant,
audioFile,
inviteLink
})
.from(rating)
.innerJoin(participant, and(
eq(rating.participantId, participant.id),
isNull(participant.deletedAt)
))
.innerJoin(audioFile, eq(rating.audioFileId, audioFile.id))
.innerJoin(inviteLink, and(
eq(participant.inviteToken, inviteLink.token),
isNull(inviteLink.deletedAt)
))
.orderBy(desc(rating.createdAt));
// Group ratings by audio file and participant
const timeseriesData = {};
ratings.forEach(entry => {
const audioId = entry.audioFile.id;
const participantId = entry.participant.id;
const participantName = entry.inviteLink.participantName || 'Unnamed';
if (!timeseriesData[audioId]) {
timeseriesData[audioId] = {
audioFile: entry.audioFile,
participants: {}
};
}
if (!timeseriesData[audioId].participants[participantId]) {
timeseriesData[audioId].participants[participantId] = {
name: participantName,
ratings: [],
isCompleted: false
};
}
timeseriesData[audioId].participants[participantId].ratings.push({
timestamp: entry.rating.timestamp,
value: entry.rating.value,
isCompleted: entry.rating.isCompleted,
createdAt: entry.rating.createdAt
});
if (entry.rating.isCompleted) {
timeseriesData[audioId].participants[participantId].isCompleted = true;
}
});
// Sort ratings by timestamp for each participant
Object.values(timeseriesData).forEach(audioData => {
Object.values(audioData.participants).forEach(participantData => {
participantData.ratings.sort((a, b) => a.timestamp - b.timestamp);
});
});
// Create Excel workbook
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet('Timeseries Data');
// Add headers
worksheet.addRow([
'Participant Name',
'Audio File',
'Status',
'Data Points Count',
'Duration Covered (seconds)',
'Timeseries Data (timestamp:value pairs)',
'Last Updated'
]);
// Style headers
const headerRow = worksheet.getRow(1);
headerRow.font = { bold: true };
headerRow.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFE6E6FA' }
};
// Add data rows
Object.entries(timeseriesData).forEach(([audioId, audioData]) => {
Object.entries(audioData.participants).forEach(([participantId, participantData]) => {
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 durationCovered = maxTimestamp - minTimestamp;
const lastUpdated = participantData.ratings.length > 0 ?
new Date(Math.max(...participantData.ratings.map(r => new Date(r.createdAt).getTime()))) : null;
// Format timeseries data as string
const timeseriesString = participantData.ratings
.map(rating => `${rating.timestamp.toFixed(2)}:${rating.value}`)
.join('; ');
worksheet.addRow([
participantData.name,
audioData.audioFile.filename,
participantData.isCompleted ? 'Completed' : 'In Progress',
participantData.ratings.length,
durationCovered.toFixed(2),
timeseriesString,
lastUpdated ? lastUpdated.toLocaleString() : '-'
]);
});
});
// Auto-fit columns
worksheet.columns.forEach(column => {
let maxLength = 0;
column.eachCell({ includeEmpty: true }, (cell) => {
const columnLength = cell.value ? cell.value.toString().length : 10;
if (columnLength > maxLength) {
maxLength = columnLength;
}
});
column.width = Math.min(maxLength + 2, 50); // Cap at 50 for readability
});
// Generate buffer
const buffer = await workbook.xlsx.writeBuffer();
// Return the Excel file
return new Response(buffer, {
status: 200,
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': `attachment; filename="timeseries-export-${new Date().toISOString().split('T')[0]}.xlsx"`
}
});
} catch (error) {
console.error('Export error:', error);
return new Response(JSON.stringify({ error: 'Export failed' }), {
status: 500,
headers: {
'Content-Type': 'application/json'
}
});
}
}

View File

@@ -0,0 +1,114 @@
import { error } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js';
import { inviteLink, participant, audioFile, rating } from '$lib/server/db/schema.js';
import { eq, isNull, and } from 'drizzle-orm';
export async function load({ url, cookies }) {
const token = url.searchParams.get('token');
if (!token) {
throw error(400, 'Invalid or missing invite token');
}
const invites = await db.select().from(inviteLink).where(
and(
eq(inviteLink.token, token),
isNull(inviteLink.deletedAt)
)
);
if (invites.length === 0) {
throw error(404, 'Invite link not found or has been deleted');
}
const invite = invites[0];
let participantId = cookies.get(`participant-${token}`);
let isExistingParticipant = false;
if (participantId) {
const participants = await db
.select()
.from(participant)
.where(
and(
eq(participant.id, participantId),
isNull(participant.deletedAt)
)
);
isExistingParticipant = participants.length > 0;
}
if (!isExistingParticipant) {
participantId = crypto.randomUUID();
await db.insert(participant).values({
id: participantId,
inviteToken: token,
sessionId: null,
createdAt: new Date()
});
await db
.update(inviteLink)
.set({
isUsed: true,
usedAt: new Date()
})
.where(eq(inviteLink.token, token));
cookies.set(`participant-${token}`, participantId, {
path: '/',
httpOnly: true,
secure: false,
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 30
});
}
const audioFiles = await db.select({
id: audioFile.id,
filename: audioFile.filename,
contentType: audioFile.contentType,
duration: audioFile.duration,
fileSize: audioFile.fileSize,
createdAt: audioFile.createdAt
})
.from(audioFile)
.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 completedRatings = await db
.select({
audioFileId: rating.audioFileId
})
.from(rating)
.innerJoin(audioFile, and(
eq(rating.audioFileId, audioFile.id),
isNull(audioFile.deletedAt) // Only count ratings for active audio files
))
.where(
and(
eq(rating.participantId, participantId),
eq(rating.isCompleted, true),
isNull(rating.deletedAt) // Only count active ratings
)
);
const completedAudioIds = new Set(completedRatings.map(r => r.audioFileId));
// Add completion status to audio files
const audioFilesWithStatus = audioFiles.map(file => ({
...file,
isCompleted: completedAudioIds.has(file.id)
}));
return {
invite,
participantId,
audioFiles: audioFilesWithStatus,
token,
completedCount: completedRatings.length,
totalCount: audioFiles.length
};
}

View File

@@ -0,0 +1,146 @@
<script>
export let data;
</script>
<div class="min-h-screen bg-gray-50 py-8">
<div class="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8">
<div class="mb-8">
<h1 class="mb-2 text-3xl font-bold text-gray-900">Dashboard</h1>
{#if data.invite.participantName}
<p class="text-lg text-gray-600">Hello, {data.invite.participantName}!</p>
{/if}
<p class="mt-4 text-gray-600">
Thank you for participating in our study. You will listen to audio files and rate them using
a slider. Click "Submit Rating" when you're finished rating each file.
</p>
</div>
<!-- Progress Summary -->
<div class="mb-8 rounded-lg bg-white p-6 shadow">
<h2 class="mb-4 text-xl font-semibold text-gray-900">Your Progress</h2>
<div class="flex items-center space-x-4">
<div class="flex-1">
<div class="flex justify-between text-sm font-medium text-gray-900 mb-1">
<span>Completed Ratings</span>
<span>{data.completedCount} of {data.totalCount}</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-3">
<div
class="bg-indigo-600 h-3 rounded-full transition-all duration-300"
style="width: {data.totalCount > 0 ? (data.completedCount / data.totalCount) * 100 : 0}%"
></div>
</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-indigo-600">
{Math.round(data.totalCount > 0 ? (data.completedCount / data.totalCount) * 100 : 0)}%
</div>
<div class="text-sm text-gray-500">Complete</div>
</div>
</div>
{#if data.completedCount === data.totalCount && data.totalCount > 0}
<div class="mt-4 rounded-md border border-green-200 bg-green-50 p-4">
<p class="font-medium text-green-800">🎉 Congratulations! You have completed all audio ratings.</p>
</div>
{/if}
</div>
<div class="mb-8 rounded-lg bg-white p-6 shadow">
<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">
<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>
<li>Click "Submit Rating" when you're finished to save your rating</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>
</ul>
</div>
<div class="rounded-lg bg-white shadow">
<div class="border-b border-gray-200 px-6 py-4">
<h2 class="text-lg font-medium text-gray-900">Audio Files ({data.audioFiles.length})</h2>
</div>
<div class="p-6">
{#if data.audioFiles.length > 0}
<div class="grid gap-4">
{#each data.audioFiles as audioFile (audioFile.id)}
<div class="rounded-lg border {audioFile.isCompleted ? 'border-green-200 bg-green-50' : 'border-gray-200'} p-6">
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="flex items-center space-x-3">
<h3 class="text-lg font-medium text-gray-900">{audioFile.filename}</h3>
{#if audioFile.isCompleted}
<span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
✓ Completed
</span>
{:else}
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-800">
Pending
</span>
{/if}
</div>
<p class="mt-1 text-sm text-gray-500">
{audioFile.contentType}{new Date(audioFile.createdAt).toLocaleDateString()}
</p>
</div>
<div class="flex items-center space-x-3">
{#if audioFile.isCompleted}
<div class="flex space-x-2">
<a
href="/participate/audio/{audioFile.id}?token={data.token}"
class="inline-flex items-center px-3 py-1.5 border border-green-300 text-sm font-medium rounded-md text-green-700 bg-white hover:bg-green-50 transition-colors"
>
<svg class="h-4 w-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
View
</a>
</div>
{:else}
<a
href="/participate/audio/{audioFile.id}?token={data.token}"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 transition-colors"
>
Start Rating
<svg class="h-4 w-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
></path>
</svg>
</a>
{/if}
</div>
</div>
</div>
{/each}
</div>
{:else}
<div class="py-12 text-center">
<svg
class="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19V6l12-3v13M9 19c0 1.105-.895 2-2 2s-2-.895-2-2 .895-2 2-2 2 .895 2 2zm12-3c0 1.105-.895 2-2 2s-2-.895-2-2 .895-2 2-2 2 .895 2 2z"
></path>
</svg>
<h3 class="mt-4 text-lg font-medium text-gray-900">No audio files available</h3>
<p class="mt-2 text-sm text-gray-500">
Please check back later when audio files have been uploaded.
</p>
</div>
{/if}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,256 @@
import { redirect, error } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js';
import { audioFile, rating, participantProgress, participant } from '$lib/server/db/schema.js';
import { eq, and, isNull } from 'drizzle-orm';
export async function load({ params, url, cookies }) {
const audioId = params.id;
const token = url.searchParams.get('token');
if (!token) {
throw error(400, 'Invalid or missing invite token');
}
const participantId = cookies.get(`participant-${token}`);
if (!participantId) {
redirect(302, `/participate?token=${token}`);
}
// Verify participant exists and isn't soft-deleted
const participants = await db
.select()
.from(participant)
.where(
and(
eq(participant.id, participantId),
isNull(participant.deletedAt)
)
);
if (participants.length === 0) {
redirect(302, `/participate?token=${token}`);
}
const audioFiles = await db.select({
id: audioFile.id,
filename: audioFile.filename,
contentType: audioFile.contentType,
duration: audioFile.duration,
fileSize: audioFile.fileSize,
createdAt: audioFile.createdAt
})
.from(audioFile)
.where(and(
eq(audioFile.id, audioId),
isNull(audioFile.deletedAt) // Only show active audio files
));
if (audioFiles.length === 0) {
throw error(404, 'Audio file not found');
}
const progressData = await db
.select({
id: participantProgress.id,
isCompleted: participantProgress.isCompleted,
lastPosition: participantProgress.lastPosition,
maxReachedTime: participantProgress.maxReachedTime,
updatedAt: participantProgress.updatedAt
})
.from(participantProgress)
.where(
and(
eq(participantProgress.participantId, participantId),
eq(participantProgress.audioFileId, audioId)
)
);
const progress = progressData.length > 0 ? progressData[0] : null;
const existingRatings = await db
.select()
.from(rating)
.where(and(
eq(rating.participantId, participantId),
eq(rating.audioFileId, audioId),
isNull(rating.deletedAt)
));
return {
audioFile: audioFiles[0],
participantId,
token,
progress,
existingRatings
};
}
export const actions = {
saveRating: async ({ request }) => {
const data = await request.formData();
const participantId = data.get('participantId');
const audioFileId = data.get('audioFileId');
const ratingHistoryStr = data.get('ratingHistory');
const finalValue = parseFloat(data.get('finalValue'));
const maxReachedTime = parseFloat(data.get('maxReachedTime')) || 0;
const currentPosition = parseFloat(data.get('currentPosition')) || 0;
if (!participantId || !audioFileId || !ratingHistoryStr || isNaN(finalValue)) {
return { error: 'Invalid rating data' };
}
try {
const ratingHistory = JSON.parse(ratingHistoryStr);
console.log('Rating history length:', ratingHistory.length);
console.log('Rating history:', ratingHistory);
// Use a transaction to ensure atomicity
await db.transaction(async (tx) => {
// Soft delete any existing ratings for this participant and audio file (for redo functionality)
await tx.update(rating)
.set({ deletedAt: new Date() })
.where(
and(
eq(rating.participantId, participantId),
eq(rating.audioFileId, audioFileId),
isNull(rating.deletedAt)
)
);
// Save single completed rating record with entire timeseries as JSON
const ratingId = `${participantId}-${audioFileId}-${Date.now()}`;
const finalTimestamp = ratingHistory[ratingHistory.length - 1]?.timestamp || 0;
await tx.insert(rating).values({
id: ratingId,
participantId,
audioFileId,
timestamp: finalTimestamp,
value: finalValue,
isCompleted: true,
timeseriesData: JSON.stringify(ratingHistory), // Store entire timeseries as JSON
createdAt: new Date(),
deletedAt: null // Active rating
});
});
// Save listening progress (only when rating is saved)
const progressId = `${participantId}-${audioFileId}`;
const existingProgress = await db
.select()
.from(participantProgress)
.where(
and(
eq(participantProgress.participantId, participantId),
eq(participantProgress.audioFileId, audioFileId)
)
);
if (existingProgress.length > 0) {
await db
.update(participantProgress)
.set({
lastPosition: currentPosition,
maxReachedTime: maxReachedTime,
isCompleted: true, // Mark as completed when rating is saved
updatedAt: new Date()
})
.where(eq(participantProgress.id, existingProgress[0].id));
} else {
await db.insert(participantProgress).values({
id: progressId,
participantId,
audioFileId,
lastPosition: currentPosition,
maxReachedTime: maxReachedTime,
isCompleted: true,
updatedAt: new Date()
});
}
return { success: true };
} catch (error) {
console.error('Error saving rating:', error);
return { error: 'Failed to save rating' };
}
},
deleteRating: async ({ request }) => {
const data = await request.formData();
const participantId = data.get('participantId');
const audioFileId = data.get('audioFileId');
if (!participantId || !audioFileId) {
return { error: 'Invalid request data' };
}
try {
// Soft delete the active rating for this participant and audio file
await db.update(rating)
.set({ deletedAt: new Date() })
.where(
and(
eq(rating.participantId, participantId),
eq(rating.audioFileId, audioFileId),
isNull(rating.deletedAt)
)
);
return { success: true };
} catch (error) {
console.error('Error deleting rating:', error);
return { error: 'Failed to delete rating' };
}
},
updateProgress: async ({ request }) => {
const data = await request.formData();
const participantId = data.get('participantId');
const audioFileId = data.get('audioFileId');
const lastPosition = parseFloat(data.get('lastPosition'));
const isCompleted = data.get('isCompleted') === 'true';
if (!participantId || !audioFileId || isNaN(lastPosition)) {
return { error: 'Invalid progress data' };
}
try {
const progressId = `${participantId}-${audioFileId}`;
const existingProgress = await db
.select()
.from(participantProgress)
.where(
and(
eq(participantProgress.participantId, participantId),
eq(participantProgress.audioFileId, audioFileId)
)
);
if (existingProgress.length > 0) {
await db
.update(participantProgress)
.set({
lastPosition,
isCompleted,
updatedAt: new Date()
})
.where(eq(participantProgress.id, existingProgress[0].id));
} else {
await db.insert(participantProgress).values({
id: progressId,
participantId,
audioFileId,
isCompleted,
lastPosition,
updatedAt: new Date()
});
}
return { success: true };
} catch (error) {
console.error('Error updating progress:', error);
return { error: 'Failed to update progress' };
}
}
};

View File

@@ -0,0 +1,534 @@
<script>
import { onMount, onDestroy } from 'svelte';
export let data;
let audio;
let isPlaying = false;
let currentTime = 0;
let duration = 0;
let ratingValue = 50;
let lastProgressSave = 0;
let audioLoading = true;
let audioLoadProgress = 0;
let waveformCanvas;
let waveformData = [];
let animationFrame;
let hasRatingChanged = false;
let isSavingRating = false;
let ratingHistory = [];
let hasListenedToEnd = false;
let maxReachedTime = 0;
let isAudioCompleted = false;
const PROGRESS_SAVE_INTERVAL = 2000;
onMount(() => {
// Always start from the beginning for new listening sessions
currentTime = 0;
console.log('Component mounted, audio file:', data.audioFile);
console.log('Audio API URL:', `/api/audio/${data.audioFile.id}`);
// Always reset completion tracking - require full listening every time
hasListenedToEnd = false;
maxReachedTime = 0;
isAudioCompleted = false;
// Add keyboard event listener for spacebar (only in browser)
if (typeof document !== 'undefined') {
document.addEventListener('keydown', handleKeydown);
}
// Generate waveform data when audio loads
generateWaveform();
});
onDestroy(() => {
if (typeof document !== 'undefined') {
document.removeEventListener('keydown', handleKeydown);
}
if (animationFrame && typeof window !== 'undefined') {
cancelAnimationFrame(animationFrame);
}
});
function handleKeydown(event) {
// Spacebar to play/pause
if (event.code === 'Space' && !audioLoading) {
event.preventDefault();
togglePlay();
}
}
async function generateWaveform() {
// Only run in browser
if (typeof window === 'undefined') return;
try {
const response = await fetch(`/api/audio/${data.audioFile.id}`);
const arrayBuffer = await response.arrayBuffer();
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
const rawData = audioBuffer.getChannelData(0);
const samples = 200; // Number of bars in waveform
const blockSize = Math.floor(rawData.length / samples);
const filteredData = [];
for (let i = 0; i < samples; i++) {
let blockStart = blockSize * i;
let sum = 0;
for (let j = 0; j < blockSize; j++) {
sum += Math.abs(rawData[blockStart + j]);
}
filteredData.push(sum / blockSize);
}
// Normalize the data
const max = Math.max(...filteredData);
waveformData = filteredData.map(val => val / max);
drawWaveform();
} catch (error) {
console.error('Error generating waveform:', error);
// Fallback to simple bars
waveformData = Array.from({length: 200}, () => Math.random() * 0.8 + 0.2);
drawWaveform();
}
}
function drawWaveform() {
if (typeof window === 'undefined' || !waveformCanvas || waveformData.length === 0) return;
const ctx = waveformCanvas.getContext('2d');
const canvas = waveformCanvas;
// Set canvas size
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * window.devicePixelRatio;
canvas.height = rect.height * window.devicePixelRatio;
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
const width = rect.width;
const height = rect.height;
const barWidth = width / waveformData.length;
const progress = duration > 0 ? currentTime / duration : 0;
ctx.clearRect(0, 0, width, height);
waveformData.forEach((amplitude, index) => {
const barHeight = amplitude * height * 0.8;
const x = index * barWidth;
const y = (height - barHeight) / 2;
// Color bars based on playback progress
if (index / waveformData.length <= progress) {
ctx.fillStyle = '#4f46e5'; // Played portion
} else {
ctx.fillStyle = '#d1d5db'; // Unplayed portion
}
ctx.fillRect(x, y, barWidth - 1, barHeight);
});
if (isPlaying && typeof window !== 'undefined') {
animationFrame = requestAnimationFrame(drawWaveform);
}
}
function togglePlay() {
console.log('Toggle play called, audio paused:', audio?.paused);
console.log('Audio element:', audio);
console.log('Audio src:', audio?.src);
if (audio.paused) {
audio.play().then(() => {
console.log('Audio started playing');
}).catch(error => {
console.error('Error playing audio:', error);
});
} else {
audio.pause();
console.log('Audio paused');
}
}
function handleTimeUpdate() {
currentTime = audio.currentTime;
// Track the maximum time reached to prevent skipping
if (currentTime > maxReachedTime) {
maxReachedTime = currentTime;
}
// Check if user has listened to the full audio (100%)
if (duration > 0 && maxReachedTime >= duration * 0.98) { // 98% to account for small timing differences
isAudioCompleted = true;
}
// Don't auto-save progress - only save when user submits rating
}
function handleLoadedMetadata() {
console.log('Audio metadata loaded, duration:', audio.duration);
duration = audio.duration;
// Always start from the beginning - don't restore previous position
audio.currentTime = 0;
// Draw initial waveform once metadata is loaded
if (waveformData.length > 0) {
drawWaveform();
}
}
function handleError(event) {
console.error('Audio error:', event);
console.error('Audio error details:', audio.error);
}
function handleCanPlay() {
console.log('Audio can play');
audioLoading = false;
}
function handleLoadStart() {
console.log('Audio load started');
audioLoading = true;
audioLoadProgress = 0;
}
function handleProgress() {
if (audio && audio.buffered.length > 0) {
const loaded = audio.buffered.end(audio.buffered.length - 1);
const total = audio.duration || data.audioFile.duration || 1;
audioLoadProgress = (loaded / total) * 100;
console.log('Audio loading progress:', audioLoadProgress + '%');
}
}
function handleCanPlayThrough() {
console.log('Audio can play through');
audioLoading = false;
audioLoadProgress = 100;
}
function handlePlay() {
isPlaying = true;
drawWaveform();
}
function handlePause() {
isPlaying = false;
if (animationFrame && typeof window !== 'undefined') {
cancelAnimationFrame(animationFrame);
}
}
function handleEnded() {
isPlaying = false;
hasListenedToEnd = true;
isAudioCompleted = true;
maxReachedTime = duration;
}
function handleRatingChange() {
hasRatingChanged = true;
// Track rating changes for timeseries visualization
ratingHistory.push({
timestamp: currentTime,
value: ratingValue,
time: Date.now()
});
}
async function saveRating() {
if (!hasRatingChanged || isSavingRating) return;
isSavingRating = true;
const formData = new FormData();
formData.append('participantId', data.participantId);
formData.append('audioFileId', data.audioFile.id);
formData.append('ratingHistory', JSON.stringify(ratingHistory));
formData.append('finalValue', ratingValue.toString());
formData.append('maxReachedTime', maxReachedTime.toString());
formData.append('currentPosition', currentTime.toString());
try {
const response = await fetch('?/saveRating', {
method: 'POST',
body: formData
});
if (response.ok) {
hasRatingChanged = false;
// Reset completion tracking only after successful save
// This ensures the old rating data is truly replaced
hasListenedToEnd = false;
maxReachedTime = 0;
isAudioCompleted = false;
// Redirect back to participant dashboard
window.location.href = `/participate?token=${data.token}`;
} else {
const result = await response.text();
alert('Error saving rating: ' + result);
}
} catch (error) {
console.error('Error saving rating:', error);
alert('Error saving rating. Please try again.');
} finally {
isSavingRating = false;
}
}
async function saveProgress(completed = false) {
const formData = new FormData();
formData.append('participantId', data.participantId);
formData.append('audioFileId', data.audioFile.id);
formData.append('lastPosition', currentTime.toString());
formData.append('isCompleted', completed.toString());
try {
await fetch('?/updateProgress', {
method: 'POST',
body: formData
});
} catch (error) {
console.error('Error saving progress:', error);
}
}
function formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
</script>
<div class="min-h-screen bg-gray-50 py-8">
<div class="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8">
<div class="mb-6">
<a
href="/participate?token={data.token}"
class="text-sm text-indigo-600 hover:text-indigo-900"
>
← Back to Audio List
</a>
</div>
<div class="rounded-lg bg-white p-8 shadow">
<h1 class="mb-2 text-2xl font-bold text-gray-900">{data.audioFile.filename}</h1>
<p class="mb-8 text-gray-600">
Listen to the audio and move the slider to rate it continuously
</p>
<!-- Audio Player -->
<div class="mb-8">
<audio
bind:this={audio}
src="/api/audio/{data.audioFile.id}"
on:timeupdate={handleTimeUpdate}
on:loadedmetadata={handleLoadedMetadata}
on:play={handlePlay}
on:pause={handlePause}
on:ended={handleEnded}
on:error={handleError}
on:canplay={handleCanPlay}
on:loadstart={handleLoadStart}
on:progress={handleProgress}
on:canplaythrough={handleCanPlayThrough}
preload="auto"
style="display: none;"
></audio>
<!-- Loading Indicator -->
{#if audioLoading}
<div class="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-4">
<div class="flex items-center space-x-3">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<div class="flex-1">
<p class="text-sm font-medium text-blue-800">Loading audio file...</p>
<div class="mt-2 w-full bg-blue-200 rounded-full h-2">
<div
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
style="width: {audioLoadProgress}%"
></div>
</div>
<p class="text-xs text-blue-600 mt-1">{Math.round(audioLoadProgress)}% loaded</p>
</div>
</div>
</div>
{/if}
<div class="space-y-6">
<!-- Spacebar Instruction -->
<div class="text-center bg-blue-50 border border-blue-200 rounded-lg p-3">
<p class="text-sm text-blue-800">
<i class="fas fa-keyboard mr-2"></i>
Press <kbd class="px-2 py-1 bg-blue-200 rounded font-mono text-xs">Space</kbd> to play/pause
</p>
</div>
<!-- Play/Pause Button -->
<div class="text-center">
<button
on:click={togglePlay}
disabled={audioLoading}
class="inline-flex h-20 w-20 items-center justify-center rounded-full {audioLoading ? 'bg-gray-400 cursor-not-allowed' : 'bg-indigo-600 hover:bg-indigo-700'} text-white transition-colors shadow-lg"
>
{#if isPlaying}
<i class="fas fa-pause text-2xl"></i>
{:else}
<i class="fas fa-play text-2xl ml-1"></i>
{/if}
</button>
</div>
<!-- Waveform Visualization -->
<div class="space-y-3">
<canvas
bind:this={waveformCanvas}
class="w-full h-20 rounded-lg bg-gray-100"
style="width: 100%; height: 80px;"
></canvas>
<!-- Time Display -->
<div class="flex justify-between text-sm text-gray-500">
<span>{formatTime(currentTime)}</span>
<span class="text-gray-400">
<i class="fas fa-info-circle mr-1"></i>
No seeking allowed - listen continuously
</span>
<span>{formatTime(duration)}</span>
</div>
<!-- Listening Progress Indicator -->
{#if duration > 0}
<div class="mt-3 space-y-2">
<div class="flex justify-between items-center">
<span class="text-xs font-medium text-gray-600">Listening Progress</span>
<span class="text-xs text-gray-500">
{Math.round((maxReachedTime / duration) * 100)}% completed
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="h-2 rounded-full transition-all duration-300 {isAudioCompleted ? 'bg-green-500' : 'bg-blue-500'}"
style="width: {Math.min((maxReachedTime / duration) * 100, 100)}%"
></div>
</div>
<div class="flex justify-between text-xs text-gray-400">
<span>Must listen to full clip</span>
{#if isAudioCompleted}
<span class="text-green-600 font-medium">✓ Ready to submit</span>
{:else}
<span class="text-red-500">Keep listening...</span>
{/if}
</div>
</div>
{/if}
</div>
</div>
</div>
<!-- Rating Slider -->
<div class="space-y-6">
{#if data.existingRatings && data.existingRatings.length > 0}
<div class="rounded-md border border-amber-200 bg-amber-50 p-4">
<p class="font-medium text-amber-800">⚠️ You have already submitted a rating for this audio file</p>
<p class="text-sm text-amber-700 mt-1">
To redo your rating: listen to the full audio clip again, adjust the slider as needed, and click "Update Rating".
This will replace your previous rating completely.
</p>
</div>
{/if}
<div>
<h3 class="mb-4 text-lg font-medium text-gray-900">Rate the Audio (0-100)</h3>
<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.
</p>
<div class="space-y-4">
<input
type="range"
min="0"
max="100"
bind:value={ratingValue}
on:input={handleRatingChange}
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">
<span>Poor (0)</span>
<span class="font-semibold">Current Rating: {ratingValue}</span>
<span>Excellent (100)</span>
</div>
</div>
</div>
<!-- Save Rating Button -->
<div class="flex items-center justify-between">
<button
on:click={saveRating}
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"
>
{#if isSavingRating}
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
Saving...
{:else}
{#if data.existingRatings && data.existingRatings.length > 0}
Update Rating
{:else}
Submit Rating
{/if}
{/if}
</button>
<div class="flex flex-col items-end space-y-1">
{#if hasRatingChanged}
<p class="text-sm text-amber-600">
<i class="fas fa-exclamation-triangle mr-1"></i>
You have unsaved changes
</p>
{/if}
{#if !isAudioCompleted}
<p class="text-sm text-red-600">
<i class="fas fa-play-circle mr-1"></i>
Must listen to full audio clip to submit
</p>
{/if}
</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>
</div>
</div>
<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;
}
</style>

View File

@@ -1,4 +1,4 @@
import adapter from '@sveltejs/adapter-auto'; import adapter from '@sveltejs/adapter-node';
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
const config = { const config = {