From 94dada75ba7191a8f32c3ca9ea136e2b8f98b933 Mon Sep 17 00:00:00 2001 From: saltbo Date: Thu, 4 Jun 2026 14:08:39 -0400 Subject: [PATCH] feat(downloads): add task actions and classification --- .../0038_add-download-task-classification.sql | 2 + migrations/meta/0038_snapshot.json | 3937 +++++++++++++++++ migrations/meta/_journal.json | 7 + server/db/schema.ts | 2 + .../routes/download-tasks.integration.test.ts | 117 +- server/routes/download-tasks.ts | 60 +- server/services/downloads.ts | 125 +- server/test/setup.ts | 2 + shared/schemas/downloads.ts | 13 + shared/schemas/index.ts | 3 + shared/types/index.ts | 5 + src/i18n/locales/en.json | 17 + src/i18n/locales/zh.json | 17 + src/lib/api.test.ts | 43 +- src/lib/api.ts | 18 +- src/routes/_authenticated/downloads/index.tsx | 233 +- 16 files changed, 4555 insertions(+), 46 deletions(-) create mode 100644 migrations/0038_add-download-task-classification.sql create mode 100644 migrations/meta/0038_snapshot.json diff --git a/migrations/0038_add-download-task-classification.sql b/migrations/0038_add-download-task-classification.sql new file mode 100644 index 00000000..306bba6c --- /dev/null +++ b/migrations/0038_add-download-task-classification.sql @@ -0,0 +1,2 @@ +ALTER TABLE `download_tasks` ADD `category` text;--> statement-breakpoint +ALTER TABLE `download_tasks` ADD `tags` text DEFAULT '[]' NOT NULL; \ No newline at end of file diff --git a/migrations/meta/0038_snapshot.json b/migrations/meta/0038_snapshot.json new file mode 100644 index 00000000..275eccaf --- /dev/null +++ b/migrations/meta/0038_snapshot.json @@ -0,0 +1,3937 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "9d853e25-bbdf-4a02-8cc9-c236b06777e5", + "prevId": "c0440992-159b-4854-8dba-25ecf4bf6750", + "tables": { + "activity_events": { + "name": "activity_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_name": { + "name": "target_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "announcements": { + "name": "announcements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "announcements_status_priority_idx": { + "name": "announcements_status_priority_idx", + "columns": [ + "status", + "priority" + ], + "isUnique": false + }, + "announcements_published_idx": { + "name": "announcements_published_idx", + "columns": [ + "published_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "background_jobs": { + "name": "background_jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_folder": { + "name": "target_folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_path": { + "name": "target_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "input_bytes": { + "name": "input_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "output_bytes": { + "name": "output_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "processed_bytes": { + "name": "processed_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "current_filename": { + "name": "current_filename", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "result_metadata": { + "name": "result_metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retryable": { + "name": "retryable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "cancelable": { + "name": "cancelable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "retried_from_job_id": { + "name": "retried_from_job_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "background_jobs_org_created_idx": { + "name": "background_jobs_org_created_idx", + "columns": [ + "org_id", + "created_at" + ], + "isUnique": false + }, + "background_jobs_org_status_idx": { + "name": "background_jobs_org_status_idx", + "columns": [ + "org_id", + "status" + ], + "isUnique": false + }, + "background_jobs_org_type_idx": { + "name": "background_jobs_org_type_idx", + "columns": [ + "org_id", + "type" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "cloud_traffic_reports": { + "name": "cloud_traffic_reports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_id": { + "name": "storage_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit_bytes": { + "name": "unit_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credits_per_unit": { + "name": "credits_per_unit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "cloud_traffic_reports_event_uniq": { + "name": "cloud_traffic_reports_event_uniq", + "columns": [ + "event_id" + ], + "isUnique": true + }, + "cloud_traffic_reports_org_period_idx": { + "name": "cloud_traffic_reports_org_period_idx", + "columns": [ + "org_id", + "period" + ], + "isUnique": false + }, + "cloud_traffic_reports_status_idx": { + "name": "cloud_traffic_reports_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "download_tasks": { + "name": "download_tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_uri": { + "name": "source_uri", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_folder": { + "name": "target_folder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "assigned_downloader_id": { + "name": "assigned_downloader_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "downloaded_bytes": { + "name": "downloaded_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "uploaded_bytes": { + "name": "uploaded_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "authorized_bytes": { + "name": "authorized_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "billed_bytes": { + "name": "billed_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "billed_credits": { + "name": "billed_credits", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "billing_status": { + "name": "billing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "download_bps": { + "name": "download_bps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "upload_bps": { + "name": "upload_bps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "result_object_id": { + "name": "result_object_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upload_token_hash": { + "name": "upload_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upload_token_jti": { + "name": "upload_token_jti", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upload_token_expires_at": { + "name": "upload_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "download_tasks_org_created_idx": { + "name": "download_tasks_org_created_idx", + "columns": [ + "org_id", + "created_at" + ], + "isUnique": false + }, + "download_tasks_org_status_idx": { + "name": "download_tasks_org_status_idx", + "columns": [ + "org_id", + "status" + ], + "isUnique": false + }, + "download_tasks_downloader_idx": { + "name": "download_tasks_downloader_idx", + "columns": [ + "assigned_downloader_id", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "downloaders": { + "name": "downloaders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_jti": { + "name": "token_jti", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'offline'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "arch": { + "name": "arch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "engine": { + "name": "engine", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'builtin'" + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "max_concurrent_tasks": { + "name": "max_concurrent_tasks", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "current_tasks": { + "name": "current_tasks", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "download_bps": { + "name": "download_bps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "upload_bps": { + "name": "upload_bps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "free_disk_bytes": { + "name": "free_disk_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "remote_download_credit_billing_enabled": { + "name": "remote_download_credit_billing_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "remote_download_credit_unit_bytes": { + "name": "remote_download_credit_unit_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 104857600 + }, + "remote_download_credit_per_unit": { + "name": "remote_download_credit_per_unit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "last_heartbeat_at": { + "name": "last_heartbeat_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "downloaders_token_jti_unique": { + "name": "downloaders_token_jti_unique", + "columns": [ + "token_jti" + ], + "isUnique": true + }, + "downloaders_status_idx": { + "name": "downloaders_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "downloaders_enabled_idx": { + "name": "downloaders_enabled_idx", + "columns": [ + "enabled" + ], + "isUnique": false + }, + "downloaders_created_idx": { + "name": "downloaders_created_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "image_hosting_configs": { + "name": "image_hosting_configs", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "custom_domain": { + "name": "custom_domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cf_hostname_id": { + "name": "cf_hostname_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "domain_verified_at": { + "name": "domain_verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referer_allowlist": { + "name": "referer_allowlist", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "image_hosting_configs_custom_domain_unique": { + "name": "image_hosting_configs_custom_domain_unique", + "columns": [ + "custom_domain" + ], + "isUnique": true + } + }, + "foreignKeys": { + "image_hosting_configs_org_id_organization_id_fk": { + "name": "image_hosting_configs_org_id_organization_id_fk", + "tableFrom": "image_hosting_configs", + "tableTo": "organization", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "image_hostings": { + "name": "image_hostings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_id": { + "name": "storage_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime": { + "name": "mime", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "access_count": { + "name": "access_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "image_hostings_token_unique": { + "name": "image_hostings_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "image_hostings_org_path_uniq": { + "name": "image_hostings_org_path_uniq", + "columns": [ + "org_id", + "path" + ], + "isUnique": true + }, + "image_hostings_org_created_idx": { + "name": "image_hostings_org_created_idx", + "columns": [ + "org_id", + "created_at" + ], + "isUnique": false + }, + "image_hostings_token_idx": { + "name": "image_hostings_token_idx", + "columns": [ + "token" + ], + "isUnique": false + } + }, + "foreignKeys": { + "image_hostings_org_id_organization_id_fk": { + "name": "image_hostings_org_id_organization_id_fk", + "tableFrom": "image_hostings", + "tableTo": "organization", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "image_hostings_storage_id_storages_id_fk": { + "name": "image_hostings_storage_id_storages_id_fk", + "tableFrom": "image_hostings", + "tableTo": "storages", + "columnsFrom": [ + "storage_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invite_codes": { + "name": "invite_codes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_by": { + "name": "used_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invite_codes_code_unique": { + "name": "invite_codes_code_unique", + "columns": [ + "code" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "license_bindings": { + "name": "license_bindings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "cloud_binding_id": { + "name": "cloud_binding_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_store_id": { + "name": "cloud_store_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_account_id": { + "name": "cloud_account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_account_email": { + "name": "cloud_account_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cached_certificate": { + "name": "cached_certificate", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cached_certificate_expires_at": { + "name": "cached_certificate_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bound_at": { + "name": "bound_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "disconnected_at": { + "name": "disconnected_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refresh_at": { + "name": "last_refresh_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refresh_error": { + "name": "last_refresh_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "license_bindings_active_uniq": { + "name": "license_bindings_active_uniq", + "columns": [ + "status" + ], + "isUnique": true, + "where": "status = 'active'" + }, + "license_bindings_cloud_binding_idx": { + "name": "license_bindings_cloud_binding_idx", + "columns": [ + "cloud_binding_id" + ], + "isUnique": false + }, + "license_bindings_instance_idx": { + "name": "license_bindings_instance_idx", + "columns": [ + "instance_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "matters": { + "name": "matters", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "alias": { + "name": "alias", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "dirtype": { + "name": "dirtype", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "parent": { + "name": "parent", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "object": { + "name": "object", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "storage_id": { + "name": "storage_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "trashed_at": { + "name": "trashed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "matters_alias_unique": { + "name": "matters_alias_unique", + "columns": [ + "alias" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notifications": { + "name": "notifications", + "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 + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "ref_type": { + "name": "ref_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read_at": { + "name": "read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "notifications_user_created_idx": { + "name": "notifications_user_created_idx", + "columns": [ + "user_id", + "created_at" + ], + "isUnique": false + }, + "notifications_user_read_idx": { + "name": "notifications_user_read_idx", + "columns": [ + "user_id", + "read_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "object_upload_sessions": { + "name": "object_upload_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_id": { + "name": "object_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_id": { + "name": "storage_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "upload_id": { + "name": "upload_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part_size": { + "name": "part_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "object_upload_sessions_object_idx": { + "name": "object_upload_sessions_object_idx", + "columns": [ + "org_id", + "object_id" + ], + "isUnique": false + }, + "object_upload_sessions_expires_idx": { + "name": "object_upload_sessions_expires_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "org_quota_entitlements": { + "name": "org_quota_entitlements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entitlement_type": { + "name": "entitlement_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'grant'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "starts_at": { + "name": "starts_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "org_quota_entitlements_org_resource_idx": { + "name": "org_quota_entitlements_org_resource_idx", + "columns": [ + "org_id", + "resource_type", + "status" + ], + "isUnique": false + }, + "org_quota_entitlements_org_type_idx": { + "name": "org_quota_entitlements_org_type_idx", + "columns": [ + "org_id", + "resource_type", + "entitlement_type", + "status" + ], + "isUnique": false + }, + "org_quota_entitlements_active_plan_uniq": { + "name": "org_quota_entitlements_active_plan_uniq", + "columns": [ + "org_id", + "resource_type", + "entitlement_type" + ], + "isUnique": true, + "where": "status = 'active' AND entitlement_type = 'plan'" + }, + "org_quota_entitlements_source_resource_uniq": { + "name": "org_quota_entitlements_source_resource_uniq", + "columns": [ + "source", + "source_id", + "resource_type" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "org_quotas": { + "name": "org_quotas", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quota": { + "name": "quota", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "used": { + "name": "used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "traffic_quota": { + "name": "traffic_quota", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "traffic_used": { + "name": "traffic_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "traffic_period": { + "name": "traffic_period", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'1970-01'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "remote_download_usage_reports": { + "name": "remote_download_usage_reports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "downloader_id": { + "name": "downloader_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "unit_index": { + "name": "unit_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "unit_bytes": { + "name": "unit_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credits_per_unit": { + "name": "credits_per_unit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "remote_download_usage_reports_event_id_unique": { + "name": "remote_download_usage_reports_event_id_unique", + "columns": [ + "event_id" + ], + "isUnique": true + }, + "remote_download_usage_task_unit_uniq": { + "name": "remote_download_usage_task_unit_uniq", + "columns": [ + "task_id", + "unit_index" + ], + "isUnique": true + }, + "remote_download_usage_org_idx": { + "name": "remote_download_usage_org_idx", + "columns": [ + "org_id" + ], + "isUnique": false + }, + "remote_download_usage_status_idx": { + "name": "remote_download_usage_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "share_recipients": { + "name": "share_recipients", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "share_id": { + "name": "share_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_user_id": { + "name": "recipient_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipient_email": { + "name": "recipient_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "share_recipients_share_id_idx": { + "name": "share_recipients_share_id_idx", + "columns": [ + "share_id" + ], + "isUnique": false + }, + "share_recipients_user_id_idx": { + "name": "share_recipients_user_id_idx", + "columns": [ + "recipient_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "shares": { + "name": "shares", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "matter_id": { + "name": "matter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "download_limit": { + "name": "download_limit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "downloads": { + "name": "downloads", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "shares_token_unique": { + "name": "shares_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "shares_creator_status_created_idx": { + "name": "shares_creator_status_created_idx", + "columns": [ + "creator_id", + "status", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "site_invitations": { + "name": "site_invitations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_by": { + "name": "accepted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "site_invitations_token_unique": { + "name": "site_invitations_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "site_invitations_email_idx": { + "name": "site_invitations_email_idx", + "columns": [ + "email" + ], + "isUnique": false + }, + "site_invitations_created_idx": { + "name": "site_invitations_created_idx", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "site_invitations_expires_idx": { + "name": "site_invitations_expires_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storages": { + "name": "storages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'auto'" + }, + "access_key": { + "name": "access_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret_key": { + "name": "secret_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "custom_host": { + "name": "custom_host", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "egress_credit_billing_enabled": { + "name": "egress_credit_billing_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "egress_credit_unit_bytes": { + "name": "egress_credit_unit_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 104857600 + }, + "egress_credit_per_unit": { + "name": "egress_credit_per_unit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "used": { + "name": "used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "system_options": { + "name": "system_options", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "team_invite_links": { + "name": "team_invite_links", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "team_invite_links_token_unique": { + "name": "team_invite_links_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webdav_dead_properties": { + "name": "webdav_dead_properties", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_path": { + "name": "resource_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "webdav_dead_properties_resource_prop_uniq": { + "name": "webdav_dead_properties_resource_prop_uniq", + "columns": [ + "org_id", + "resource_path", + "namespace", + "name" + ], + "isUnique": true + }, + "webdav_dead_properties_resource_idx": { + "name": "webdav_dead_properties_resource_idx", + "columns": [ + "org_id", + "resource_path" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webdav_locks": { + "name": "webdav_locks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_path": { + "name": "resource_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "depth": { + "name": "depth", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'infinity'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "webdav_locks_token_unique": { + "name": "webdav_locks_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "webdav_locks_resource_idx": { + "name": "webdav_locks_resource_idx", + "columns": [ + "org_id", + "resource_path" + ], + "isUnique": false + }, + "webdav_locks_expires_idx": { + "name": "webdav_locks_expires_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhook_events": { + "name": "webhook_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'cloud'" + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'order.quota_changed'" + }, + "payload_hash": { + "name": "payload_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "raw_payload": { + "name": "raw_payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "processed_at": { + "name": "processed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhook_events_source_event_uniq": { + "name": "webhook_events_source_event_uniq", + "columns": [ + "source", + "event_id" + ], + "isUnique": true + }, + "webhook_events_source_created_idx": { + "name": "webhook_events_source_created_idx", + "columns": [ + "source", + "created_at" + ], + "isUnique": false + }, + "webhook_events_status_idx": { + "name": "webhook_events_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "apikey_config_id_idx": { + "name": "apikey_config_id_idx", + "columns": [ + "config_id" + ], + "isUnique": false + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": [ + "reference_id" + ], + "isUnique": false + }, + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": [ + "key" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deviceCode": { + "name": "deviceCode", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "deviceCode_device_code_idx": { + "name": "deviceCode_device_code_idx", + "columns": [ + "device_code" + ], + "isUnique": false + }, + "deviceCode_user_code_idx": { + "name": "deviceCode_user_code_idx", + "columns": [ + "user_code" + ], + "isUnique": false + }, + "deviceCode_status_idx": { + "name": "deviceCode_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "banned": { + "name": "banned", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "user_username_unique": { + "name": "user_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 3290eea6..809216e8 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -260,6 +260,13 @@ "when": 1780502607046, "tag": "0037_add-download-task-upload-progress", "breakpoints": true + }, + { + "idx": 38, + "version": "6", + "when": 1780596348411, + "tag": "0038_add-download-task-classification", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/db/schema.ts b/server/db/schema.ts index f3acccaf..efb3294d 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -336,6 +336,8 @@ export const downloadTasks = sqliteTable( sourceUri: text('source_uri').notNull(), name: text('name'), targetFolder: text('target_folder').notNull().default(''), + category: text('category'), + tags: text('tags').notNull().default('[]'), assignedDownloaderId: text('assigned_downloader_id'), status: text('status').notNull(), downloadedBytes: integer('downloaded_bytes').notNull().default(0), diff --git a/server/routes/download-tasks.integration.test.ts b/server/routes/download-tasks.integration.test.ts index a9721b07..2b5410a3 100644 --- a/server/routes/download-tasks.integration.test.ts +++ b/server/routes/download-tasks.integration.test.ts @@ -167,6 +167,8 @@ describe('Download tasks API integration', () => { source: { type: 'http', uri: 'https://example.com/fixture.txt' }, targetFolder: 'Remote Downloads', name: 'fixture.txt', + category: 'fixtures', + tags: ['sample', 'http'], }), }) expect(createTaskRes.status).toBe(201) @@ -174,21 +176,27 @@ describe('Download tasks API integration', () => { id: string assignedDownloaderId: string status: string + category: string + tags: string[] uploadToken?: string } expect(createdTask.status).toBe('assigned') expect(createdTask.assignedDownloaderId).toBe(createdDownloader.downloader.id) + expect(createdTask.category).toBe('fixtures') + expect(createdTask.tags).toEqual(['sample', 'http']) expect(createdTask.uploadToken).toBeUndefined() - const assignedRes = await app.request('/api/download-tasks?assignedTo=me', { + const assignedRes = await app.request('/api/download-tasks?assignedTo=me&category=fixtures&tag=http', { headers: { Authorization: `Bearer ${createdDownloader.token}` }, }) expect(assignedRes.status).toBe(200) const assigned = (await assignedRes.json()) as { - items: Array<{ id: string; uploadToken?: string; status: string }> + items: Array<{ id: string; uploadToken?: string; status: string; category: string; tags: string[] }> } const assignedTask = assigned.items.find((item) => item.id === createdTask.id) expect(assignedTask?.status).toBe('assigned') + expect(assignedTask?.category).toBe('fixtures') + expect(assignedTask?.tags).toEqual(['sample', 'http']) expect(assignedTask?.uploadToken).toBeTruthy() const uploadHeaders = { Authorization: `Bearer ${assignedTask?.uploadToken}`, @@ -351,4 +359,109 @@ describe('Download tasks API integration', () => { expect(task.downloadedBytes).toBe(10 * 1024 * 1024) expect(task.storageUploadedBytes).toBe(10 * 1024 * 1024) }) + + it('submits user task actions through downloader polling state', async () => { + const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) + await insertStorage(db) + + const createdDownloader = await registerDownloaderThroughDeviceLogin(app, 'action-downloader') + const downloaderHeaders = { + Authorization: `Bearer ${createdDownloader.token}`, + 'Content-Type': 'application/json', + } + const heartbeatRes = await app.request('/api/downloader/heartbeat', { + method: 'POST', + headers: downloaderHeaders, + body: JSON.stringify({ ...heartbeat, currentTasks: 0 }), + }) + expect(heartbeatRes.status).toBe(200) + + const user = await authedHeaders(app, 'download-actions-user@example.com') + const createTaskRes = await app.request('/api/download-tasks', { + method: 'POST', + headers: { ...user, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + source: { type: 'http', uri: 'https://example.com/actions.bin' }, + targetFolder: '', + }), + }) + expect(createTaskRes.status).toBe(201) + const createdTask = (await createTaskRes.json()) as { id: string; status: string; assignedDownloaderId: string } + expect(createdTask.status).toBe('assigned') + expect(createdTask.assignedDownloaderId).toBe(createdDownloader.downloader.id) + + const pauseRes = await app.request(`/api/download-tasks/${createdTask.id}/actions`, { + method: 'POST', + headers: { ...user, 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'pause' }), + }) + expect(pauseRes.status).toBe(200) + await expect(pauseRes.json()).resolves.toMatchObject({ status: 'paused' }) + + const pausedAssignedRes = await app.request('/api/download-tasks?assignedTo=me', { + headers: { Authorization: `Bearer ${createdDownloader.token}` }, + }) + expect(pausedAssignedRes.status).toBe(200) + const pausedAssigned = (await pausedAssignedRes.json()) as { items: Array<{ id: string; status: string }> } + expect(pausedAssigned.items.find((item) => item.id === createdTask.id)?.status).toBe('paused') + + const resumeRes = await app.request(`/api/download-tasks/${createdTask.id}/actions`, { + method: 'POST', + headers: { ...user, 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'resume' }), + }) + expect(resumeRes.status).toBe(200) + await expect(resumeRes.json()).resolves.toMatchObject({ status: 'assigned' }) + + const cancelRes = await app.request(`/api/download-tasks/${createdTask.id}/actions`, { + method: 'POST', + headers: { ...user, 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'cancel' }), + }) + expect(cancelRes.status).toBe(200) + await expect(cancelRes.json()).resolves.toMatchObject({ status: 'canceled' }) + + const canceledAssignedRes = await app.request('/api/download-tasks?assignedTo=me', { + headers: { Authorization: `Bearer ${createdDownloader.token}` }, + }) + expect(canceledAssignedRes.status).toBe(200) + const canceledAssigned = (await canceledAssignedRes.json()) as { items: Array<{ id: string; status: string }> } + expect(canceledAssigned.items.find((item) => item.id === createdTask.id)?.status).toBe('canceled') + + const deleteRes = await app.request(`/api/download-tasks/${createdTask.id}/actions`, { + method: 'POST', + headers: { ...user, 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'delete' }), + }) + expect(deleteRes.status).toBe(200) + await expect(deleteRes.json()).resolves.toEqual({ id: createdTask.id, deleted: true }) + }) + + it('rejects invalid task actions', async () => { + const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) + await insertStorage(db) + const user = await authedHeaders(app, 'invalid-download-actions-user@example.com') + + const createTaskRes = await app.request('/api/download-tasks', { + method: 'POST', + headers: { ...user, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + source: { type: 'http', uri: 'https://example.com/no-downloader.bin' }, + targetFolder: '', + }), + }) + expect(createTaskRes.status).toBe(201) + const createdTask = (await createTaskRes.json()) as { id: string; status: string } + expect(createdTask.status).toBe('queued') + + const deleteRes = await app.request(`/api/download-tasks/${createdTask.id}/actions`, { + method: 'POST', + headers: { ...user, 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'delete' }), + }) + expect(deleteRes.status).toBe(409) + await expect(deleteRes.json()).resolves.toMatchObject({ + error: 'Only completed, failed, or canceled tasks can be deleted', + }) + }) }) diff --git a/server/routes/download-tasks.ts b/server/routes/download-tasks.ts index c4698337..78b7dc31 100644 --- a/server/routes/download-tasks.ts +++ b/server/routes/download-tasks.ts @@ -1,6 +1,7 @@ import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi' import { createDownloadTaskSchema, + downloadTaskActionInputSchema, downloadTaskDetailSchema, listDownloadTasksQuerySchema, updateDownloadTaskSchema, @@ -13,6 +14,7 @@ import { DownloadError, getDownloadTask, listDownloadTasks, + performDownloadTaskAction, updateDownloadTask, } from '../services/downloads' @@ -38,7 +40,19 @@ const downloadTaskSchema = z.object({ sourceUri: z.string(), name: z.string(), targetFolder: z.string(), - status: z.enum(['queued', 'assigned', 'running', 'billing_paused', 'uploading', 'completed', 'failed', 'canceled']), + category: z.string().nullable(), + tags: z.array(z.string()), + status: z.enum([ + 'queued', + 'assigned', + 'running', + 'billing_paused', + 'paused', + 'uploading', + 'completed', + 'failed', + 'canceled', + ]), downloadedBytes: int64Schema(), storageUploadedBytes: int64Schema(), totalBytes: nullableInt64Schema(), @@ -93,6 +107,7 @@ const eventsRoute = createRoute({ method: 'get', path: '/events', middleware: [requirePermission('remoteDownload', 'read')] as const, + request: { query: listDownloadTasksQuerySchema }, responses: { 200: { content: { 'text/event-stream': { schema: z.string() } }, @@ -130,6 +145,26 @@ const updateRoute = createRoute({ }, }) +const actionRoute = createRoute({ + method: 'post', + path: '/{id}/actions', + middleware: [requirePermission('remoteDownload', 'cancel')] as const, + request: { + params: z.object({ id: z.string() }), + body: { content: { 'application/json': { schema: downloadTaskActionInputSchema } }, required: true }, + }, + responses: { + 200: jsonResponse( + z.union([downloadTaskSchema, z.object({ id: z.string(), deleted: z.literal(true) })]), + 'Task action result', + ), + 401: jsonResponse(errorSchema, 'Unauthorized'), + 403: jsonResponse(errorSchema, 'Forbidden'), + 404: jsonResponse(errorSchema, 'Not found'), + 409: jsonResponse(errorSchema, 'Invalid task state'), + }, +}) + const downloadTasksRoute = new OpenAPIHono() .openapi(listRoute, (async (c: OpenAPIContext) => { const principal = c.get('principal') @@ -139,6 +174,8 @@ const downloadTasksRoute = new OpenAPIHono() const result = await listDownloadTasks(c.get('platform'), { downloaderId: principal.downloaderId, status: query.status, + category: query.category, + tag: query.tag, page: query.page, pageSize: query.pageSize, includeUploadToken: true, @@ -151,6 +188,8 @@ const downloadTasksRoute = new OpenAPIHono() const result = await listDownloadTasks(c.get('platform'), { orgId, status: query.status, + category: query.category, + tag: query.tag, page: query.page, pageSize: query.pageSize, }) @@ -176,6 +215,7 @@ const downloadTasksRoute = new OpenAPIHono() .openapi(eventsRoute, (async (c: OpenAPIContext) => { const orgId = c.get('orgId') if (!orgId) return c.json({ error: 'Unauthorized' }, 401) + const query = c.req.valid('query') as z.infer const signal = c.req.raw.signal let closed = false let lastFingerprint = '' @@ -188,11 +228,18 @@ const downloadTasksRoute = new OpenAPIHono() const tick = async () => { if (closed) return try { - const result = await listDownloadTasks(c.get('platform'), { orgId, page: 1, pageSize: 50 }) + const result = await listDownloadTasks(c.get('platform'), { + orgId, + status: query.status, + category: query.category, + tag: query.tag, + page: query.page, + pageSize: query.pageSize, + }) const fingerprint = result.items.map((task) => `${task.id}:${task.updatedAt}`).join('|') if (fingerprint !== lastFingerprint) { lastFingerprint = fingerprint - send('snapshot', { items: result.items, total: result.total, page: 1, pageSize: 50 }) + send('snapshot', { items: result.items, total: result.total, page: query.page, pageSize: query.pageSize }) } else { send('heartbeat', { at: new Date().toISOString() }) } @@ -225,6 +272,13 @@ const downloadTasksRoute = new OpenAPIHono() const id = c.req.param('id') as string return downloadTaskResponse(c, async () => getDownloadTask(c.get('platform'), orgId, id)) }) as never) + .openapi(actionRoute, (async (c: OpenAPIContext) => { + const orgId = c.get('orgId') + if (!orgId) return c.json({ error: 'Unauthorized' }, 401) + const id = c.req.param('id') as string + const { action } = c.req.valid('json') as z.infer + return downloadTaskResponse(c, async () => performDownloadTaskAction(c.get('platform'), orgId, id, action)) + }) as never) .openapi(updateRoute, (async (c: OpenAPIContext) => { const principal = c.get('principal') const id = c.req.param('id') as string diff --git a/server/services/downloads.ts b/server/services/downloads.ts index 4382e866..475d1556 100644 --- a/server/services/downloads.ts +++ b/server/services/downloads.ts @@ -2,11 +2,12 @@ import type { CreateDownloaderInput, CreateDownloadTaskInput, DownloaderHeartbeatInput, + DownloadTaskActionInput, UpdateDownloaderInput, UpdateDownloadTaskInput, } from '@shared/schemas' import type { Downloader, DownloadTask } from '@shared/types' -import { and, asc, count, desc, eq, inArray } from 'drizzle-orm' +import { and, asc, count, desc, eq, inArray, like } from 'drizzle-orm' import { nanoid } from 'nanoid' import { downloaders, downloadTasks } from '../db/schema' import type { Platform } from '../platform/interface' @@ -15,6 +16,8 @@ import { RemoteDownloadBillingBlockedError, reportRemoteDownloadUnit } from './r const DEFAULT_REMOTE_DOWNLOAD_UNIT_BYTES = 100 * 1024 * 1024 const UPLOAD_TOKEN_TTL_SECONDS = 24 * 60 * 60 +const ACTIVE_TASK_STATUSES = ['queued', 'assigned', 'running', 'billing_paused', 'uploading'] as const +const TERMINAL_TASK_STATUSES = ['completed', 'failed', 'canceled'] as const export class DownloadError extends Error { constructor( @@ -25,7 +28,7 @@ export class DownloadError extends Error { | 'invalid_state' | 'billing_paused' | 'unsupported_source', - message = code, + message: string = code, ) { super(message) this.name = 'DownloadError' @@ -139,7 +142,7 @@ export async function deleteDownloader(platform: Platform, id: string): Promise< .where( and( eq(downloadTasks.assignedDownloaderId, id), - inArray(downloadTasks.status, ['queued', 'assigned', 'running', 'billing_paused', 'uploading']), + inArray(downloadTasks.status, [...ACTIVE_TASK_STATUSES, 'paused']), ), ) await platform.db.delete(downloaders).where(eq(downloaders.id, id)) @@ -207,6 +210,8 @@ export async function createDownloadTask( sourceUri: input.source.uri, name: input.name ?? null, targetFolder: input.targetFolder, + category: input.category ?? null, + tags: JSON.stringify(input.tags ?? []), assignedDownloaderId: assigned?.id ?? null, status: assigned ? 'assigned' : 'queued', uploadTokenHash: uploadToken?.hash ?? null, @@ -225,6 +230,8 @@ export async function listDownloadTasks( orgId?: string downloaderId?: string status?: string + category?: string + tag?: string page: number pageSize: number includeUploadToken?: boolean @@ -235,6 +242,8 @@ export async function listDownloadTasks( if (opts.orgId) filters.push(eq(downloadTasks.orgId, opts.orgId)) if (opts.downloaderId) filters.push(eq(downloadTasks.assignedDownloaderId, opts.downloaderId)) if (opts.status) filters.push(eq(downloadTasks.status, opts.status)) + if (opts.category) filters.push(eq(downloadTasks.category, opts.category)) + if (opts.tag) filters.push(like(downloadTasks.tags, `%${JSON.stringify(opts.tag)}%`)) const where = filters.length ? and(...filters) : undefined const [rows, totalRows] = await Promise.all([ platform.db @@ -371,6 +380,105 @@ export async function updateDownloadTask( return getDownloadTask(platform, task.orgId, id) } +export async function performDownloadTaskAction( + platform: Platform, + orgId: string, + id: string, + action: DownloadTaskActionInput['action'], +): Promise { + const rows = await platform.db + .select() + .from(downloadTasks) + .where(and(eq(downloadTasks.id, id), eq(downloadTasks.orgId, orgId))) + .limit(1) + const task = rows[0] + if (!task) throw new DownloadError('not_found') + + if (action === 'delete') { + if (!TERMINAL_TASK_STATUSES.includes(task.status as (typeof TERMINAL_TASK_STATUSES)[number])) { + throw new DownloadError('invalid_state', 'Only completed, failed, or canceled tasks can be deleted') + } + await platform.db.delete(downloadTasks).where(eq(downloadTasks.id, id)) + return { id, deleted: true } + } + + const now = new Date() + if (action === 'pause') { + if (task.status === 'paused') return toDownloadTask(task) + if (!ACTIVE_TASK_STATUSES.includes(task.status as (typeof ACTIVE_TASK_STATUSES)[number])) { + throw new DownloadError('invalid_state', 'Only active tasks can be paused') + } + await platform.db + .update(downloadTasks) + .set({ status: 'paused', downloadBps: 0, uploadBps: 0, updatedAt: now }) + .where(eq(downloadTasks.id, id)) + return getDownloadTask(platform, orgId, id) + } + + if (action === 'resume') { + if (task.status !== 'paused') throw new DownloadError('invalid_state', 'Only paused tasks can be resumed') + await platform.db + .update(downloadTasks) + .set({ + status: task.assignedDownloaderId ? 'assigned' : 'queued', + downloadBps: 0, + uploadBps: 0, + updatedAt: now, + }) + .where(eq(downloadTasks.id, id)) + if (!task.assignedDownloaderId) await assignQueuedTasks(platform) + return getDownloadTask(platform, orgId, id) + } + + if (action === 'cancel') { + if (task.status === 'canceled') return toDownloadTask(task) + if (task.status === 'completed') throw new DownloadError('invalid_state', 'Completed tasks cannot be canceled') + await platform.db + .update(downloadTasks) + .set({ + status: 'canceled', + downloadBps: 0, + uploadBps: 0, + finishedAt: task.finishedAt ?? now, + updatedAt: now, + }) + .where(eq(downloadTasks.id, id)) + return getDownloadTask(platform, orgId, id) + } + + if (action === 'retry') { + if (!['failed', 'canceled'].includes(task.status)) { + throw new DownloadError('invalid_state', 'Only failed or canceled tasks can be retried') + } + await platform.db + .update(downloadTasks) + .set({ + status: 'queued', + assignedDownloaderId: null, + uploadTokenHash: null, + uploadTokenJti: null, + uploadTokenExpiresAt: null, + downloadedBytes: 0, + uploadedBytes: 0, + totalBytes: null, + downloadBps: 0, + uploadBps: 0, + errorMessage: null, + resultObjectId: null, + detail: null, + assignedAt: null, + startedAt: null, + finishedAt: null, + updatedAt: now, + }) + .where(eq(downloadTasks.id, id)) + await assignQueuedTasks(platform) + return getDownloadTask(platform, orgId, id) + } + + throw new DownloadError('invalid_state') +} + export async function assertTaskUploadAllowed(platform: Platform, params: { taskId: string; downloaderId: string }) { const rows = await platform.db.select().from(downloadTasks).where(eq(downloadTasks.id, params.taskId)).limit(1) const task = rows[0] @@ -511,6 +619,8 @@ function toDownloadTask(row: DownloadTaskRow): DownloadTask { sourceUri: row.sourceUri, name: row.name, targetFolder: row.targetFolder, + category: row.category, + tags: parseTaskTags(row.tags), assignedDownloaderId: row.assignedDownloaderId, status: row.status as DownloadTask['status'], downloadedBytes: row.downloadedBytes, @@ -547,6 +657,15 @@ function parseTaskDetail(value: string | null): DownloadTask['detail'] { } } +function parseTaskTags(value: string): string[] { + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : [] + } catch { + return [] + } +} + function parseCapabilities(value: string): string[] { try { const parsed = JSON.parse(value) diff --git a/server/test/setup.ts b/server/test/setup.ts index 6e5feb89..7182357c 100644 --- a/server/test/setup.ts +++ b/server/test/setup.ts @@ -425,6 +425,8 @@ const APP_SCHEMA_SQL = ` source_uri TEXT NOT NULL, name TEXT, target_folder TEXT NOT NULL DEFAULT '', + category TEXT, + tags TEXT NOT NULL DEFAULT '[]', assigned_downloader_id TEXT, status TEXT NOT NULL, downloaded_bytes INTEGER NOT NULL DEFAULT 0, diff --git a/shared/schemas/downloads.ts b/shared/schemas/downloads.ts index 80e5451c..ba911c52 100644 --- a/shared/schemas/downloads.ts +++ b/shared/schemas/downloads.ts @@ -7,11 +7,13 @@ export const downloadTaskStatusSchema = z.enum([ 'assigned', 'running', 'billing_paused', + 'paused', 'uploading', 'completed', 'failed', 'canceled', ]) +export const downloadTaskActionSchema = z.enum(['pause', 'resume', 'cancel', 'retry', 'delete']) export const downloadSourceTypeSchema = z.enum(['http', 'magnet', 'torrent_url']) export const downloadTaskPhaseSchema = z.enum(['metadata', 'downloading', 'uploading', 'seeding', 'completed', 'error']) @@ -86,6 +88,8 @@ export const createDownloaderSchema = z.object({ }) const downloadUriSchema = z.string().min(1).max(4096) +const downloadTaskCategorySchema = z.string().trim().min(1).max(120) +const downloadTaskTagsSchema = z.array(z.string().trim().min(1).max(80)).max(20) export const createDownloadTaskSchema = z.object({ source: z.object({ @@ -94,6 +98,8 @@ export const createDownloadTaskSchema = z.object({ }), targetFolder: z.string(), name: z.string().min(1).max(255).optional(), + category: downloadTaskCategorySchema.optional(), + tags: downloadTaskTagsSchema.optional(), }) export const updateDownloadTaskSchema = z.object({ @@ -108,9 +114,15 @@ export const updateDownloadTaskSchema = z.object({ detail: downloadTaskDetailSchema.nullable().optional(), }) +export const downloadTaskActionInputSchema = z.object({ + action: downloadTaskActionSchema, +}) + export const listDownloadTasksQuerySchema = z.object({ status: downloadTaskStatusSchema.optional(), assignedTo: z.enum(['me']).optional(), + category: z.string().trim().min(1).max(120).optional(), + tag: z.string().trim().min(1).max(80).optional(), page: z.coerce.number().int().min(1).default(1), pageSize: z.coerce.number().int().min(1).max(100).default(20), }) @@ -152,6 +164,7 @@ export type UpdateDownloaderInput = z.infer export type CreateDownloaderInput = z.infer export type CreateDownloadTaskInput = z.infer export type UpdateDownloadTaskInput = z.infer +export type DownloadTaskActionInput = z.infer export type ListDownloadTasksQuery = z.infer export type DownloadTaskDetail = z.infer export type CreateObjectUploadSessionInput = z.infer diff --git a/shared/schemas/index.ts b/shared/schemas/index.ts index cb481c88..33a14979 100644 --- a/shared/schemas/index.ts +++ b/shared/schemas/index.ts @@ -76,6 +76,7 @@ export type { CreateDownloadTaskInput, CreateObjectUploadSessionInput, DownloaderHeartbeatInput, + DownloadTaskActionInput, DownloadTaskDetail, ListDownloadTasksQuery, PatchObjectUploadSessionInput, @@ -91,6 +92,8 @@ export { downloaderHeartbeatSchema, downloaderStatusSchema, downloadSourceTypeSchema, + downloadTaskActionInputSchema, + downloadTaskActionSchema, downloadTaskDetailSchema, downloadTaskStatusSchema, listDownloadTasksQuerySchema, diff --git a/shared/types/index.ts b/shared/types/index.ts index b94bf742..a7f8a01b 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -236,11 +236,14 @@ export type DownloadTaskStatus = | 'assigned' | 'running' | 'billing_paused' + | 'paused' | 'uploading' | 'completed' | 'failed' | 'canceled' +export type DownloadTaskAction = 'pause' | 'resume' | 'cancel' | 'retry' | 'delete' + export interface DownloadTask { id: string orgId: string @@ -249,6 +252,8 @@ export interface DownloadTask { sourceUri: string name: string | null targetFolder: string + category: string | null + tags: string[] assignedDownloaderId: string | null status: DownloadTaskStatus downloadedBytes: number diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 17968e7f..6beaba31 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -125,11 +125,19 @@ "downloads.targetFolderRoot": "Root", "downloads.name": "File name", "downloads.namePlaceholder": "Optional", + "downloads.category": "Category", + "downloads.categoryPlaceholder": "Movies, docs, backups", + "downloads.tags": "Tags", + "downloads.tagsPlaceholder": "Comma separated, e.g. 4K, private", + "downloads.filters": "Filter download tasks", + "downloads.filterCategory": "Filter by category", + "downloads.filterTag": "Filter by tag", "downloads.create": "Create", "downloads.createTitle": "New remote download", "downloads.start": "Start download", "downloads.createSuccess": "Download task created", "downloads.cancelSuccess": "Download task canceled", + "downloads.actionSuccess": "Task action submitted", "downloads.cancel": "Cancel", "downloads.resizePanel": "Resize download list and detail panel", "downloads.empty": "No remote download tasks", @@ -146,11 +154,17 @@ "downloads.status.assigned": "Assigned", "downloads.status.running": "Running", "downloads.status.billing_paused": "Billing paused", + "downloads.status.paused": "Paused", "downloads.status.uploading": "Uploading", "downloads.status.completed": "Completed", "downloads.status.seeding": "Seeding", "downloads.status.failed": "Failed", "downloads.status.canceled": "Canceled", + "downloads.actions.pause": "Pause", + "downloads.actions.resume": "Resume", + "downloads.actions.cancel": "Cancel", + "downloads.actions.retry": "Retry", + "downloads.actions.delete": "Delete", "downloads.phase.metadata": "Metadata", "downloads.phase.downloading": "Downloading", "downloads.phase.uploading": "Uploading", @@ -169,6 +183,8 @@ "downloads.detail.tabs.log": "Log", "downloads.detail.noSelection": "Select a download task to inspect it", "downloads.detail.target": "Target folder", + "downloads.detail.category": "Category", + "downloads.detail.tags": "Tags", "downloads.detail.engine": "Engine", "downloads.detail.phase": "Phase", "downloads.detail.engineState": "Engine state", @@ -778,6 +794,7 @@ "common.confirm": "Confirm", "common.copy": "Copy", "common.close": "Close", + "common.clear": "Clear", "common.loading": "Loading...", "common.error": "Error", "common.success": "Success", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index cb18dfd2..56ccaa2c 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -125,11 +125,19 @@ "downloads.targetFolderRoot": "根目录", "downloads.name": "文件名", "downloads.namePlaceholder": "可选", + "downloads.category": "分类", + "downloads.categoryPlaceholder": "例如:电影、资料、备份", + "downloads.tags": "标签", + "downloads.tagsPlaceholder": "用逗号分隔,例如:4K, 私有", + "downloads.filters": "筛选下载任务", + "downloads.filterCategory": "按分类筛选", + "downloads.filterTag": "按标签筛选", "downloads.create": "创建", "downloads.createTitle": "新建远程下载", "downloads.start": "开始下载", "downloads.createSuccess": "下载任务已创建", "downloads.cancelSuccess": "下载任务已取消", + "downloads.actionSuccess": "任务操作已提交", "downloads.cancel": "取消", "downloads.resizePanel": "调整下载列表和详情面板高度", "downloads.empty": "暂无远程下载任务", @@ -146,11 +154,17 @@ "downloads.status.assigned": "已分配", "downloads.status.running": "运行中", "downloads.status.billing_paused": "计费暂停", + "downloads.status.paused": "已暂停", "downloads.status.uploading": "上传中", "downloads.status.completed": "已完成", "downloads.status.seeding": "做种中", "downloads.status.failed": "失败", "downloads.status.canceled": "已取消", + "downloads.actions.pause": "暂停", + "downloads.actions.resume": "继续", + "downloads.actions.cancel": "取消", + "downloads.actions.retry": "重试", + "downloads.actions.delete": "删除", "downloads.phase.metadata": "获取元数据", "downloads.phase.downloading": "下载中", "downloads.phase.uploading": "上传中", @@ -169,6 +183,8 @@ "downloads.detail.tabs.log": "日志", "downloads.detail.noSelection": "选择一个下载任务查看详情", "downloads.detail.target": "目标目录", + "downloads.detail.category": "分类", + "downloads.detail.tags": "标签", "downloads.detail.engine": "下载引擎", "downloads.detail.phase": "阶段", "downloads.detail.engineState": "引擎状态", @@ -778,6 +794,7 @@ "common.confirm": "确认", "common.copy": "复制", "common.close": "关闭", + "common.clear": "清除", "common.loading": "加载中...", "common.error": "错误", "common.success": "成功", diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index 140ce3f5..3f7c5707 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -112,6 +112,7 @@ import { revokeRemoteDownloadApiKey, revokeSiteInvitation, revokeWebDavAppPassword, + runDownloadTaskAction, saveBranding, saveEmailConfig, saveShareToDrive, @@ -1000,13 +1001,22 @@ describe('api', () => { const payload = { items: [], total: 0, page: 2, pageSize: 10 } vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload)) - const result = await listDownloadTasks({ status: 'running', assignedTo: 'me', page: 2, pageSize: 10 }) + const result = await listDownloadTasks({ + status: 'running', + assignedTo: 'me', + category: 'movies', + tag: '4k', + page: 2, + pageSize: 10, + }) expect(result).toEqual(payload) const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] expect(url).toContain('/api/download-tasks?') expect(url).toContain('status=running') expect(url).toContain('assignedTo=me') + expect(url).toContain('category=movies') + expect(url).toContain('tag=4k') expect(url).toContain('page=2') expect(url).toContain('pageSize=10') expect(init.method).toBe('GET') @@ -1014,7 +1024,12 @@ describe('api', () => { it('creates a download task', async () => { const payload = { id: 'task-1', status: 'queued' } - const body = { source: { type: 'http' as const, uri: 'https://example.com/file.zip' }, targetFolder: 'root' } + const body = { + source: { type: 'http' as const, uri: 'https://example.com/file.zip' }, + targetFolder: 'root', + category: 'archives', + tags: ['backup', '2026'], + } vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload)) const result = await createDownloadTask(body) @@ -1040,8 +1055,30 @@ describe('api', () => { expect(init.body).toBe(JSON.stringify(body)) }) + it('runs a download task action', async () => { + const payload = { id: 'task-1', status: 'paused' } + vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload)) + + const result = await runDownloadTaskAction('task-1', 'pause') + + expect(result).toEqual(payload) + const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] + expect(url).toContain('/api/download-tasks/task-1/actions') + expect(init.method).toBe('POST') + expect(init.body).toBe(JSON.stringify({ action: 'pause' })) + }) + + it('throws ApiError on download task action failure', async () => { + vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Only active tasks can be paused' }, false, 409)) + + await expect(runDownloadTaskAction('task-1', 'pause')).rejects.toThrow('Only active tasks can be paused') + }) + it('builds the download task events URL from RPC client', () => { - expect(downloadTaskEventsUrl().pathname).toBe('/api/download-tasks/events') + const url = downloadTaskEventsUrl({ category: 'movies', tag: '4k' }) + expect(url.pathname).toBe('/api/download-tasks/events') + expect(url.searchParams.get('category')).toBe('movies') + expect(url.searchParams.get('tag')).toBe('4k') }) it('lists admin downloaders', async () => { diff --git a/src/lib/api.ts b/src/lib/api.ts index cfd6b506..7c98a415 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -15,6 +15,7 @@ import type { CreateShareRequest, CreateStorageInput, DownloaderHeartbeatInput, + DownloadTaskActionInput, GiftCardStatus, PatchObjectUploadSessionInput, PresignObjectUploadPartsInput, @@ -272,6 +273,8 @@ export function patchObjectUploadSession(id: string, uploadSessionId: string, da export interface ListDownloadTasksOptions { status?: string assignedTo?: 'me' + category?: string + tag?: string page?: number pageSize?: number } @@ -283,6 +286,8 @@ export function listDownloadTasks(opts: ListDownloadTasksOptions = {}) { } if (opts.status) query.status = opts.status if (opts.assignedTo) query.assignedTo = opts.assignedTo + if (opts.category) query.category = opts.category + if (opts.tag) query.tag = opts.tag return unwrap>(downloadTasksApi.index.$get({ query })) } @@ -294,8 +299,17 @@ export function updateDownloadTask(id: string, data: UpdateDownloadTaskInput) { return unwrap(downloadTasksApi[':id'].$patch({ param: { id }, json: data })) } -export function downloadTaskEventsUrl() { - return downloadTasksUrlApi.events.$url() +export type DownloadTaskActionResult = DownloadTask | { id: string; deleted: true } + +export function runDownloadTaskAction(id: string, action: DownloadTaskActionInput['action']) { + return unwrap(downloadTasksApi[':id'].actions.$post({ param: { id }, json: { action } })) +} + +export function downloadTaskEventsUrl(opts: Pick = {}) { + const query: Record = { page: '1', pageSize: '50' } + if (opts.category) query.category = opts.category + if (opts.tag) query.tag = opts.tag + return downloadTasksUrlApi.events.$url({ query }) } export function listDownloaders() { diff --git a/src/routes/_authenticated/downloads/index.tsx b/src/routes/_authenticated/downloads/index.tsx index ce25c04c..1d7e00c7 100644 --- a/src/routes/_authenticated/downloads/index.tsx +++ b/src/routes/_authenticated/downloads/index.tsx @@ -1,5 +1,5 @@ import { DirType } from '@shared/constants' -import type { DownloadTask, DownloadTaskStatus, StorageObject } from '@shared/types' +import type { DownloadTask, DownloadTaskAction, DownloadTaskStatus, StorageObject } from '@shared/types' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { createFileRoute } from '@tanstack/react-router' import { @@ -11,6 +11,7 @@ import { Clock, Download, FileDown, + Filter, Folder, FolderInput, Gauge, @@ -19,8 +20,12 @@ import { LoaderCircle, Magnet, PauseCircle, + PlayCircle, Plus, RadioTower, + RotateCcw, + Tag, + Trash2, Upload, Users, XCircle, @@ -31,6 +36,7 @@ import { type ReactNode, type PointerEvent as ReactPointerEvent, useEffect, + useMemo, useRef, useState, } from 'react' @@ -48,7 +54,7 @@ import { Progress } from '@/components/ui/progress' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { Textarea } from '@/components/ui/textarea' import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' -import { createDownloadTask, downloadTaskEventsUrl, listDownloadTasks, updateDownloadTask } from '@/lib/api' +import { createDownloadTask, downloadTaskEventsUrl, listDownloadTasks, runDownloadTaskAction } from '@/lib/api' import { cn } from '@/lib/utils' export const Route = createFileRoute('/_authenticated/downloads/')({ @@ -82,26 +88,38 @@ function DownloadsPage() { const [uri, setUri] = useState('') const [targetFolder, setTargetFolder] = useState('') const [name, setName] = useState('') + const [category, setCategory] = useState('') + const [tagsInput, setTagsInput] = useState('') + const [filterCategory, setFilterCategory] = useState('') + const [filterTag, setFilterTag] = useState('') const [createOpen, setCreateOpen] = useState(false) const [selectedTaskId, setSelectedTaskId] = useState(null) const [detailTab, setDetailTab] = useState('overview') const [detailHeight, setDetailHeight] = useState(DETAIL_DEFAULT_HEIGHT) const [panelDrag, setPanelDrag] = useState(null) const panelsRef = useRef(null) + const categoryFilterValue = filterCategory.trim() || undefined + const tagFilterValue = filterTag.trim() || undefined + const queryKey = useMemo( + () => [...QUERY_KEY, categoryFilterValue ?? '', tagFilterValue ?? ''], + [categoryFilterValue, tagFilterValue], + ) const tasksQuery = useQuery({ - queryKey: QUERY_KEY, - queryFn: () => listDownloadTasks({ page: 1, pageSize: 50 }), + queryKey, + queryFn: () => listDownloadTasks({ page: 1, pageSize: 50, category: categoryFilterValue, tag: tagFilterValue }), }) useEffect(() => { - const events = new EventSource(downloadTaskEventsUrl(), { withCredentials: true }) + const events = new EventSource(downloadTaskEventsUrl({ category: categoryFilterValue, tag: tagFilterValue }), { + withCredentials: true, + }) events.addEventListener('snapshot', (event) => { const data = JSON.parse((event as MessageEvent).data) - queryClient.setQueryData(QUERY_KEY, data) + queryClient.setQueryData(queryKey, data) }) return () => events.close() - }, [queryClient]) + }, [categoryFilterValue, queryClient, queryKey, tagFilterValue]) useEffect(() => { if (!panelDrag) return @@ -132,6 +150,8 @@ function DownloadsPage() { setUri('') setName('') setTargetFolder('') + setCategory('') + setTagsInput('') setCreateOpen(false) queryClient.invalidateQueries({ queryKey: QUERY_KEY }) toast.success(t('downloads.createSuccess')) @@ -139,11 +159,11 @@ function DownloadsPage() { onError: (err) => toast.error(err.message), }) - const pauseMutation = useMutation({ - mutationFn: (id: string) => updateDownloadTask(id, { status: 'canceled' }), + const actionMutation = useMutation({ + mutationFn: ({ id, action }: { id: string; action: DownloadTaskAction }) => runDownloadTaskAction(id, action), onSuccess: () => { queryClient.invalidateQueries({ queryKey: QUERY_KEY }) - toast.success(t('downloads.cancelSuccess')) + toast.success(t('downloads.actionSuccess')) }, onError: (err) => toast.error(err.message), }) @@ -154,6 +174,8 @@ function DownloadsPage() { source: { type: sourceType, uri: uri.trim() }, targetFolder: targetFolder.trim(), name: name.trim() || undefined, + category: category.trim() || undefined, + tags: parseTagsInput(tagsInput), }) } @@ -182,10 +204,18 @@ function DownloadsPage() { }]} actions={ - +
+ + +
} /> @@ -252,6 +282,30 @@ function DownloadsPage() { placeholder={t('downloads.namePlaceholder')} /> +
+ + setCategory(event.target.value)} + placeholder={t('downloads.categoryPlaceholder')} + /> +
+
+ + setTagsInput(event.target.value)} + placeholder={t('downloads.tagsPlaceholder')} + /> +
@@ -301,7 +355,8 @@ function DownloadsPage() { task={task} selected={task.id === activeSelectedTaskId} onSelect={() => setSelectedTaskId(task.id)} - onCancel={(id) => pauseMutation.mutate(id)} + actionPending={actionMutation.isPending && actionMutation.variables?.id === task.id} + onAction={(id, action) => actionMutation.mutate({ id, action })} /> ))} @@ -330,6 +385,72 @@ function DownloadsPage() { ) } +function DownloadFilters({ + category, + tag, + onCategoryChange, + onTagChange, +}: { + category: string + tag: string + onCategoryChange: (value: string) => void + onTagChange: (value: string) => void +}) { + const { t } = useTranslation() + const active = Boolean(category || tag) + + return ( + + + + + +
+ + onCategoryChange(event.target.value)} + placeholder={t('downloads.filterCategory')} + className="h-8" + /> +
+
+ + onTagChange(event.target.value)} + placeholder={t('downloads.filterTag')} + className="h-8" + /> +
+ {active && ( + + )} +
+
+ ) +} + function buildPath(parent: string, name: string): string { return parent ? `${parent}/${name}` : name } @@ -338,6 +459,17 @@ function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max) } +function parseTagsInput(value: string): string[] { + return [ + ...new Set( + value + .split(',') + .map((item) => item.trim()) + .filter(Boolean), + ), + ] +} + function FolderPicker({ value, onChange }: { value: string; onChange: (path: string) => void }) { const { t } = useTranslation() const [open, setOpen] = useState(false) @@ -450,16 +582,18 @@ function TaskRow({ task, selected, onSelect, - onCancel, + actionPending, + onAction, }: { task: DownloadTask selected: boolean onSelect: () => void - onCancel: (id: string) => void + actionPending: boolean + onAction: (id: string, action: DownloadTaskAction) => void }) { const { t } = useTranslation() const progress = transferProgress(task) - const active = ACTIVE_STATUSES.has(task.status) + const actions = taskActions(task) return ( {formatDuration(task.detail?.etaSeconds)} - - {active ? ( - + + {actions.length > 0 ? ( +
+ {actions.map((action) => ( + + ))} +
) : ( - - +
-
)}
) } +function taskActions(task: DownloadTask): DownloadTaskAction[] { + if (ACTIVE_STATUSES.has(task.status)) return ['pause', 'cancel'] + if (task.status === 'paused') return ['resume', 'cancel'] + if (task.status === 'failed' || task.status === 'canceled') return ['retry', 'delete'] + if (task.status === 'completed') return ['delete'] + return [] +} + +function TaskActionIcon({ action }: { action: DownloadTaskAction }) { + if (action === 'pause') return + if (action === 'resume') return + if (action === 'retry') return + if (action === 'delete') return + return +} + function TransferProgress({ task, className }: { task: DownloadTask; className?: string }) { const progress = transferProgress(task) return ( @@ -649,6 +809,8 @@ function OverviewPanel({ task }: { task: DownloadTask }) { label={t('downloads.detail.target')} value={task.targetFolder || t('downloads.targetFolderRoot')} /> + + , }, + paused: { + className: + 'border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-800 dark:bg-orange-950/40 dark:text-orange-300', + icon: , + }, uploading: { className: 'border-teal-200 bg-teal-50 text-teal-700 dark:border-teal-800 dark:bg-teal-950/40 dark:text-teal-300', icon: ,