first working demo
This commit is contained in:
26
.dockerignore
Normal file
26
.dockerignore
Normal 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
|
||||
10
.env.example
10
.env.example
@@ -1,5 +1,9 @@
|
||||
# Replace with your DB credentials!
|
||||
DATABASE_URL="libsql://db-name-user.turso.io"
|
||||
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
47
Dockerfile
Normal 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" ]
|
||||
@@ -19,6 +19,7 @@ The admin backend should be password protected. The username and password are st
|
||||
- Create invite links for participants
|
||||
- View existing invite links
|
||||
- View participant ratings
|
||||
- Upload and manage audio files which are saved as blobs in the database.
|
||||
|
||||
### User frontend
|
||||
|
||||
@@ -27,4 +28,4 @@ The user frontend is where participants will interact with the app. It allows us
|
||||
- Access using their invite link. The invite link will contain a unique token that identifies the participant.
|
||||
- Listen to audio files
|
||||
- Rate the audio files using a slider
|
||||
- Leave and resume their session. This means that users should be able to see which audio files they have already rated and continue from where they left off.
|
||||
- Leave and resume their session. This means that users should be able to see which audio files they have already rated and continue from where they left off.
|
||||
|
||||
@@ -2,13 +2,19 @@ import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
|
||||
|
||||
const isLocalDb = process.env.DATABASE_URL.startsWith('file:');
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/lib/server/db/schema.js',
|
||||
dialect: 'turso',
|
||||
dbCredentials: {
|
||||
authToken: process.env.DATABASE_AUTH_TOKEN,
|
||||
url: process.env.DATABASE_URL
|
||||
},
|
||||
dialect: isLocalDb ? 'sqlite' : 'turso',
|
||||
dbCredentials: isLocalDb
|
||||
? {
|
||||
url: process.env.DATABASE_URL
|
||||
}
|
||||
: {
|
||||
authToken: process.env.DATABASE_AUTH_TOKEN,
|
||||
url: process.env.DATABASE_URL
|
||||
},
|
||||
verbose: true,
|
||||
strict: true
|
||||
});
|
||||
|
||||
64
drizzle/0000_perfect_night_nurse.sql
Normal file
64
drizzle/0000_perfect_night_nurse.sql
Normal 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`);
|
||||
17
drizzle/0001_easy_khan.sql
Normal file
17
drizzle/0001_easy_khan.sql
Normal 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`);
|
||||
3
drizzle/0002_yummy_kate_bishop.sql
Normal file
3
drizzle/0002_yummy_kate_bishop.sql
Normal 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;
|
||||
2
drizzle/0003_free_master_chief.sql
Normal file
2
drizzle/0003_free_master_chief.sql
Normal 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`);
|
||||
1
drizzle/0004_curvy_hemingway.sql
Normal file
1
drizzle/0004_curvy_hemingway.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `participant_progress` ADD `max_reached_time` real DEFAULT 0;
|
||||
31
drizzle/0005_redesign_ratings.sql
Normal file
31
drizzle/0005_redesign_ratings.sql
Normal 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;
|
||||
3
drizzle/0007_colossal_betty_brant.sql
Normal file
3
drizzle/0007_colossal_betty_brant.sql
Normal 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;
|
||||
1
drizzle/0008_wild_texas_twister.sql
Normal file
1
drizzle/0008_wild_texas_twister.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `audio_file` ADD `deleted_at` integer;
|
||||
0
drizzle/db.sqlite
Normal file
0
drizzle/db.sqlite
Normal file
434
drizzle/meta/0000_snapshot.json
Normal file
434
drizzle/meta/0000_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
458
drizzle/meta/0001_snapshot.json
Normal file
458
drizzle/meta/0001_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
463
drizzle/meta/0002_snapshot.json
Normal file
463
drizzle/meta/0002_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
481
drizzle/meta/0003_snapshot.json
Normal file
481
drizzle/meta/0003_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
489
drizzle/meta/0004_snapshot.json
Normal file
489
drizzle/meta/0004_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
488
drizzle/meta/0005_snapshot.json
Normal file
488
drizzle/meta/0005_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
496
drizzle/meta/0006_snapshot.json
Normal file
496
drizzle/meta/0006_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
493
drizzle/meta/0007_snapshot.json
Normal file
493
drizzle/meta/0007_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
500
drizzle/meta/0008_snapshot.json
Normal file
500
drizzle/meta/0008_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
69
drizzle/meta/_journal.json
Normal file
69
drizzle/meta/_journal.json
Normal 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
22
fly.toml
Normal 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
54
migrate-to-s3.js
Normal 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
3722
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.2.5",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@flydotio/dockerfile": "^0.7.10",
|
||||
"@sveltejs/adapter-auto": "^6.0.0",
|
||||
"@sveltejs/kit": "^2.22.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||
@@ -37,10 +38,14 @@
|
||||
"vite": "^7.0.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.850.0",
|
||||
"@libsql/client": "^0.14.0",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@oslojs/crypto": "^1.0.1",
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||
<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%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
||||
@@ -4,12 +4,27 @@ import { createClient } from '@libsql/client';
|
||||
import * as schema from './schema';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
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');
|
||||
let _db = null;
|
||||
|
||||
const client = createClient({
|
||||
url: env.DATABASE_URL,
|
||||
authToken: env.DATABASE_AUTH_TOKEN
|
||||
function initializeDatabase() {
|
||||
if (!_db) {
|
||||
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 });
|
||||
|
||||
@@ -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', {
|
||||
id: text('id').primaryKey(),
|
||||
@@ -14,3 +15,65 @@ export const session = sqliteTable('session', {
|
||||
.references(() => user.id),
|
||||
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
157
src/lib/server/s3.js
Normal 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}`;
|
||||
}
|
||||
@@ -1,2 +1,50 @@
|
||||
<h1>Welcome to SvelteKit</h1>
|
||||
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
|
||||
<div class="min-h-screen bg-gradient-to-br from-indigo-50 to-white flex flex-col">
|
||||
<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>
|
||||
13
src/routes/admin/+layout.server.js
Normal file
13
src/routes/admin/+layout.server.js
Normal 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
|
||||
};
|
||||
}
|
||||
46
src/routes/admin/+layout.svelte
Normal file
46
src/routes/admin/+layout.svelte
Normal 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>
|
||||
129
src/routes/admin/+page.server.js
Normal file
129
src/routes/admin/+page.server.js
Normal 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'
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
231
src/routes/admin/+page.svelte
Normal file
231
src/routes/admin/+page.svelte
Normal 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>
|
||||
156
src/routes/admin/audio/+page.server.js
Normal file
156
src/routes/admin/audio/+page.server.js
Normal 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' });
|
||||
}
|
||||
}
|
||||
};
|
||||
526
src/routes/admin/audio/+page.svelte
Normal file
526
src/routes/admin/audio/+page.svelte
Normal 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>
|
||||
37
src/routes/admin/audio/[id]/download/+server.js
Normal file
37
src/routes/admin/audio/[id]/download/+server.js
Normal 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');
|
||||
}
|
||||
}
|
||||
37
src/routes/admin/login/+page.server.js
Normal file
37
src/routes/admin/login/+page.server.js
Normal 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');
|
||||
}
|
||||
};
|
||||
60
src/routes/admin/login/+page.svelte
Normal file
60
src/routes/admin/login/+page.svelte
Normal 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>
|
||||
8
src/routes/admin/logout/+page.server.js
Normal file
8
src/routes/admin/logout/+page.server.js
Normal 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');
|
||||
}
|
||||
};
|
||||
106
src/routes/admin/ratings/+page.server.js
Normal file
106
src/routes/admin/ratings/+page.server.js
Normal 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
|
||||
};
|
||||
}
|
||||
267
src/routes/admin/ratings/+page.svelte
Normal file
267
src/routes/admin/ratings/+page.svelte
Normal 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>
|
||||
67
src/routes/api/audio/[id]/+server.js
Normal file
67
src/routes/api/audio/[id]/+server.js
Normal 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');
|
||||
}
|
||||
}
|
||||
154
src/routes/api/export/timeseries/+server.js
Normal file
154
src/routes/api/export/timeseries/+server.js
Normal 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'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
114
src/routes/participate/+page.server.js
Normal file
114
src/routes/participate/+page.server.js
Normal 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
|
||||
};
|
||||
}
|
||||
146
src/routes/participate/+page.svelte
Normal file
146
src/routes/participate/+page.svelte
Normal 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>
|
||||
256
src/routes/participate/audio/[id]/+page.server.js
Normal file
256
src/routes/participate/audio/[id]/+page.server.js
Normal 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' };
|
||||
}
|
||||
}
|
||||
};
|
||||
534
src/routes/participate/audio/[id]/+page.svelte
Normal file
534
src/routes/participate/audio/[id]/+page.svelte
Normal 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>
|
||||
@@ -1,4 +1,4 @@
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
|
||||
Reference in New Issue
Block a user