From cba1228990022f4411f9891e77cb6c1dee54dc1f Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 10 May 2026 22:44:33 +0100 Subject: [PATCH 01/18] feat(database): add space viewer settings and password columns --- .../migrations/0023_misty_luckman.sql | 2 + .../migrations/meta/0023_snapshot.json | 3188 +++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + packages/database/schema.ts | 9 + 4 files changed, 3206 insertions(+) create mode 100644 packages/database/migrations/0023_misty_luckman.sql create mode 100644 packages/database/migrations/meta/0023_snapshot.json diff --git a/packages/database/migrations/0023_misty_luckman.sql b/packages/database/migrations/0023_misty_luckman.sql new file mode 100644 index 00000000000..8cb9a01292b --- /dev/null +++ b/packages/database/migrations/0023_misty_luckman.sql @@ -0,0 +1,2 @@ +ALTER TABLE `spaces` ADD `settings` json;--> statement-breakpoint +ALTER TABLE `spaces` ADD `password` text; \ No newline at end of file diff --git a/packages/database/migrations/meta/0023_snapshot.json b/packages/database/migrations/meta/0023_snapshot.json new file mode 100644 index 00000000000..d8eaca34246 --- /dev/null +++ b/packages/database/migrations/meta/0023_snapshot.json @@ -0,0 +1,3188 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "a9518362-ccdf-4f5d-bfdc-68e8f45bcba3", + "prevId": "7166a4df-5a5f-46ba-96ec-c12295186007", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_in": { + "name": "expires_in", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_in": { + "name": "refresh_token_expires_in", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "tempColumn": { + "name": "tempColumn", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": ["userId"], + "isUnique": false + }, + "provider_account_id_idx": { + "name": "provider_account_id_idx", + "columns": ["providerAccountId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "accounts_id": { + "name": "accounts_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "auth_api_keys": { + "name": "auth_api_keys", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "auth_api_keys_id": { + "name": "auth_api_keys_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "comments": { + "name": "comments", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "authorId": { + "name": "authorId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "parentCommentId": { + "name": "parentCommentId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "video_type_created_idx": { + "name": "video_type_created_idx", + "columns": ["videoId", "type", "createdAt", "id"], + "isUnique": false + }, + "author_id_idx": { + "name": "author_id_idx", + "columns": ["authorId"], + "isUnique": false + }, + "parent_comment_id_idx": { + "name": "parent_comment_id_idx", + "columns": ["parentCommentId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "comments_id": { + "name": "comments_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_api_keys": { + "name": "developer_api_keys", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyType": { + "name": "keyType", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyPrefix": { + "name": "keyPrefix", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyHash": { + "name": "keyHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encryptedKey": { + "name": "encryptedKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "key_hash_idx": { + "name": "key_hash_idx", + "columns": ["keyHash"], + "isUnique": true + }, + "app_key_type_idx": { + "name": "app_key_type_idx", + "columns": ["appId", "keyType"], + "isUnique": false + } + }, + "foreignKeys": { + "developer_api_keys_appId_developer_apps_id_fk": { + "name": "developer_api_keys_appId_developer_apps_id_fk", + "tableFrom": "developer_api_keys", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_api_keys_id": { + "name": "developer_api_keys_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_app_domains": { + "name": "developer_app_domains", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "varchar(253)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "developer_app_domains_appId_developer_apps_id_fk": { + "name": "developer_app_domains_appId_developer_apps_id_fk", + "tableFrom": "developer_app_domains", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_app_domains_id": { + "name": "developer_app_domains_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "app_domain_unique": { + "name": "app_domain_unique", + "columns": ["appId", "domain"] + } + }, + "checkConstraint": {} + }, + "developer_apps": { + "name": "developer_apps", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment": { + "name": "environment", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logoUrl": { + "name": "logoUrl", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "owner_deleted_idx": { + "name": "owner_deleted_idx", + "columns": ["ownerId", "deletedAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "developer_apps_id": { + "name": "developer_apps_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_credit_accounts": { + "name": "developer_credit_accounts", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "balanceMicroCredits": { + "name": "balanceMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripePaymentMethodId": { + "name": "stripePaymentMethodId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autoTopUpEnabled": { + "name": "autoTopUpEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "autoTopUpThresholdMicroCredits": { + "name": "autoTopUpThresholdMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "autoTopUpAmountCents": { + "name": "autoTopUpAmountCents", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "app_id_unique": { + "name": "app_id_unique", + "columns": ["appId"], + "isUnique": true + } + }, + "foreignKeys": { + "developer_credit_accounts_appId_developer_apps_id_fk": { + "name": "developer_credit_accounts_appId_developer_apps_id_fk", + "tableFrom": "developer_credit_accounts", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_credit_accounts_id": { + "name": "developer_credit_accounts_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_credit_transactions": { + "name": "developer_credit_transactions", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accountId": { + "name": "accountId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amountMicroCredits": { + "name": "amountMicroCredits", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "balanceAfterMicroCredits": { + "name": "balanceAfterMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "referenceId": { + "name": "referenceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referenceType": { + "name": "referenceType", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "account_type_created_idx": { + "name": "account_type_created_idx", + "columns": ["accountId", "type", "createdAt"], + "isUnique": false + }, + "account_ref_dedup_idx": { + "name": "account_ref_dedup_idx", + "columns": ["accountId", "referenceId", "referenceType"], + "isUnique": false + } + }, + "foreignKeys": { + "dev_credit_txn_account_fk": { + "name": "dev_credit_txn_account_fk", + "tableFrom": "developer_credit_transactions", + "tableTo": "developer_credit_accounts", + "columnsFrom": ["accountId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_credit_transactions_id": { + "name": "developer_credit_transactions_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_daily_storage_snapshots": { + "name": "developer_daily_storage_snapshots", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshotDate": { + "name": "snapshotDate", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "totalDurationMinutes": { + "name": "totalDurationMinutes", + "type": "float", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "videoCount": { + "name": "videoCount", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "microCreditsCharged": { + "name": "microCreditsCharged", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "processedAt": { + "name": "processedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "developer_daily_storage_snapshots_appId_developer_apps_id_fk": { + "name": "developer_daily_storage_snapshots_appId_developer_apps_id_fk", + "tableFrom": "developer_daily_storage_snapshots", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_daily_storage_snapshots_id": { + "name": "developer_daily_storage_snapshots_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "app_date_unique": { + "name": "app_date_unique", + "columns": ["appId", "snapshotDate"] + } + }, + "checkConstraint": {} + }, + "developer_videos": { + "name": "developer_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "externalUserId": { + "name": "externalUserId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Untitled'" + }, + "duration": { + "name": "duration", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fps": { + "name": "fps", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "s3Key": { + "name": "s3Key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "transcriptionStatus": { + "name": "transcriptionStatus", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "app_created_idx": { + "name": "app_created_idx", + "columns": ["appId", "createdAt"], + "isUnique": false + }, + "app_user_idx": { + "name": "app_user_idx", + "columns": ["appId", "externalUserId"], + "isUnique": false + }, + "app_deleted_idx": { + "name": "app_deleted_idx", + "columns": ["appId", "deletedAt"], + "isUnique": false + } + }, + "foreignKeys": { + "developer_videos_appId_developer_apps_id_fk": { + "name": "developer_videos_appId_developer_apps_id_fk", + "tableFrom": "developer_videos", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_videos_id": { + "name": "developer_videos_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "folders": { + "name": "folders", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'normal'" + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdById": { + "name": "createdById", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parentId": { + "name": "parentId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "created_by_id_idx": { + "name": "created_by_id_idx", + "columns": ["createdById"], + "isUnique": false + }, + "parent_id_idx": { + "name": "parent_id_idx", + "columns": ["parentId"], + "isUnique": false + }, + "space_id_idx": { + "name": "space_id_idx", + "columns": ["spaceId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "folders_id": { + "name": "folders_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "imported_videos": { + "name": "imported_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "orgId": { + "name": "orgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "imported_videos_orgId_source_source_id_pk": { + "name": "imported_videos_orgId_source_source_id_pk", + "columns": ["orgId", "source", "source_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "messenger_conversations": { + "name": "messenger_conversations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent": { + "name": "agent", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "anonymousId": { + "name": "anonymousId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "takeoverByUserId": { + "name": "takeoverByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "takeoverAt": { + "name": "takeoverAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "lastMessageAt": { + "name": "lastMessageAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "user_last_message_idx": { + "name": "user_last_message_idx", + "columns": ["userId", "lastMessageAt"], + "isUnique": false + }, + "anonymous_last_message_idx": { + "name": "anonymous_last_message_idx", + "columns": ["anonymousId", "lastMessageAt"], + "isUnique": false + }, + "mode_last_message_idx": { + "name": "mode_last_message_idx", + "columns": ["mode", "lastMessageAt"], + "isUnique": false + }, + "updated_at_idx": { + "name": "updated_at_idx", + "columns": ["updatedAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "messenger_conversations_id": { + "name": "messenger_conversations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "messenger_messages": { + "name": "messenger_messages", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversationId": { + "name": "conversationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "anonymousId": { + "name": "anonymousId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "conversation_created_at_idx": { + "name": "conversation_created_at_idx", + "columns": ["conversationId", "createdAt"], + "isUnique": false + }, + "role_created_at_idx": { + "name": "role_created_at_idx", + "columns": ["role", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": { + "messenger_messages_conversationId_messenger_conversations_id_fk": { + "name": "messenger_messages_conversationId_messenger_conversations_id_fk", + "tableFrom": "messenger_messages", + "tableTo": "messenger_conversations", + "columnsFrom": ["conversationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "messenger_messages_id": { + "name": "messenger_messages_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "notifications": { + "name": "notifications", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "orgId": { + "name": "orgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipientId": { + "name": "recipientId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dedupKey": { + "name": "dedupKey", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "readAt": { + "name": "readAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "org_id_idx": { + "name": "org_id_idx", + "columns": ["orgId"], + "isUnique": false + }, + "type_idx": { + "name": "type_idx", + "columns": ["type"], + "isUnique": false + }, + "read_at_idx": { + "name": "read_at_idx", + "columns": ["readAt"], + "isUnique": false + }, + "created_at_idx": { + "name": "created_at_idx", + "columns": ["createdAt"], + "isUnique": false + }, + "recipient_read_idx": { + "name": "recipient_read_idx", + "columns": ["recipientId", "readAt"], + "isUnique": false + }, + "recipient_created_idx": { + "name": "recipient_created_idx", + "columns": ["recipientId", "createdAt"], + "isUnique": false + }, + "dedup_key_idx": { + "name": "dedup_key_idx", + "columns": ["dedupKey"], + "isUnique": true + }, + "type_recipient_created_idx": { + "name": "type_recipient_created_idx", + "columns": ["type", "recipientId", "createdAt"], + "isUnique": false + }, + "type_recipient_video_created_idx": { + "name": "type_recipient_video_created_idx", + "columns": ["type", "recipientId", "videoId", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "notifications_id": { + "name": "notifications_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organization_invites": { + "name": "organization_invites", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invitedEmail": { + "name": "invitedEmail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invitedByUserId": { + "name": "invitedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "invited_email_idx": { + "name": "invited_email_idx", + "columns": ["invitedEmail"], + "isUnique": false + }, + "invited_by_user_id_idx": { + "name": "invited_by_user_id_idx", + "columns": ["invitedByUserId"], + "isUnique": false + }, + "status_idx": { + "name": "status_idx", + "columns": ["status"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organization_invites_id": { + "name": "organization_invites_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organization_members": { + "name": "organization_members", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hasProSeat": { + "name": "hasProSeat", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "user_id_organization_id_idx": { + "name": "user_id_organization_id_idx", + "columns": ["userId", "organizationId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organization_members_id": { + "name": "organization_members_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tombstoneAt": { + "name": "tombstoneAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowedEmailDomain": { + "name": "allowedEmailDomain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customDomain": { + "name": "customDomain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "domainVerified": { + "name": "domainVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iconUrl": { + "name": "iconUrl", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "workosOrganizationId": { + "name": "workosOrganizationId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workosConnectionId": { + "name": "workosConnectionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "owner_id_tombstone_idx": { + "name": "owner_id_tombstone_idx", + "columns": ["ownerId", "tombstoneAt"], + "isUnique": false + }, + "custom_domain_idx": { + "name": "custom_domain_idx", + "columns": ["customDomain"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organizations_id": { + "name": "organizations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "s3_buckets": { + "name": "s3_buckets", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bucketName": { + "name": "bucketName", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessKeyId": { + "name": "accessKeyId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secretAccessKey": { + "name": "secretAccessKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('aws')" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "owner_organization_idx": { + "name": "owner_organization_idx", + "columns": ["ownerId", "organizationId"], + "isUnique": false + }, + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "organization_active_idx": { + "name": "organization_active_idx", + "columns": ["organizationId", "active", "updatedAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "s3_buckets_id": { + "name": "s3_buckets_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sessionToken": { + "name": "sessionToken", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "session_token_idx": { + "name": "session_token_idx", + "columns": ["sessionToken"], + "isUnique": true + }, + "user_id_idx": { + "name": "user_id_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "shared_videos": { + "name": "shared_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folderId": { + "name": "folderId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sharedByUserId": { + "name": "sharedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sharedAt": { + "name": "sharedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "folder_id_idx": { + "name": "folder_id_idx", + "columns": ["folderId"], + "isUnique": false + }, + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "shared_by_user_id_idx": { + "name": "shared_by_user_id_idx", + "columns": ["sharedByUserId"], + "isUnique": false + }, + "video_id_organization_id_idx": { + "name": "video_id_organization_id_idx", + "columns": ["videoId", "organizationId"], + "isUnique": false + }, + "video_id_folder_id_idx": { + "name": "video_id_folder_id_idx", + "columns": ["videoId", "folderId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "shared_videos_id": { + "name": "shared_videos_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "space_members": { + "name": "space_members", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "space_members_id": { + "name": "space_members_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "space_id_user_id_unique": { + "name": "space_id_user_id_unique", + "columns": ["spaceId", "userId"] + } + }, + "checkConstraint": {} + }, + "space_videos": { + "name": "space_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folderId": { + "name": "folderId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedById": { + "name": "addedById", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedAt": { + "name": "addedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "folder_id_idx": { + "name": "folder_id_idx", + "columns": ["folderId"], + "isUnique": false + }, + "video_id_idx": { + "name": "video_id_idx", + "columns": ["videoId"], + "isUnique": false + }, + "added_by_id_idx": { + "name": "added_by_id_idx", + "columns": ["addedById"], + "isUnique": false + }, + "space_id_video_id_idx": { + "name": "space_id_video_id_idx", + "columns": ["spaceId", "videoId"], + "isUnique": false + }, + "space_id_folder_id_idx": { + "name": "space_id_folder_id_idx", + "columns": ["spaceId", "folderId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "space_videos_id": { + "name": "space_videos_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "spaces": { + "name": "spaces", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "primary": { + "name": "primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdById": { + "name": "createdById", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "iconUrl": { + "name": "iconUrl", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "privacy": { + "name": "privacy", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Private'" + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "created_by_id_idx": { + "name": "created_by_id_idx", + "columns": ["createdById"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "spaces_id": { + "name": "spaces_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "storage_integrations": { + "name": "storage_integrations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "displayName": { + "name": "displayName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "encryptedConfig": { + "name": "encryptedConfig", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "googleDriveAccessToken": { + "name": "googleDriveAccessToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveAccessTokenExpiresAt": { + "name": "googleDriveAccessTokenExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveTokenRefreshLeaseId": { + "name": "googleDriveTokenRefreshLeaseId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveTokenRefreshLeaseExpiresAt": { + "name": "googleDriveTokenRefreshLeaseExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveStorageQuotaCache": { + "name": "googleDriveStorageQuotaCache", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "owner_provider_idx": { + "name": "owner_provider_idx", + "columns": ["ownerId", "provider"], + "isUnique": false + }, + "owner_active_idx": { + "name": "owner_active_idx", + "columns": ["ownerId", "active"], + "isUnique": false + }, + "organization_provider_idx": { + "name": "organization_provider_idx", + "columns": ["organizationId", "provider"], + "isUnique": false + }, + "organization_active_idx": { + "name": "organization_active_idx", + "columns": ["organizationId", "active", "status"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "storage_integrations_id": { + "name": "storage_integrations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "storage_objects": { + "name": "storage_objects", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integrationId": { + "name": "integrationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "objectKey": { + "name": "objectKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "objectKeyHash": { + "name": "objectKeyHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerObjectId": { + "name": "providerObjectId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploadSessionUrl": { + "name": "uploadSessionUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uploadStatus": { + "name": "uploadStatus", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "contentType": { + "name": "contentType", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contentLength": { + "name": "contentLength", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "integration_key_hash_idx": { + "name": "integration_key_hash_idx", + "columns": ["integrationId", "objectKeyHash"], + "isUnique": true + }, + "integration_status_idx": { + "name": "integration_status_idx", + "columns": ["integrationId", "uploadStatus"], + "isUnique": false + }, + "video_id_idx": { + "name": "video_id_idx", + "columns": ["videoId"], + "isUnique": false + }, + "owner_id_idx": { + "name": "owner_id_idx", + "columns": ["ownerId"], + "isUnique": false + } + }, + "foreignKeys": { + "storage_objects_integrationId_storage_integrations_id_fk": { + "name": "storage_objects_integrationId_storage_integrations_id_fk", + "tableFrom": "storage_objects", + "tableTo": "storage_integrations", + "columnsFrom": ["integrationId"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "storage_objects_id": { + "name": "storage_objects_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastName": { + "name": "lastName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thirdPartyStripeSubscriptionId": { + "name": "thirdPartyStripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionStatus": { + "name": "stripeSubscriptionStatus", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionPriceId": { + "name": "stripeSubscriptionPriceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "preferences": { + "name": "preferences", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('null')" + }, + "activeOrganizationId": { + "name": "activeOrganizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "onboardingSteps": { + "name": "onboardingSteps", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customBucket": { + "name": "customBucket", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inviteQuota": { + "name": "inviteQuota", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "defaultOrgId": { + "name": "defaultOrgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "email_idx": { + "name": "email_idx", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "verification_tokens": { + "name": "verification_tokens", + "columns": { + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_tokens_identifier": { + "name": "verification_tokens_identifier", + "columns": ["identifier"] + } + }, + "uniqueConstraints": { + "verification_tokens_token_unique": { + "name": "verification_tokens_token_unique", + "columns": ["token"] + } + }, + "checkConstraint": {} + }, + "video_uploads": { + "name": "video_uploads", + "columns": { + "video_id": { + "name": "video_id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded": { + "name": "uploaded", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total": { + "name": "total", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "mode": { + "name": "mode", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase": { + "name": "phase", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'uploading'" + }, + "processing_progress": { + "name": "processing_progress", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "processing_message": { + "name": "processing_message", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_file_key": { + "name": "raw_file_key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "video_uploads_video_id": { + "name": "video_uploads_video_id", + "columns": ["video_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "videos": { + "name": "videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "orgId": { + "name": "orgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'My Video'" + }, + "bucket": { + "name": "bucket", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storageIntegrationId": { + "name": "storageIntegrationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fps": { + "name": "fps", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "transcriptionStatus": { + "name": "transcriptionStatus", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{\"type\":\"MediaConvert\"}')" + }, + "folderId": { + "name": "folderId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "effectiveCreatedAt": { + "name": "effectiveCreatedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "generated": { + "as": "COALESCE(\n STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(`metadata`, '$.customCreatedAt')), '%Y-%m-%dT%H:%i:%s.%fZ'),\n STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(`metadata`, '$.customCreatedAt')), '%Y-%m-%dT%H:%i:%sZ'),\n `createdAt`\n )", + "type": "stored" + } + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "xStreamInfo": { + "name": "xStreamInfo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "firstViewEmailSentAt": { + "name": "firstViewEmailSentAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "isScreenshot": { + "name": "isScreenshot", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "awsRegion": { + "name": "awsRegion", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "awsBucket": { + "name": "awsBucket", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "videoStartTime": { + "name": "videoStartTime", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "audioStartTime": { + "name": "audioStartTime", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jobId": { + "name": "jobId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jobStatus": { + "name": "jobStatus", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "skipProcessing": { + "name": "skipProcessing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "owner_id_idx": { + "name": "owner_id_idx", + "columns": ["ownerId"], + "isUnique": false + }, + "is_public_idx": { + "name": "is_public_idx", + "columns": ["public"], + "isUnique": false + }, + "folder_id_idx": { + "name": "folder_id_idx", + "columns": ["folderId"], + "isUnique": false + }, + "storage_integration_id_idx": { + "name": "storage_integration_id_idx", + "columns": ["storageIntegrationId"], + "isUnique": false + }, + "org_owner_folder_idx": { + "name": "org_owner_folder_idx", + "columns": ["orgId", "ownerId", "folderId"], + "isUnique": false + }, + "org_effective_created_idx": { + "name": "org_effective_created_idx", + "columns": ["orgId", "effectiveCreatedAt"], + "isUnique": false + } + }, + "foreignKeys": { + "videos_storageIntegrationId_storage_integrations_id_fk": { + "name": "videos_storageIntegrationId_storage_integrations_id_fk", + "tableFrom": "videos", + "tableTo": "storage_integrations", + "columnsFrom": ["storageIntegrationId"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "videos_id": { + "name": "videos_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index 7f7a1c47ec2..cab5ef213d3 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -162,6 +162,13 @@ "when": 1778252207674, "tag": "0022_dazzling_namor", "breakpoints": true + }, + { + "idx": 23, + "version": "5", + "when": 1778434170430, + "tag": "0023_misty_luckman", + "breakpoints": true } ] } diff --git a/packages/database/schema.ts b/packages/database/schema.ts index 9cd8e581fb7..94563464864 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -910,6 +910,15 @@ export const spaces = mysqlTable( length: 255, }).$type(), description: varchar("description", { length: 1000 }), + settings: json("settings").$type<{ + disableSummary?: boolean; + disableCaptions?: boolean; + disableChapters?: boolean; + disableReactions?: boolean; + disableTranscript?: boolean; + disableComments?: boolean; + }>(), + password: encryptedTextNullable("password"), createdAt: timestamp("createdAt").notNull().defaultNow(), updatedAt: timestamp("updatedAt").notNull().defaultNow().onUpdateNow(), privacy: varchar("privacy", { length: 255, enum: ["Public", "Private"] }) From a740e6bad28fc96fe9500278d3a1678c264e7e3d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 10 May 2026 22:44:37 +0100 Subject: [PATCH 02/18] feat(web-domain): verify share access against multiple password hashes --- packages/web-domain/src/Video.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/web-domain/src/Video.ts b/packages/web-domain/src/Video.ts index 46ae2385088..711df357328 100644 --- a/packages/web-domain/src/Video.ts +++ b/packages/web-domain/src/Video.ts @@ -217,12 +217,24 @@ export class VerifyVideoPasswordError extends Schema.TaggedError) => + verifyPasswordCandidates( + video, + Option.match(password, { + onNone: () => [], + onSome: (value) => [value], + }), + ); + +export const verifyPasswordCandidates = ( + video: Video, + passwords: ReadonlyArray, +) => Effect.gen(function* () { const passwordAttachment = yield* Effect.serviceOption( VideoPasswordAttachment, ); - if (Option.isNone(password)) return; + if (passwords.length === 0) return; if ( Option.isNone(passwordAttachment) || @@ -233,7 +245,7 @@ export const verifyPassword = (video: Video, password: Option.Option) => cause: "not-provided", }); - if (passwordAttachment.value.password.value !== password.value) + if (!passwords.includes(passwordAttachment.value.password.value)) return yield* new VerifyVideoPasswordError({ id: video.id, cause: "wrong-password", From 4476bc53ee4748b1f817dabbfe9057d41eccabf5 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 10 May 2026 22:44:37 +0100 Subject: [PATCH 03/18] feat(web-backend): add effective video rules resolution helpers --- .../src/Videos/EffectiveVideoRules.ts | 102 ++++++++++++++++++ packages/web-backend/src/index.ts | 9 ++ 2 files changed, 111 insertions(+) create mode 100644 packages/web-backend/src/Videos/EffectiveVideoRules.ts diff --git a/packages/web-backend/src/Videos/EffectiveVideoRules.ts b/packages/web-backend/src/Videos/EffectiveVideoRules.ts new file mode 100644 index 00000000000..e276d3c6ae7 --- /dev/null +++ b/packages/web-backend/src/Videos/EffectiveVideoRules.ts @@ -0,0 +1,102 @@ +export type ViewerSettingKey = + | "disableSummary" + | "disableCaptions" + | "disableChapters" + | "disableReactions" + | "disableTranscript" + | "disableComments"; + +export type ViewerSettings = Partial>; + +export type SpaceRuleInput = { + id: string; + name: string; + settings?: ViewerSettings | null; + hasPassword?: boolean; + password?: string | null; +}; + +export type SpaceRuleSource = { + id: string; + name: string; +}; + +export type EffectiveVideoRules = { + settings: Required; + inheritedSettings: Partial>; + inheritedPasswordSources: SpaceRuleSource[]; + hasInheritedPassword: boolean; +}; + +const settingKeys: ViewerSettingKey[] = [ + "disableSummary", + "disableCaptions", + "disableChapters", + "disableReactions", + "disableTranscript", + "disableComments", +]; + +const emptySettings: Required = { + disableSummary: false, + disableCaptions: false, + disableChapters: false, + disableReactions: false, + disableTranscript: false, + disableComments: false, +}; + +export function resolveEffectiveVideoRules({ + videoSettings, + organizationSettings, + spaces, +}: { + videoSettings?: ViewerSettings | null; + organizationSettings?: ViewerSettings | null; + spaces: SpaceRuleInput[]; +}): EffectiveVideoRules { + const inheritedSettings: Partial< + Record + > = {}; + const settings = { ...emptySettings }; + + for (const key of settingKeys) { + const sources = spaces + .filter((space) => space.settings?.[key] === true) + .map((space) => ({ id: space.id, name: space.name })); + + if (sources.length > 0) { + settings[key] = true; + inheritedSettings[key] = sources; + } else { + settings[key] = + videoSettings?.[key] ?? organizationSettings?.[key] ?? false; + } + } + + const inheritedPasswordSources = spaces + .filter((space) => space.hasPassword || Boolean(space.password)) + .map((space) => ({ id: space.id, name: space.name })); + + return { + settings, + inheritedSettings, + inheritedPasswordSources, + hasInheritedPassword: inheritedPasswordSources.length > 0, + }; +} + +export function collectPasswordHashes({ + videoPassword, + spacePasswords, +}: { + videoPassword?: string | null; + spacePasswords: Array<{ password?: string | null }>; +}) { + return [ + ...(videoPassword ? [videoPassword] : []), + ...spacePasswords + .map((space) => space.password) + .filter((password): password is string => Boolean(password)), + ]; +} diff --git a/packages/web-backend/src/index.ts b/packages/web-backend/src/index.ts index 81bb1e6ba96..24f5bab9110 100644 --- a/packages/web-backend/src/index.ts +++ b/packages/web-backend/src/index.ts @@ -25,6 +25,15 @@ export { } from "./Storage/StorageRepo.ts"; export { Tinybird } from "./Tinybird/index.ts"; export { Users } from "./Users/index.ts"; +export { + collectPasswordHashes, + type EffectiveVideoRules, + resolveEffectiveVideoRules, + type SpaceRuleInput, + type SpaceRuleSource, + type ViewerSettingKey, + type ViewerSettings, +} from "./Videos/EffectiveVideoRules.ts"; export { Videos } from "./Videos/index.ts"; export { buildCanView, From 9d913ce2c2f3621214900223a5406ce963112769 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 10 May 2026 22:44:37 +0100 Subject: [PATCH 04/18] feat(web-backend): expose space passwords and settings in queries --- packages/web-backend/src/Spaces/SpacesRepo.ts | 13 +++++++++++++ packages/web-backend/src/Spaces/index.ts | 5 +++++ 2 files changed, 18 insertions(+) diff --git a/packages/web-backend/src/Spaces/SpacesRepo.ts b/packages/web-backend/src/Spaces/SpacesRepo.ts index bf2a8d000ad..46035b912b0 100644 --- a/packages/web-backend/src/Spaces/SpacesRepo.ts +++ b/packages/web-backend/src/Spaces/SpacesRepo.ts @@ -29,6 +29,19 @@ export class SpacesRepo extends Effect.Service()("SpacesRepo", { ) .pipe(Effect.map(Array.get(0))), + passwordsForVideo: (videoId: Video.VideoId) => + db.use((db) => + db + .select({ + id: Db.spaces.id, + name: Db.spaces.name, + password: Db.spaces.password, + }) + .from(Db.spaceVideos) + .innerJoin(Db.spaces, Dz.eq(Db.spaceVideos.spaceId, Db.spaces.id)) + .where(Dz.eq(Db.spaceVideos.videoId, videoId)), + ), + membership: ( userId: User.UserId, spaceId: Space.SpaceIdOrOrganisationId, diff --git a/packages/web-backend/src/Spaces/index.ts b/packages/web-backend/src/Spaces/index.ts index c0aecf7ba50..606094dc16d 100644 --- a/packages/web-backend/src/Spaces/index.ts +++ b/packages/web-backend/src/Spaces/index.ts @@ -26,6 +26,11 @@ export class Spaces extends Effect.Service()("Spaces", { name: Db.spaces.name, organizationId: Db.spaces.organizationId, createdById: Db.spaces.createdById, + iconUrl: Db.spaces.iconUrl, + settings: Db.spaces.settings, + hasPassword: Dz.sql`${Db.spaces.password} IS NOT NULL`.mapWith( + Boolean, + ), }) .from(Db.spaces) .where(Dz.eq(Db.spaces.id, spaceOrOrgId)) From c4d168ab689da4693622766ff3bf585a0f2415bd Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 10 May 2026 22:44:37 +0100 Subject: [PATCH 05/18] feat(web-backend): enforce inherited space passwords in view policy --- .../web-backend/src/Videos/VideosPolicy.ts | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/packages/web-backend/src/Videos/VideosPolicy.ts b/packages/web-backend/src/Videos/VideosPolicy.ts index 5cc92d209e8..858483b61db 100644 --- a/packages/web-backend/src/Videos/VideosPolicy.ts +++ b/packages/web-backend/src/Videos/VideosPolicy.ts @@ -1,10 +1,17 @@ import { isEmailAllowedByRestriction } from "@cap/utils"; -import { Policy, Video } from "@cap/web-domain"; +import { + type DatabaseError, + type Organisation, + Policy, + type User, + Video, +} from "@cap/web-domain"; import { Array, Effect, Option } from "effect"; import { Database } from "../Database.ts"; import { OrganisationsRepo } from "../Organisations/OrganisationsRepo.ts"; import { SpacesRepo } from "../Spaces/SpacesRepo.ts"; +import { collectPasswordHashes } from "./EffectiveVideoRules.ts"; import { VideosRepo } from "./VideosRepo.ts"; export type VideosPolicyDeps = { @@ -13,23 +20,26 @@ export type VideosPolicyDeps = { id: Video.VideoId, ) => Effect.Effect< Option.Option]>, - any + DatabaseError >; }; orgsRepo: { membershipForVideo: ( - userId: any, + userId: User.UserId, videoId: Video.VideoId, - ) => Effect.Effect; + ) => Effect.Effect; allowedEmailDomain: ( - orgId: any, - ) => Effect.Effect, any>; + orgId: Organisation.OrganisationId, + ) => Effect.Effect, DatabaseError>; }; spacesRepo: { membershipForVideo: ( - userId: any, + userId: User.UserId, videoId: Video.VideoId, - ) => Effect.Effect, any>; + ) => Effect.Effect, DatabaseError>; + passwordsForVideo: ( + videoId: Video.VideoId, + ) => Effect.Effect; }; }; @@ -51,7 +61,16 @@ export function buildCanView( if (Option.isSome(user)) { const userId = user.value.id; if (userId === video.ownerId) return true; + } + + const spacePasswords = yield* spacesRepo.passwordsForVideo(video.id); + const passwordHashes = collectPasswordHashes({ + videoPassword: Option.getOrNull(password), + spacePasswords: [...spacePasswords], + }); + if (Option.isSome(user)) { + const userId = user.value.id; const [videoOrgShareMembership, videoSpaceShareMembership] = yield* Effect.all([ orgsRepo @@ -67,7 +86,7 @@ export function buildCanView( yield* Effect.log( "Explicit org/space membership found. Access granted.", ); - yield* Video.verifyPassword(video, password); + yield* Video.verifyPasswordCandidates(video, passwordHashes); return true; } } @@ -108,7 +127,7 @@ export function buildCanView( } } - yield* Video.verifyPassword(video, password); + yield* Video.verifyPasswordCandidates(video, passwordHashes); return true; }), From ffd917cec0b231cab3a219bf26e468b8a5c451c7 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 10 May 2026 22:44:40 +0100 Subject: [PATCH 06/18] feat(web): extend dashboard spaces with settings and password flags --- apps/web/app/(org)/dashboard/dashboard-data.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/web/app/(org)/dashboard/dashboard-data.ts b/apps/web/app/(org)/dashboard/dashboard-data.ts index 464393803eb..e375ca2ecdc 100644 --- a/apps/web/app/(org)/dashboard/dashboard-data.ts +++ b/apps/web/app/(org)/dashboard/dashboard-data.ts @@ -37,11 +37,12 @@ export type OrganizationSettings = NonNullable< export type Spaces = Omit< typeof spaces.$inferSelect, - "createdAt" | "updatedAt" | "iconUrl" + "createdAt" | "updatedAt" | "iconUrl" | "password" > & { memberCount: number; videoCount: number; iconUrl: ImageUpload.ImageUrl | null; + hasPassword: boolean; }; export type UserPreferences = (typeof users.$inferSelect)["preferences"]; @@ -130,6 +131,10 @@ export async function getDashboardData(user: typeof userSelectProps) { organizationId: spaces.organizationId, createdById: spaces.createdById, iconUrl: spaces.iconUrl, + settings: spaces.settings, + hasPassword: sql`${spaces.password} IS NOT NULL`.mapWith( + Boolean, + ), memberCount: sql`( SELECT COUNT(*) FROM space_members WHERE space_members.spaceId = spaces.id )`, @@ -224,6 +229,8 @@ export async function getDashboardData(user: typeof userSelectProps) { memberCount: orgMemberCount, createdById: activeOrgInfo.ownerId, videoCount: orgVideoCount, + settings: null, + hasPassword: false, } as const; }).pipe(runPromise); @@ -320,12 +327,15 @@ export async function getDashboardData(user: typeof userSelectProps) { allMembers.map( Effect.fn(function* (m) { const imageUploads = yield* ImageUploads; + if (!m.user) { + throw new Error("Organization member user not found"); + } return { ...m.member, user: { - ...m.user!, - image: m.user?.image - ? yield* imageUploads.resolveImageUrl(m.user?.image) + ...m.user, + image: m.user.image + ? yield* imageUploads.resolveImageUrl(m.user.image) : null, }, }; From affd0cfcc5213d9eb5fe90d018673f5fd92aa4a1 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 10 May 2026 22:44:40 +0100 Subject: [PATCH 07/18] feat(web): resolve inherited rules in folder video queries --- apps/web/lib/folder.ts | 53 +++++++++++++++++++++++++++++++++++------- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/apps/web/lib/folder.ts b/apps/web/lib/folder.ts index ab7f7a325f1..a0aa5c1a89b 100644 --- a/apps/web/lib/folder.ts +++ b/apps/web/lib/folder.ts @@ -11,7 +11,11 @@ import { videos, videoUploads, } from "@cap/database/schema"; -import { Database, ImageUploads } from "@cap/web-backend"; +import { + Database, + ImageUploads, + resolveEffectiveVideoRules, +} from "@cap/web-backend"; import type { ImageUpload, Organisation, Space, Video } from "@cap/web-domain"; import { CurrentUser, Folder } from "@cap/web-domain"; import { and, desc, eq, isNull } from "drizzle-orm"; @@ -76,7 +80,9 @@ const getSharedSpacesForVideos = Effect.fn(function* ( id: spaces.id, name: spaces.name, organizationId: spaces.organizationId, - iconUrl: organizations.iconUrl, + iconUrl: spaces.iconUrl, + settings: spaces.settings, + hasPassword: sql`${spaces.password} IS NOT NULL`.mapWith(Boolean), }) .from(spaceVideos) .innerJoin(spaces, eq(spaceVideos.spaceId, spaces.id)) @@ -119,8 +125,10 @@ const getSharedSpacesForVideos = Effect.fn(function* ( id: string; name: string; organizationId: string; - iconUrl: string; + iconUrl: ImageUpload.ImageUrlOrKey | null; isOrg: boolean; + settings?: (typeof spaces.$inferSelect)["settings"]; + hasPassword: boolean; }> > = {}; @@ -132,8 +140,10 @@ const getSharedSpacesForVideos = Effect.fn(function* ( id: space.id, name: space.name, organizationId: space.organizationId, - iconUrl: space.iconUrl || "", + iconUrl: space.iconUrl, isOrg: false, + settings: space.settings, + hasPassword: space.hasPassword, }); }); @@ -146,8 +156,10 @@ const getSharedSpacesForVideos = Effect.fn(function* ( id: org.id, name: org.name, organizationId: org.organizationId, - iconUrl: org.iconUrl || "", + iconUrl: org.iconUrl, isOrg: true, + settings: null, + hasPassword: false, }); }); @@ -175,6 +187,7 @@ export const getVideosByFolderId = Effect.fn(function* ( public: videos.public, metadata: videos.metadata, duration: videos.duration, + settings: videos.settings, totalComments: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'text' THEN ${comments.id} END)`, totalReactions: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'emoji' THEN ${comments.id} END)`, sharedOrganizations: sql< @@ -233,6 +246,9 @@ export const getVideosByFolderId = Effect.fn(function* ( videos.createdAt, videos.public, videos.metadata, + videos.duration, + videos.settings, + videos.password, users.name, ) .orderBy(desc(videos.effectiveCreatedAt)), @@ -246,6 +262,25 @@ export const getVideosByFolderId = Effect.fn(function* ( const processedVideoData = yield* Effect.all( videoData.map( Effect.fn(function* (video) { + const sharedSpaces = sharedSpacesMap[video.id] ?? []; + const rules = resolveEffectiveVideoRules({ + videoSettings: video.settings, + organizationSettings: null, + spaces: sharedSpaces.filter((space) => !space.isOrg), + }); + const resolvedSharedSpaces = yield* Effect.all( + sharedSpaces.map( + Effect.fn(function* (space) { + return { + ...space, + iconUrl: space.iconUrl + ? yield* imageUploads.resolveImageUrl(space.iconUrl) + : null, + }; + }), + ), + ); + return { id: video.id as Video.VideoId, // Cast to Video.VideoId branded type ownerId: video.ownerId, @@ -268,9 +303,7 @@ export const getVideosByFolderId = Effect.fn(function* ( }), ), ), - sharedSpaces: Array.isArray(sharedSpacesMap[video.id]) - ? sharedSpacesMap[video.id] - : [], + sharedSpaces: resolvedSharedSpaces, ownerName: video.ownerName ?? "", metadata: video.metadata as | { @@ -279,6 +312,10 @@ export const getVideosByFolderId = Effect.fn(function* ( } | undefined, hasPassword: video.hasPassword, + hasInheritedPassword: rules.hasInheritedPassword, + inheritedPasswordSources: rules.inheritedPasswordSources, + inheritedSpaceSettings: rules.inheritedSettings, + settings: video.settings, hasActiveUpload: video.hasActiveUpload, foldersData: [], // Empty array since videos in a folder don't need folder data }; From 1335fcd394fa14b3a052418143b4230fe0ab2062 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 10 May 2026 22:44:40 +0100 Subject: [PATCH 08/18] feat(web): persist viewer rules and password on space creation --- apps/web/actions/organization/create-space.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/apps/web/actions/organization/create-space.ts b/apps/web/actions/organization/create-space.ts index 439619535d2..13f519de7b7 100644 --- a/apps/web/actions/organization/create-space.ts +++ b/apps/web/actions/organization/create-space.ts @@ -2,8 +2,10 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; +import { hashPassword } from "@cap/database/crypto"; import { nanoId } from "@cap/database/helpers"; import { spaceMembers, spaces } from "@cap/database/schema"; +import { userIsPro } from "@cap/utils"; import { type ImageUpload, Space, @@ -15,6 +17,30 @@ import { and, eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { uploadSpaceIcon } from "./upload-space-icon"; +const settingKeys = [ + "disableSummary", + "disableCaptions", + "disableChapters", + "disableReactions", + "disableTranscript", + "disableComments", +] as const; + +const getSettingsFromFormData = (formData: FormData) => + Object.fromEntries( + settingKeys.map((key) => [key, formData.get(key) === "true"]), + ); + +const proSettingKeys = [ + "disableSummary", + "disableChapters", + "disableTranscript", +] as const; + +const hasProSettingsEnabled = ( + settings: ReturnType, +) => proSettingKeys.some((key) => settings[key]); + interface CreateSpaceResponse { success: boolean; spaceId?: string; @@ -37,6 +63,10 @@ export async function createSpace( } const name = formData.get("name") as string; + const passwordEnabled = formData.get("passwordEnabled") === "true"; + const password = formData.get("password") as string | null; + const settings = getSettingsFromFormData(formData); + const canUseProFeatures = userIsPro(user); if (!name) { return { @@ -45,6 +75,27 @@ export async function createSpace( }; } + if (passwordEnabled && !password?.trim()) { + return { + success: false, + error: "Space password is required", + }; + } + + if (!canUseProFeatures && passwordEnabled) { + return { + success: false, + error: "Upgrade required to protect a space with a password", + }; + } + + if (!canUseProFeatures && hasProSettingsEnabled(settings)) { + return { + success: false, + error: "Upgrade required to change these viewer rules", + }; + } + // Check for duplicate space name in the same organization const existingSpace = await db() .select({ id: spaces.id }) @@ -67,6 +118,10 @@ export async function createSpace( // Generate the space ID early so we can use it in the file path const spaceId = Space.SpaceId.make(nanoId()); let iconUrl: ImageUpload.ImageUrlOrKey | null = null; + const hashedPassword = + passwordEnabled && password?.trim() + ? await hashPassword(password.trim()) + : null; await db().transaction(async (tx) => { // Create the space first @@ -76,6 +131,8 @@ export async function createSpace( organizationId: user.activeOrganizationId, createdById: user.id, iconUrl: null, + settings, + password: hashedPassword, }); // --- Member Management Logic --- From 5bdcc09824b03dd5e8ace007c2932619b17e83e1 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 10 May 2026 22:44:40 +0100 Subject: [PATCH 09/18] feat(web): support space passwords and stricter space updates --- apps/web/actions/organization/update-space.ts | 79 ++++++++++++++++--- 1 file changed, 69 insertions(+), 10 deletions(-) diff --git a/apps/web/actions/organization/update-space.ts b/apps/web/actions/organization/update-space.ts index 09afab8fea5..d8232ca4c3a 100644 --- a/apps/web/actions/organization/update-space.ts +++ b/apps/web/actions/organization/update-space.ts @@ -2,8 +2,10 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; +import { hashPassword } from "@cap/database/crypto"; import { nanoId } from "@cap/database/helpers"; import { spaceMembers, spaces } from "@cap/database/schema"; +import { userIsPro } from "@cap/utils"; import { S3Buckets } from "@cap/web-backend"; import { Space, @@ -17,6 +19,36 @@ import { revalidatePath } from "next/cache"; import { runPromise } from "@/lib/server"; import { uploadSpaceIcon } from "./upload-space-icon"; +const settingKeys = [ + "disableSummary", + "disableCaptions", + "disableChapters", + "disableReactions", + "disableTranscript", + "disableComments", +] as const; + +const getSettingsFromFormData = (formData: FormData) => + Object.fromEntries( + settingKeys.map((key) => [key, formData.get(key) === "true"]), + ); + +const proSettingKeys = [ + "disableSummary", + "disableChapters", + "disableTranscript", +] as const; + +const preserveProSettings = ( + submittedSettings: ReturnType, + existingSettings: (typeof spaces.$inferSelect)["settings"], +) => ({ + ...submittedSettings, + ...Object.fromEntries( + proSettingKeys.map((key) => [key, existingSettings?.[key] ?? false]), + ), +}); + export async function updateSpace(formData: FormData) { const user = await getCurrentUser(); if (!user) return { success: false, error: "Unauthorized" }; @@ -25,12 +57,18 @@ export async function updateSpace(formData: FormData) { const name = formData.get("name") as string; const members = formData.getAll("members[]") as User.UserId[]; const iconFile = formData.get("icon") as File | null; + const passwordAction = formData.get("passwordAction") as + | "keep" + | "set" + | "remove" + | null; + const password = formData.get("password") as string | null; - // Get the space to check authorization const [space] = await db() .select({ createdById: spaces.createdById, organizationId: spaces.organizationId, + settings: spaces.settings, }) .from(spaces) .where(eq(spaces.id, id)) @@ -40,22 +78,45 @@ export async function updateSpace(formData: FormData) { return { success: false, error: "Space not found" }; } - // Check if user is the creator or a member of the space const isCreator = space.createdById === user.id; const [membership] = await db() - .select() + .select({ role: spaceMembers.role }) .from(spaceMembers) .where(and(eq(spaceMembers.spaceId, id), eq(spaceMembers.userId, user.id))) .limit(1); - if (!isCreator && !membership) { + if (!isCreator && membership?.role !== "Admin") { return { success: false, error: "Unauthorized" }; } - // Update space name - await db().update(spaces).set({ name }).where(eq(spaces.id, id)); + const submittedSettings = getSettingsFromFormData(formData); + const canUseProFeatures = userIsPro(user); + const settings = canUseProFeatures + ? submittedSettings + : preserveProSettings(submittedSettings, space.settings); + const spaceUpdate: { + name: string; + settings: ReturnType; + password?: string | null; + } = { name, settings }; + + if (passwordAction === "set") { + if (!canUseProFeatures) { + return { + success: false, + error: "Upgrade required to protect a space with a password", + }; + } + if (!password?.trim()) { + return { success: false, error: "Space password is required" }; + } + spaceUpdate.password = await hashPassword(password.trim()); + } else if (passwordAction === "remove") { + spaceUpdate.password = null; + } + + await db().update(spaces).set(spaceUpdate).where(eq(spaces.id, id)); - // Update members - ensure creator is always included const memberIds = Array.from(new Set([...members, space.createdById])); await db().delete(spaceMembers).where(eq(spaceMembers.spaceId, id)); @@ -74,13 +135,10 @@ export async function updateSpace(formData: FormData) { }), ); - // Handle icon removal if requested if (formData.get("removeIcon") === "true") { - // Remove icon from S3 and set iconUrl to null const spaceArr = await db().select().from(spaces).where(eq(spaces.id, id)); const spaceData = spaceArr[0]; if (spaceData?.iconUrl) { - // Extract the S3 key (it might already be a key or could be a legacy URL) const key = spaceData.iconUrl.startsWith("organizations/") ? spaceData.iconUrl : spaceData.iconUrl.match(/organizations\/.+/)?.[0]; @@ -102,6 +160,7 @@ export async function updateSpace(formData: FormData) { } revalidatePath("/dashboard"); + revalidatePath("/dashboard/caps"); revalidatePath(`/dashboard/spaces/${id}`); return { success: true }; } From 03967843bf5cc62aba18122f62fbf3190210b695 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 10 May 2026 22:44:40 +0100 Subject: [PATCH 10/18] feat(web): verify share session against space password hashes --- apps/web/actions/videos/password.ts | 34 +++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/apps/web/actions/videos/password.ts b/apps/web/actions/videos/password.ts index 5046e27e82e..864828d12b5 100644 --- a/apps/web/actions/videos/password.ts +++ b/apps/web/actions/videos/password.ts @@ -2,8 +2,13 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; -import { encrypt, hashPassword, verifyPassword } from "@cap/database/crypto"; -import { videos } from "@cap/database/schema"; +import { + encrypt, + hashPassword, + verifyPassword as verifyPlainPassword, +} from "@cap/database/crypto"; +import { spaces, spaceVideos, videos } from "@cap/database/schema"; +import { collectPasswordHashes } from "@cap/web-backend"; import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; @@ -92,15 +97,30 @@ export async function verifyVideoPassword( .from(videos) .where(eq(videos.id, videoId)); - if (!video || !video.password) throw new Error("No password set"); + if (!video) throw new Error("No password set"); - const valid = await verifyPassword(video.password, password); + const spacePasswords = await db() + .select({ password: spaces.password }) + .from(spaceVideos) + .innerJoin(spaces, eq(spaceVideos.spaceId, spaces.id)) + .where(eq(spaceVideos.videoId, videoId)); - if (!valid) throw new Error("Invalid password"); + const passwordHashes = collectPasswordHashes({ + videoPassword: video.password, + spacePasswords, + }); - (await cookies()).set("x-cap-password", await encrypt(video.password)); + if (passwordHashes.length === 0) throw new Error("No password set"); - return { success: true, value: "Password verified" }; + for (const passwordHash of passwordHashes) { + const valid = await verifyPlainPassword(passwordHash, password); + if (valid) { + (await cookies()).set("x-cap-password", await encrypt(passwordHash)); + return { success: true, value: "Password verified" }; + } + } + + throw new Error("Invalid password"); } catch (error) { console.error("Error verifying video password:", error); return { success: false, error: "Failed to verify password" }; From c86886afcafb01d896b004e1686ce867bf4b7e89 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 10 May 2026 22:44:48 +0100 Subject: [PATCH 11/18] feat(web): add space viewer rules and password controls to UI --- .../_components/Navbar/SpaceDialog.tsx | 238 +++++++++++++++++- .../_components/Navbar/SpacesList.tsx | 37 ++- 2 files changed, 256 insertions(+), 19 deletions(-) diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/SpaceDialog.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/SpaceDialog.tsx index a9b83e8ac2e..117a6a2bcba 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/SpaceDialog.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/SpaceDialog.tsx @@ -13,20 +13,26 @@ import { FormField, Input, Label, + Switch, } from "@cap/ui"; import type { ImageUpload } from "@cap/web-domain"; -import { faLayerGroup } from "@fortawesome/free-solid-svg-icons"; +import { + faGear, + faLayerGroup, + faLock, +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { useRouter } from "next/navigation"; import type React from "react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import * as z from "zod"; import { updateSpace } from "@/actions/organization/update-space"; import { FileInput } from "@/components/FileInput"; import { useDashboardContext } from "../../Contexts"; +import type { OrganizationSettings } from "../../dashboard-data"; import { MemberSelect } from "../../spaces/[spaceId]/components/MemberSelect"; import { createSpace } from "./server"; @@ -39,6 +45,8 @@ interface SpaceDialogProps { name: string; members: string[]; iconUrl?: ImageUpload.ImageUrl; + settings?: OrganizationSettings | null; + hasPassword?: boolean; } | null; onSpaceUpdated?: () => void; } @@ -73,7 +81,7 @@ const SpaceDialog = ({ {edit ? "Edit Space" : "Create New Space"} -
+
= (props) => { const { edit = false, space } = props; const router = useRouter(); @@ -156,7 +216,51 @@ export const NewSpaceForm: React.FC = (props) => { const [selectedFile, setSelectedFile] = useState(null); const [isUploading, setIsUploading] = useState(false); - const { activeOrganization } = useDashboardContext(); + const { activeOrganization, user, setUpgradeModalOpen } = + useDashboardContext(); + const [settings, setSettings] = useState({ + ...defaultSettings, + ...space?.settings, + }); + const [passwordEnabled, setPasswordEnabled] = useState( + Boolean(space?.hasPassword), + ); + const [passwordValue, setPasswordValue] = useState(""); + const iconInputId = useId(); + + useEffect(() => { + setSettings({ ...defaultSettings, ...space?.settings }); + setPasswordEnabled(Boolean(space?.hasPassword)); + setPasswordValue(""); + }, [space]); + + const handleToggleSetting = (key: keyof OrganizationSettings) => { + setSettings((prev) => { + const nextValue = !prev[key]; + + if (key === "disableTranscript" && nextValue) { + return { + ...prev, + [key]: nextValue, + disableSummary: true, + disableChapters: true, + }; + } + + return { ...prev, [key]: nextValue }; + }); + }; + + const handlePasswordToggle = (checked: boolean) => { + if (checked && user && !user.isPro) { + setUpgradeModalOpen(true); + return; + } + setPasswordEnabled(checked); + if (!checked) { + setPasswordValue(""); + } + }; const handleFileChange = (file: File | null) => { if (file) { @@ -199,8 +303,33 @@ export const NewSpaceForm: React.FC = (props) => { }); } + for (const option of settingOptions) { + formData.append(option.value, String(settings[option.value])); + } + + formData.append("passwordEnabled", String(passwordEnabled)); + + if (passwordEnabled && passwordValue.trim()) { + formData.append("password", passwordValue.trim()); + } + if (edit && space?.id) { + if ( + passwordEnabled && + !space.hasPassword && + !passwordValue.trim() + ) { + throw new Error("Space password is required"); + } formData.append("id", space.id); + const passwordAction = !passwordEnabled + ? space.hasPassword + ? "remove" + : "keep" + : passwordValue.trim() + ? "set" + : "keep"; + formData.append("passwordAction", passwordAction); // If the user removed the icon, send a removeIcon flag if (selectedFile === null && space.iconUrl) { formData.append("removeIcon", "true"); @@ -212,6 +341,9 @@ export const NewSpaceForm: React.FC = (props) => { toast.success("Space updated successfully"); router.refresh(); } else { + if (passwordEnabled && !passwordValue.trim()) { + throw new Error("Space password is required"); + } const result = await createSpace(formData); if (!result.success) { throw new Error(result.error || "Failed to create space"); @@ -297,8 +429,102 @@ export const NewSpaceForm: React.FC = (props) => { }} /> +
+
+
+
+ +
+
+

+ Require password +

+

+ All caps in this space require this password +

+
+
+ +
+ {passwordEnabled && ( +
+ setPasswordValue(e.target.value)} + placeholder={ + space?.hasPassword ? "Enter new password" : "Set a password" + } + /> + {space?.hasPassword && !passwordValue && ( +

+ Leave blank to keep existing password +

+ )} +
+ )} +
+ +
+
+
+ +
+
+

Viewer rules

+

+ These apply to every cap in this space +

+
+
+
+ {settingOptions.map((option) => { + const disabled = + (option.pro && !user?.isPro) || + ((option.value === "disableSummary" || + option.value === "disableChapters") && + settings.disableTranscript); + + return ( +
+
+
+

{option.label}

+ {option.pro && ( +

+ Pro +

+ )} +
+

+ {option.description} +

+
+ handleToggleSetting(option.value)} + /> +
+ ); + })} +
+
+
- + Upload a custom logo or icon for your space (max 1MB). @@ -306,7 +532,7 @@ export const NewSpaceForm: React.FC = (props) => {
void }) => { } }; - if (!spacesData) return null; - const { displayedSpaces, hasMoreSpaces, hiddenSpacesCount } = useMemo(() => { + const spaces = spacesData ?? []; return { - displayedSpaces: showAllSpaces ? spacesData : spacesData.slice(0, 3), - hasMoreSpaces: spacesData.length > 3, - hiddenSpacesCount: Math.max(0, spacesData.length - 3), + displayedSpaces: showAllSpaces ? spaces : spaces.slice(0, 3), + hasMoreSpaces: spaces.length > 3, + hiddenSpacesCount: Math.max(0, spaces.length - 3), }; }, [spacesData, showAllSpaces]); + if (!spacesData) return null; + const handleDragOver = (e: React.DragEvent, spaceId: string) => { e.preventDefault(); setActiveDropTarget(spaceId); @@ -228,7 +230,7 @@ const SpacesList = ({ toggleMobileNav }: { toggleMobileNav?: () => void }) => { content={space.name} key={space.id} > -
void }) => { {space.name} + {space.hasPassword && ( + + )} {/* Hide delete button for 'All spaces' synthetic entry */} {!space.primary && isOwner && ( -
handleDeleteSpace(e, space)} className={ "flex justify-center items-center ml-auto rounded-full opacity-0 transition-all group size-6 group-hover:opacity-100 hover:bg-gray-4" @@ -308,12 +317,12 @@ const SpacesList = ({ toggleMobileNav }: { toggleMobileNav?: () => void }) => { icon={faXmark} className="size-3.5 text-gray-12" /> -
+ )} )} -
+ ); })} @@ -374,24 +383,26 @@ const SpaceToggleControl = ({ if (sidebarCollapsed) return null; if (!showAllSpaces && hasMoreSpaces) { return ( -
setShowAllSpaces(true)} className="flex justify-between items-center p-2 w-full truncate rounded-xl transition-colors cursor-pointer text-gray-10 hover:text-gray-12 hover:bg-gray-3" > + {hiddenSpacesCount} more -
+ ); } if (showAllSpaces) { return ( -
setShowAllSpaces(false)} className="flex justify-between items-center p-2 w-full truncate rounded-xl transition-colors cursor-pointer text-gray-10 hover:text-gray-12 hover:bg-gray-3" > Show less -
+ ); } return null; From 5799f496d116b1d90a7e876cf851545043b669e8 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 10 May 2026 22:44:48 +0100 Subject: [PATCH 12/18] feat(web): show inherited space rules on caps dashboard --- apps/web/app/(org)/dashboard/caps/Caps.tsx | 8 +++ .../caps/components/CapCard/CapCard.tsx | 65 +++++++++++------ .../caps/components/SettingsDialog.tsx | 17 ++++- .../caps/components/SharingDialog.tsx | 70 +++++++++++++++++-- apps/web/app/(org)/dashboard/caps/page.tsx | 49 +++++++++++-- 5 files changed, 178 insertions(+), 31 deletions(-) diff --git a/apps/web/app/(org)/dashboard/caps/Caps.tsx b/apps/web/app/(org)/dashboard/caps/Caps.tsx index 4106cc732df..41894598208 100644 --- a/apps/web/app/(org)/dashboard/caps/Caps.tsx +++ b/apps/web/app/(org)/dashboard/caps/Caps.tsx @@ -2,6 +2,7 @@ import type { VideoMetadata } from "@cap/database/types"; import { Button } from "@cap/ui"; +import type { SpaceRuleSource, ViewerSettingKey } from "@cap/web-backend"; import type { ImageUpload, Video } from "@cap/web-domain"; import { faFolderPlus, faInfoCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -45,11 +46,18 @@ export type VideoData = { name: string; isOrg: boolean; organizationId: string; + iconUrl?: ImageUpload.ImageUrl | null; + settings?: Partial> | null; + hasPassword?: boolean; }[]; ownerName: string; metadata?: VideoMetadata; hasPassword: boolean; + hasInheritedPassword?: boolean; + inheritedPasswordSources?: SpaceRuleSource[]; + inheritedSpaceSettings?: Partial>; hasActiveUpload: boolean; + settings?: Partial> | null; }[]; export const Caps = ({ diff --git a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx index 7f77fffae6e..278cd4b20eb 100644 --- a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx @@ -9,6 +9,7 @@ import { DropdownMenuTrigger, } from "@cap/ui"; import { calculateStrokeDashoffset, getProgressCircleConfig } from "@cap/utils"; +import type { SpaceRuleSource, ViewerSettingKey } from "@cap/web-backend"; import type { ImageUpload, Video } from "@cap/web-domain"; import { HttpClient } from "@effect/platform"; import { @@ -85,10 +86,18 @@ export interface CapCardProps extends PropsWithChildren { name: string; iconUrl?: ImageUpload.ImageUrl | null; organizationId: string; + isOrg?: boolean; + settings?: Partial> | null; + hasPassword?: boolean; }[]; ownerName: string | null; metadata?: VideoMetadata; hasPassword?: boolean; + hasInheritedPassword?: boolean; + inheritedPasswordSources?: SpaceRuleSource[]; + inheritedSpaceSettings?: Partial< + Record + >; hasActiveUpload: boolean | undefined; duration?: number; settings?: { @@ -98,7 +107,7 @@ export interface CapCardProps extends PropsWithChildren { disableChapters?: boolean; disableReactions?: boolean; disableTranscript?: boolean; - }; + } | null; }; analytics: number; isLoadingAnalytics: boolean; @@ -138,6 +147,8 @@ export const CapCard = ({ const [passwordProtected, setPasswordProtected] = useState( cap.hasPassword || false, ); + const effectivePasswordProtected = + passwordProtected || Boolean(cap.hasInheritedPassword); const { webUrl } = usePublicEnv(); const [copyPressed, setCopyPressed] = useState(false); @@ -261,7 +272,7 @@ export const CapCard = ({ return element; }; - const handleDragStart = (e: React.DragEvent) => { + const handleDragStart = (e: React.DragEvent) => { if (anyCapSelected || !isOwner) return; // Set the data transfer @@ -318,16 +329,6 @@ export const CapCard = ({ }); }; - const handleCardClick = (e: React.MouseEvent) => { - if (anyCapSelected) { - e.preventDefault(); - e.stopPropagation(); - if (onSelectToggle) { - onSelectToggle(); - } - } - }; - const handleSelectClick = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); @@ -359,11 +360,13 @@ export const CapCard = ({ onSharingUpdated={handleSharingUpdated} isPublic={cap.public} hasPassword={passwordProtected} + inheritedPasswordSources={cap.inheritedPasswordSources} onPasswordUpdated={handlePasswordUpdated} /> setIsSettingsDialogOpen(false)} /> @@ -374,8 +377,8 @@ export const CapCard = ({ hasPassword={passwordProtected} onPasswordUpdated={handlePasswordUpdated} /> -
{anyCapSelected && !sharedCapCard && ( -
+
+ )}
@@ -641,6 +652,7 @@ export const CapCard = ({
+ +
+ )}
-
+ ); }; diff --git a/apps/web/app/(org)/dashboard/caps/components/SettingsDialog.tsx b/apps/web/app/(org)/dashboard/caps/components/SettingsDialog.tsx index 80715a66fe0..968edddc8be 100644 --- a/apps/web/app/(org)/dashboard/caps/components/SettingsDialog.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/SettingsDialog.tsx @@ -7,6 +7,7 @@ import { DialogTitle, Switch, } from "@cap/ui"; +import type { SpaceRuleSource, ViewerSettingKey } from "@cap/web-backend"; import type { Video } from "@cap/web-domain"; import { faGear } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -22,6 +23,7 @@ interface SettingsDialogProps { onClose: () => void; capId: Video.VideoId; settingsData?: OrganizationSettings; + inheritedSpaceSettings?: Partial>; } const options: { @@ -70,6 +72,7 @@ export const SettingsDialog = ({ onClose, capId, settingsData, + inheritedSpaceSettings, }: SettingsDialogProps) => { const { user, organizationSettings } = useDashboardContext(); const [saveLoading, setSaveLoading] = useState(false); @@ -141,6 +144,8 @@ export const SettingsDialog = ({ ); const getEffectiveValue = (key: keyof OrganizationSettings) => { + const inheritedSources = inheritedSpaceSettings?.[key]; + if (inheritedSources && inheritedSources.length > 0) return true; const videoValue = settings?.[key]; const orgValue = organizationSettings?.[key] ?? false; return videoValue !== undefined || videoValue === true @@ -148,6 +153,13 @@ export const SettingsDialog = ({ : orgValue; }; + const getInheritedLabel = (key: keyof OrganizationSettings) => { + const sources = inheritedSpaceSettings?.[key]; + if (!sources || sources.length === 0) return null; + if (sources.length === 1) return `Required by ${sources[0]?.name}`; + return `Required by ${sources.length} spaces`; + }; + return ( @@ -162,6 +174,7 @@ export const SettingsDialog = ({ const key = option.value as keyof OrganizationSettings; const effectiveValue = getEffectiveValue(key); const orgValue = organizationSettings?.[key] ?? false; + const inheritedLabel = getInheritedLabel(key); return (
- Org {orgValue ? "disabled" : "enabled"} + {inheritedLabel ?? + `Org ${orgValue ? "disabled" : "enabled"}`}

)}
@@ -190,6 +204,7 @@ export const SettingsDialog = ({
> | null; + hasPassword?: boolean; }[]; onSharingUpdated: (updatedSharedSpaces: string[]) => void; isPublic?: boolean; spacesData?: Spaces[] | null; hasPassword?: boolean; + inheritedPasswordSources?: SpaceRuleSource[]; onPasswordUpdated?: (protectedStatus: boolean) => void; user?: CurrentUser | null; onUpgradeRequest?: (open: boolean) => void; @@ -59,6 +64,7 @@ export const SharingDialog: React.FC = ({ isPublic = false, spacesData: propSpacesData = null, hasPassword = false, + inheritedPasswordSources = [], onPasswordUpdated, user: propUser, onUpgradeRequest: propOnUpgradeRequest, @@ -281,6 +287,26 @@ export const SharingDialog: React.FC = ({ ) || []; const allShareableItems = [...organizationEntries, ...realSpaces]; + const selectedInheritedPasswordSources = [ + ...inheritedPasswordSources.filter((source) => + selectedSpaces.has(source.id), + ), + ...sharedSpaces + .filter((space) => selectedSpaces.has(space.id) && space.hasPassword) + .map((space) => ({ id: space.id, name: space.name })), + ...allShareableItems + .filter((space) => selectedSpaces.has(space.id) && space.hasPassword) + .map((space) => ({ id: space.id, name: space.name })), + ].filter( + (source, index, sources) => + sources.findIndex((item) => item.id === source.id) === index, + ); + const inheritedPasswordLabel = + selectedInheritedPasswordSources.length === 1 + ? `Required by ${selectedInheritedPasswordSources[0]?.name}` + : selectedInheritedPasswordSources.length > 1 + ? `Required by ${selectedInheritedPasswordSources.length} spaces` + : null; const filteredSpaces = searchTerm ? allShareableItems.filter((space) => @@ -306,7 +332,8 @@ export const SharingDialog: React.FC = ({
{tabs.map((tab) => ( -
= ({ > {tab}

-
+ ))}
@@ -360,6 +387,25 @@ export const SharingDialog: React.FC = ({ />
+ {inheritedPasswordLabel && ( +
+
+
+ +
+
+

+ Password required +

+

+ {inheritedPasswordLabel} +

+
+
+ +
+ )} +
= ({ ? initialPasswordEnabled ? "Password protected" : "Password protection" - : "Add password"} + : inheritedPasswordLabel + ? "Add another password" + : "Add password"}

{passwordEnabled - ? "Viewers must enter a password to view" + ? inheritedPasswordLabel + ? "Viewers can use this password or a space password" + : "Viewers must enter a password to view" : "Restrict access with a password"}

@@ -522,6 +572,8 @@ const SpaceCard = ({ name: string; iconUrl?: ImageUpload.ImageUrl | null; organizationId: string; + settings?: Partial> | null; + hasPassword?: boolean; }; selectedSpaces: Set; handleToggleSpace: (spaceId: string) => void; @@ -537,7 +589,8 @@ const SpaceCard = ({ : space.name } > -
{space.name}

+ {space.hasPassword && ( +
+ +
+ )} -
+ ); }; diff --git a/apps/web/app/(org)/dashboard/caps/page.tsx b/apps/web/app/(org)/dashboard/caps/page.tsx index c534ed2f5ca..8d5681c4ae7 100644 --- a/apps/web/app/(org)/dashboard/caps/page.tsx +++ b/apps/web/app/(org)/dashboard/caps/page.tsx @@ -12,7 +12,11 @@ import { videoUploads, } from "@cap/database/schema"; import { serverEnv } from "@cap/env"; -import { Database, ImageUploads } from "@cap/web-backend"; +import { + Database, + ImageUploads, + resolveEffectiveVideoRules, +} from "@cap/web-backend"; import { type ImageUpload, Video } from "@cap/web-domain"; import { and, count, desc, eq, inArray, isNull, sql } from "drizzle-orm"; import { type Array, Effect } from "effect"; @@ -41,6 +45,9 @@ const getSharedSpacesForVideos = Effect.fn(function* ( id: spaces.id, name: spaces.name, organizationId: spaces.organizationId, + iconUrl: spaces.iconUrl, + settings: spaces.settings, + hasPassword: sql`${spaces.password} IS NOT NULL`.mapWith(Boolean), }) .from(spaceVideos) .innerJoin(spaces, eq(spaceVideos.spaceId, spaces.id)) @@ -74,6 +81,9 @@ const getSharedSpacesForVideos = Effect.fn(function* ( name: string; organizationId: string; isOrg: boolean; + iconUrl?: ImageUpload.ImageUrlOrKey | null; + settings?: (typeof spaces.$inferSelect)["settings"]; + hasPassword: boolean; }> > = {}; @@ -87,6 +97,9 @@ const getSharedSpacesForVideos = Effect.fn(function* ( name: space.name, organizationId: space.organizationId, isOrg: false, + iconUrl: space.iconUrl, + settings: space.settings, + hasPassword: space.hasPassword, }); }); @@ -100,6 +113,9 @@ const getSharedSpacesForVideos = Effect.fn(function* ( name: org.name, organizationId: org.organizationId, isOrg: true, + iconUrl: org.iconUrl, + settings: null, + hasPassword: false, }); }); @@ -190,6 +206,10 @@ export default async function CapsPage(props: PageProps<"/dashboard/caps">) { videos.name, videos.createdAt, videos.metadata, + videos.duration, + videos.public, + videos.password, + videos.settings, videos.orgId, users.name, ) @@ -227,13 +247,23 @@ export default async function CapsPage(props: PageProps<"/dashboard/caps">) { Effect.fn(function* (video) { const imageUploads = yield* ImageUploads; - const { effectiveDate, ...videoWithoutEffectiveDate } = video; + const { effectiveDate: _effectiveDate, ...videoWithoutEffectiveDate } = + video; + const sharedSpaces = sharedSpacesMap[video.id] ?? []; + const rules = resolveEffectiveVideoRules({ + videoSettings: video.settings, + organizationSettings: null, + spaces: sharedSpaces.filter((space) => !space.isOrg), + }); return { ...videoWithoutEffectiveDate, id: Video.VideoId.make(video.id), foldersData, settings: video.settings, + hasInheritedPassword: rules.hasInheritedPassword, + inheritedPasswordSources: rules.inheritedPasswordSources, + inheritedSpaceSettings: rules.inheritedSettings, sharedOrganizations: yield* Effect.all( (video.sharedOrganizations ?? []) .filter((organization) => organization.id !== null) @@ -248,12 +278,23 @@ export default async function CapsPage(props: PageProps<"/dashboard/caps">) { }), ), ), - sharedSpaces: sharedSpacesMap[video.id] ?? [], + sharedSpaces: yield* Effect.all( + sharedSpaces.map( + Effect.fn(function* (space) { + return { + ...space, + iconUrl: space.iconUrl + ? yield* imageUploads.resolveImageUrl(space.iconUrl) + : null, + }; + }), + ), + ), ownerName: video.ownerName ?? "", metadata: video.metadata as | { customCreatedAt?: string; - [key: string]: any; + [key: string]: unknown; } | undefined, }; From 1d8048fb31a97afb5f91593fcb46ae1cd32cd23a Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 10 May 2026 22:44:48 +0100 Subject: [PATCH 13/18] feat(web): reflect inherited viewer rules on space shared caps --- .../dashboard/spaces/[spaceId]/SharedCaps.tsx | 113 +++++++++++++++--- .../[spaceId]/components/SharedCapCard.tsx | 24 +++- .../(org)/dashboard/spaces/[spaceId]/page.tsx | 110 ++++++++++++++++- 3 files changed, 220 insertions(+), 27 deletions(-) diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/SharedCaps.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/SharedCaps.tsx index b662460a97d..1c0c6fc9ef7 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/SharedCaps.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/SharedCaps.tsx @@ -2,12 +2,24 @@ import type { VideoMetadata } from "@cap/database/types"; import { Button } from "@cap/ui"; -import type { Organisation, Space, User, Video } from "@cap/web-domain"; -import { faFolderPlus, faInfoCircle } from "@fortawesome/free-solid-svg-icons"; +import type { SpaceRuleSource, ViewerSettingKey } from "@cap/web-backend"; +import type { + ImageUpload, + Organisation, + Space, + User, + Video, +} from "@cap/web-domain"; +import { + faFolderPlus, + faGear, + faInfoCircle, +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useRouter, useSearchParams } from "next/navigation"; import { useState } from "react"; import { useVideosAnalyticsQuery } from "@/lib/Queries/Analytics"; +import SpaceDialog from "../../_components/Navbar/SpaceDialog"; import { useDashboardContext } from "../../Contexts"; import { CapPagination } from "../../caps/components/CapPagination"; import Folder, { type FolderDataType } from "../../caps/components/Folder"; @@ -28,11 +40,26 @@ type SharedVideoData = { ownerId: string; name: string; createdAt: Date; + public?: boolean; totalComments: number; totalReactions: number; ownerName: string | null; metadata?: VideoMetadata; + hasPassword?: boolean; + hasInheritedPassword?: boolean; + inheritedPasswordSources?: SpaceRuleSource[]; + inheritedSpaceSettings?: Partial>; + sharedSpaces?: { + id: string; + name: string; + isOrg: boolean; + organizationId: string; + iconUrl?: ImageUpload.ImageUrl | null; + settings?: Partial> | null; + hasPassword?: boolean; + }[]; hasActiveUpload: boolean | undefined; + settings?: Partial> | null; }[]; type SpaceData = { @@ -40,6 +67,9 @@ type SpaceData = { name: string; organizationId: Organisation.OrganisationId; createdById: User.UserId; + iconUrl?: ImageUpload.ImageUrl | null; + settings?: Partial> | null; + hasPassword?: boolean; }; export const SharedCaps = ({ @@ -82,6 +112,7 @@ export const SharedCaps = ({ isDragging: false, }); const [isAddVideosDialogOpen, setIsAddVideosDialogOpen] = useState(false); + const [isSpaceSettingsOpen, setIsSpaceSettingsOpen] = useState(false); const [ isAddOrganizationVideosDialogOpen, setIsAddOrganizationVideosDialogOpen, @@ -105,18 +136,51 @@ export const SharedCaps = ({ router.refresh(); }; + const spaceSettingsDialog = spaceData ? ( + setIsSpaceSettingsOpen(false)} + onSpaceUpdated={() => { + router.refresh(); + setIsSpaceSettingsOpen(false); + }} + space={{ + id: spaceData.id, + name: spaceData.name, + members: spaceMembers?.map((member) => member.userId) ?? [], + iconUrl: spaceData.iconUrl ?? undefined, + settings: spaceData.settings ?? null, + hasPassword: spaceData.hasPassword, + }} + /> + ) : null; + if (data.length === 0 && folders?.length === 0) { return (
+ {spaceSettingsDialog} {spaceData && spaceMembers && ( - setIsAddVideosDialogOpen(true)} - /> +
+ setIsAddVideosDialogOpen(true)} + /> + {isSpaceOwner && ( + + )} +
)} {organizationData && organizationMembers && !spaceData && ( + {spaceSettingsDialog} {isDraggingCap.isDragging && (
@@ -187,14 +252,26 @@ export const SharedCaps = ({ />
{spaceData && spaceMembers && ( - setIsAddVideosDialogOpen(true)} - /> + <> + setIsAddVideosDialogOpen(true)} + /> + {isSpaceOwner && ( + + )} + )} {organizationData && organizationMembers && !spaceData && ( + >; + sharedSpaces?: { + id: string; + name: string; + isOrg: boolean; + organizationId: string; + iconUrl?: ImageUpload.ImageUrl | null; + settings?: Partial> | null; + hasPassword?: boolean; + }[]; hasActiveUpload: boolean | undefined; + settings?: Partial> | null; }; analytics: number; isLoadingAnalytics: boolean; @@ -46,7 +64,7 @@ export const SharedCapCard: React.FC = ({ const isOwner = userId === cap.ownerId; return ( -
+
  • = ({ )}
  • -
    + ); }; diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx index a6bb80a84d5..e4d1727d3c9 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx @@ -7,6 +7,7 @@ import { organizations, sharedVideos, spaceMembers, + spaces, spaceVideos, users, videos, @@ -17,6 +18,7 @@ import { Database, ImageUploads, makeCurrentUserLayer, + resolveEffectiveVideoRules, Spaces, } from "@cap/web-backend"; import { @@ -25,7 +27,7 @@ import { Space, Video, } from "@cap/web-domain"; -import { and, count, desc, eq, isNull, sql } from "drizzle-orm"; +import { and, count, desc, eq, inArray, isNull, sql } from "drizzle-orm"; import { Effect } from "effect"; import type { Metadata } from "next"; import { notFound } from "next/navigation"; @@ -142,6 +144,52 @@ const fetchOrganizationMembers = Effect.fn(function* ( ); }); +async function fetchSharedSpacesForVideos(videoIds: Video.VideoId[]) { + if (videoIds.length === 0) return {}; + + const rows = await db() + .select({ + videoId: spaceVideos.videoId, + id: spaces.id, + name: spaces.name, + organizationId: spaces.organizationId, + iconUrl: spaces.iconUrl, + settings: spaces.settings, + hasPassword: sql`${spaces.password} IS NOT NULL`.mapWith(Boolean), + }) + .from(spaceVideos) + .innerJoin(spaces, eq(spaceVideos.spaceId, spaces.id)) + .where(inArray(spaceVideos.videoId, videoIds)); + + const resolvedRows = await Effect.gen(function* () { + const imageUploads = yield* ImageUploads; + + return yield* Effect.all( + rows.map( + Effect.fn(function* (row) { + return { + ...row, + isOrg: false as const, + iconUrl: row.iconUrl + ? yield* imageUploads.resolveImageUrl(row.iconUrl) + : null, + }; + }), + ), + ); + }).pipe(runPromise); + + return resolvedRows.reduce< + Record[]> + >((acc, row) => { + const spaces = acc[row.videoId] ?? []; + acc[row.videoId] = spaces; + const { videoId: _videoId, ...space } = row; + spaces.push(space); + return acc; + }, {}); +} + export default async function SharedCapsPage(props: { params: Promise<{ spaceId: string }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }>; @@ -173,6 +221,15 @@ export default async function SharedCapsPage(props: { fetchOrganizationMembers(space.organizationId).pipe(runPromise), fetchFolders(space.id, false), ]); + const resolvedSpace = await Effect.gen(function* () { + const imageUploads = yield* ImageUploads; + return { + ...space, + iconUrl: space.iconUrl + ? yield* imageUploads.resolveImageUrl(space.iconUrl) + : null, + }; + }).pipe(runPromise); async function fetchSpaceVideos( spaceId: Space.SpaceIdOrOrganisationId, @@ -189,6 +246,9 @@ export default async function SharedCapsPage(props: { createdAt: videos.createdAt, metadata: videos.metadata, duration: videos.duration, + public: videos.public, + settings: videos.settings, + hasPassword: sql`${videos.password} IS NOT NULL`.mapWith(Boolean), totalComments: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'text' THEN ${comments.id} END)`, totalReactions: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'emoji' THEN ${comments.id} END)`, ownerName: users.name, @@ -216,6 +276,10 @@ export default async function SharedCapsPage(props: { videos.name, videos.createdAt, videos.metadata, + videos.duration, + videos.public, + videos.settings, + videos.password, users.name, ) .orderBy(desc(videos.effectiveCreatedAt)) @@ -240,14 +304,28 @@ export default async function SharedCapsPage(props: { page, limit, ); + const sharedSpacesMap = await fetchSharedSpacesForVideos( + spaceVideoData.map((video) => Video.VideoId.make(video.id)), + ); const processedVideoData = spaceVideoData.map((video) => { - const { effectiveDate, ...videoWithoutEffectiveDate } = video; + const { effectiveDate: _effectiveDate, ...videoWithoutEffectiveDate } = + video; + const sharedSpaces = sharedSpacesMap[video.id] ?? []; + const rules = resolveEffectiveVideoRules({ + videoSettings: video.settings, + organizationSettings: null, + spaces: sharedSpaces, + }); return { ...videoWithoutEffectiveDate, id: Video.VideoId.make(video.id), + hasInheritedPassword: rules.hasInheritedPassword, + inheritedPasswordSources: rules.inheritedPasswordSources, + inheritedSpaceSettings: rules.inheritedSettings, + sharedSpaces, ownerName: video.ownerName ?? null, metadata: video.metadata as - | { customCreatedAt?: string; [key: string]: any } + | { customCreatedAt?: string; [key: string]: unknown } | undefined, }; }); @@ -256,7 +334,7 @@ export default async function SharedCapsPage(props: { `COUNT(DISTINCT CASE WHEN ${comments.type} = 'text' THEN ${comments.id} END)`, totalReactions: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'emoji' THEN ${comments.id} END)`, ownerName: users.name, @@ -314,6 +395,9 @@ export default async function SharedCapsPage(props: { videos.metadata, users.name, videos.duration, + videos.public, + videos.settings, + videos.password, ) .orderBy(desc(videos.effectiveCreatedAt)) .limit(limit) @@ -345,14 +429,28 @@ export default async function SharedCapsPage(props: { ]); const { videos: orgVideoData, totalCount } = organizationVideos; + const sharedSpacesMap = await fetchSharedSpacesForVideos( + orgVideoData.map((video) => Video.VideoId.make(video.id)), + ); const processedVideoData = orgVideoData.map((video) => { - const { effectiveDate, ...videoWithoutEffectiveDate } = video; + const { effectiveDate: _effectiveDate, ...videoWithoutEffectiveDate } = + video; + const sharedSpaces = sharedSpacesMap[video.id] ?? []; + const rules = resolveEffectiveVideoRules({ + videoSettings: video.settings, + organizationSettings: null, + spaces: sharedSpaces, + }); return { ...videoWithoutEffectiveDate, id: Video.VideoId.make(video.id), + hasInheritedPassword: rules.hasInheritedPassword, + inheritedPasswordSources: rules.inheritedPasswordSources, + inheritedSpaceSettings: rules.inheritedSettings, + sharedSpaces, ownerName: video.ownerName ?? null, metadata: video.metadata as - | { customCreatedAt?: string; [key: string]: any } + | { customCreatedAt?: string; [key: string]: unknown } | undefined, }; }); From 68ce15b5ba4f53f3f0ae11a85a8c3b0a8600819f Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 10 May 2026 22:44:48 +0100 Subject: [PATCH 14/18] feat(web): honor effective viewer settings on share and embed pages --- .../[videoId]/_components/EmbedVideo.tsx | 24 +++++--- apps/web/app/embed/[videoId]/page.tsx | 61 ++++++++++++++----- .../s/[videoId]/_components/ShareHeader.tsx | 19 +++++- apps/web/app/s/[videoId]/page.tsx | 22 ++++++- apps/web/app/s/[videoId]/types.ts | 4 ++ 5 files changed, 105 insertions(+), 25 deletions(-) diff --git a/apps/web/app/embed/[videoId]/_components/EmbedVideo.tsx b/apps/web/app/embed/[videoId]/_components/EmbedVideo.tsx index 395cb709896..45f2e1762b5 100644 --- a/apps/web/app/embed/[videoId]/_components/EmbedVideo.tsx +++ b/apps/web/app/embed/[videoId]/_components/EmbedVideo.tsx @@ -4,6 +4,7 @@ import type { userSelectProps } from "@cap/database/auth/session"; import type { comments as commentsSchema, videos } from "@cap/database/schema"; import { NODE_ENV } from "@cap/env"; import { Avatar, Logo } from "@cap/ui"; +import type { ViewerSettings } from "@cap/web-backend"; import { AnimatePresence, motion } from "framer-motion"; import { useTranscript } from "hooks/use-transcript"; import { @@ -56,6 +57,7 @@ export const EmbedVideo = forwardRef< chapters?: { title: string; start: number }[]; ownerName?: string | null; autoplay?: boolean; + viewerSettings?: ViewerSettings | null; showPlaybackStatusBadge?: boolean; } >( @@ -67,6 +69,7 @@ export const EmbedVideo = forwardRef< chapters = [], ownerName, autoplay: _autoplay = false, + viewerSettings, showPlaybackStatusBadge = false, }, ref, @@ -86,10 +89,12 @@ export const EmbedVideo = forwardRef< ); const [subtitleUrl, setSubtitleUrl] = useState(null); const [chaptersUrl, setChaptersUrl] = useState(null); + const captionsDisabled = viewerSettings?.disableCaptions ?? false; + const chaptersDisabled = viewerSettings?.disableChapters ?? false; const { data: transcriptContent, error: transcriptError } = useTranscript( data.id, - data.transcriptionStatus, + captionsDisabled ? null : data.transcriptionStatus, ); useEffect(() => { @@ -106,6 +111,7 @@ export const EmbedVideo = forwardRef< useEffect(() => { if ( + !captionsDisabled && data.transcriptionStatus === "COMPLETE" && transcriptData && transcriptData.length > 0 @@ -125,10 +131,10 @@ export const EmbedVideo = forwardRef< if (prev) URL.revokeObjectURL(prev); return null; }); - }, [data.transcriptionStatus, transcriptData]); + }, [captionsDisabled, data.transcriptionStatus, transcriptData]); useEffect(() => { - if (chapters?.length > 0) { + if (!chaptersDisabled && chapters?.length > 0) { const vttContent = formatChaptersAsVTT(chapters); const blob = new Blob([vttContent], { type: "text/vtt" }); const newUrl = URL.createObjectURL(blob); @@ -144,7 +150,7 @@ export const EmbedVideo = forwardRef< if (prev) URL.revokeObjectURL(prev); return null; }); - }, [chapters]); + }, [chapters, chaptersDisabled]); const isMp4Source = data.source.type === "desktopMP4" || data.source.type === "webMP4"; @@ -237,8 +243,9 @@ export const EmbedVideo = forwardRef< rawFallbackSrc={rawFallbackSrc} duration={data.duration} showPlaybackStatusBadge={showPlaybackStatusBadge} - chaptersSrc={chaptersUrl || ""} - captionsSrc={subtitleUrl || ""} + disableCaptions={captionsDisabled} + chaptersSrc={chaptersDisabled ? "" : chaptersUrl || ""} + captionsSrc={captionsDisabled ? "" : subtitleUrl || ""} videoRef={videoRef} enableCrossOrigin={enableCrossOrigin} hasActiveUpload={data.hasActiveUpload} @@ -249,8 +256,9 @@ export const EmbedVideo = forwardRef< mediaPlayerClassName="w-full h-full" videoSrc={videoSrc} duration={data.duration} - chaptersSrc={chaptersUrl || ""} - captionsSrc={subtitleUrl || ""} + disableCaptions={captionsDisabled} + chaptersSrc={chaptersDisabled ? "" : chaptersUrl || ""} + captionsSrc={captionsDisabled ? "" : subtitleUrl || ""} videoRef={videoRef} hasActiveUpload={data.hasActiveUpload} isLiveSegments={isSegmentsSource} diff --git a/apps/web/app/embed/[videoId]/page.tsx b/apps/web/app/embed/[videoId]/page.tsx index 7b88dc53bae..5a8ca764faf 100644 --- a/apps/web/app/embed/[videoId]/page.tsx +++ b/apps/web/app/embed/[videoId]/page.tsx @@ -4,13 +4,20 @@ import { comments, organizations, sharedVideos, + spaces, + spaceVideos, users, videos, videoUploads, } from "@cap/database/schema"; import type { VideoMetadata } from "@cap/database/types"; import { buildEnv } from "@cap/env"; -import { provideOptionalAuth, Videos, VideosPolicy } from "@cap/web-backend"; +import { + provideOptionalAuth, + resolveEffectiveVideoRules, + Videos, + VideosPolicy, +} from "@cap/web-backend"; import { type Organisation, Policy, type Video } from "@cap/web-domain"; import { and, eq, isNull, sql } from "drizzle-orm"; import { Effect, Option } from "effect"; @@ -105,6 +112,19 @@ export async function generateMetadata( ); } +const renderEmbedPolicyDenied = () => + Effect.succeed( +
    +

    This video is private

    +

    + If you own this video, please sign in to + manage sharing. +

    +
    , + ); + +const renderNoSuchElement = () => Effect.sync(() => notFound()); + export default async function EmbedVideoPage( props: PageProps<"/embed/[videoId]">, ) { @@ -152,6 +172,7 @@ export default async function EmbedVideoPage( sharedOrganization: { organizationId: sharedVideos.organizationId, }, + orgSettings: organizations.settings, hasActiveUpload: sql`${videoUploads.videoId} IS NOT NULL`.mapWith( Boolean, ), @@ -171,7 +192,7 @@ export default async function EmbedVideoPage( Effect.succeed({ needsPassword: true } as const), ), Effect.map((data) => ( -
    +
    {!data.needsPassword && ( @@ -179,17 +200,8 @@ export default async function EmbedVideoPage(
    )), Effect.catchTags({ - PolicyDenied: () => - Effect.succeed( -
    -

    This video is private

    -

    - If you own this video, please sign in{" "} - to manage sharing. -

    -
    , - ), - NoSuchElementException: () => Effect.sync(() => notFound()), + PolicyDenied: renderEmbedPolicyDenied, + NoSuchElementException: renderNoSuchElement, }), provideOptionalAuth, EffectRuntime.runPromise, @@ -203,10 +215,27 @@ async function EmbedContent({ video: Omit & { sharedOrganization: { organizationId: Organisation.OrganisationId } | null; hasActiveUpload: boolean | undefined; + orgSettings?: (typeof organizations.$inferSelect)["settings"] | null; }; autoplay: boolean; }) { const user = await getCurrentUser(); + const sharedSpaces = await db() + .select({ + id: spaces.id, + name: spaces.name, + settings: spaces.settings, + hasPassword: sql`${spaces.password} IS NOT NULL`.mapWith(Boolean), + }) + .from(spaceVideos) + .innerJoin(spaces, eq(spaceVideos.spaceId, spaces.id)) + .where(eq(spaceVideos.videoId, video.id)); + + const rules = resolveEffectiveVideoRules({ + videoSettings: video.settings, + organizationSettings: video.orgSettings, + spaces: sharedSpaces, + }); let aiGenerationEnabled = false; const videoOwnerQuery = await db() @@ -225,6 +254,7 @@ async function EmbedContent({ } if ( + !rules.settings.disableTranscript && video.transcriptionStatus !== "COMPLETE" && video.transcriptionStatus !== "PROCESSING" && video.transcriptionStatus !== "SKIPPED" && @@ -286,9 +316,12 @@ async function EmbedContent({ data={video} user={user} comments={commentsQuery} - chapters={initialAiData?.chapters || []} + chapters={ + rules.settings.disableChapters ? [] : initialAiData?.chapters || [] + } ownerName={videoOwner[0]?.name || null} autoplay={autoplay} + viewerSettings={rules.settings} showPlaybackStatusBadge={user?.id === video.ownerId} /> ); diff --git a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx index ef4dc566ca8..28a89a5380f 100644 --- a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx +++ b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx @@ -2,6 +2,7 @@ import { buildEnv, NODE_ENV } from "@cap/env"; import { Button } from "@cap/ui"; +import type { ViewerSettingKey } from "@cap/web-backend"; import { faChartSimple, faChevronDown, @@ -42,12 +43,16 @@ export const ShareHeader = ({ name: string; iconUrl?: string; organizationId: string; + settings?: Partial> | null; + hasPassword?: boolean; }[]; userSpaces?: { id: string; name: string; iconUrl?: string; organizationId: string; + settings?: Partial> | null; + hasPassword?: boolean; }[]; spacesData?: Spaces[] | null; }) => { @@ -257,6 +262,7 @@ export const ShareHeader = ({ isPublic={data.public} spacesData={spacesData} hasPassword={!!data.hasPassword} + inheritedPasswordSources={data.inheritedPasswordSources} onPasswordUpdated={() => refresh()} user={user} onUpgradeRequest={setUpgradeModalOpen} @@ -276,12 +282,23 @@ export const ShareHeader = ({ /> ) : (

    { if (isOwner) { setIsEditing(true); } }} + onKeyDown={(event) => { + if ( + isOwner && + (event.key === "Enter" || event.key === " ") + ) { + event.preventDefault(); + setIsEditing(true); + } + }} > {title}

    @@ -312,7 +329,7 @@ export const ShareHeader = ({
    - {data.hasPassword && ( + {(data.hasPassword || data.hasInheritedPassword) && ( = []; // Add space-level sharing @@ -94,6 +99,8 @@ async function getSharedSpacesForVideo(videoId: Video.VideoId) { name: space.name, organizationId: space.organizationId, iconUrl: space.iconUrl || undefined, + settings: space.settings, + hasPassword: space.hasPassword, }); }); @@ -104,6 +111,8 @@ async function getSharedSpacesForVideo(videoId: Video.VideoId) { name: org.name, organizationId: org.organizationId, iconUrl: org.iconUrl || undefined, + settings: null, + hasPassword: false, }); }); @@ -445,6 +454,11 @@ async function AuthorizedContent({ // Fetch shared spaces data for this video const sharedSpaces = await getSharedSpacesForVideo(videoId); + const rules = resolveEffectiveVideoRules({ + videoSettings: video.videoSettings, + organizationSettings: video.orgSettings, + spaces: sharedSpaces.filter((space) => space.id !== space.organizationId), + }); let aiGenerationEnabled = false; const videoOwnerQuery = await db() @@ -463,6 +477,7 @@ async function AuthorizedContent({ } if ( + !rules.settings.disableTranscript && !video.hasActiveUpload && video.transcriptionStatus !== "COMPLETE" && video.transcriptionStatus !== "PROCESSING" && @@ -669,7 +684,10 @@ async function AuthorizedContent({ password: null, folderId: null, orgSettings: video.orgSettings || null, - settings: video.videoSettings || null, + settings: rules.settings, + hasInheritedPassword: rules.hasInheritedPassword, + inheritedPasswordSources: rules.inheritedPasswordSources, + inheritedSpaceSettings: rules.inheritedSettings, }; }).pipe(runPromise); diff --git a/apps/web/app/s/[videoId]/types.ts b/apps/web/app/s/[videoId]/types.ts index 6bd2536f800..bd6bf8778f7 100644 --- a/apps/web/app/s/[videoId]/types.ts +++ b/apps/web/app/s/[videoId]/types.ts @@ -1,4 +1,5 @@ import type { videos } from "@cap/database/schema"; +import type { SpaceRuleSource, ViewerSettingKey } from "@cap/web-backend"; import type { ImageUpload, Organisation, User } from "@cap/web-domain"; import type { OrganizationSettings } from "@/app/(org)/dashboard/dashboard-data"; @@ -8,6 +9,9 @@ export type VideoData = Omit & { organizationId?: Organisation.OrganisationId; sharedOrganizations?: { id: string; name: string }[]; hasPassword?: boolean; + hasInheritedPassword?: boolean; + inheritedPasswordSources?: SpaceRuleSource[]; + inheritedSpaceSettings?: Partial>; orgSettings?: OrganizationSettings | null; }; From 18d6145eb2e80c9c954f288553dfbcac9fe452f8 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 10 May 2026 22:44:51 +0100 Subject: [PATCH 15/18] test(web): add effective video rules unit coverage --- .../unit/effective-video-rules.test.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 apps/web/__tests__/unit/effective-video-rules.test.ts diff --git a/apps/web/__tests__/unit/effective-video-rules.test.ts b/apps/web/__tests__/unit/effective-video-rules.test.ts new file mode 100644 index 00000000000..838dcc1839a --- /dev/null +++ b/apps/web/__tests__/unit/effective-video-rules.test.ts @@ -0,0 +1,84 @@ +import { resolveEffectiveVideoRules } from "@cap/web-backend"; +import { describe, expect, it } from "vitest"; + +describe("resolveEffectiveVideoRules", () => { + it("uses space-disabled settings over video and organization settings", () => { + const rules = resolveEffectiveVideoRules({ + videoSettings: { disableComments: false }, + organizationSettings: { disableComments: false }, + spaces: [ + { + id: "space-1", + name: "Design", + settings: { disableComments: true }, + }, + ], + }); + + expect(rules.settings.disableComments).toBe(true); + expect(rules.inheritedSettings.disableComments).toEqual([ + { id: "space-1", name: "Design" }, + ]); + }); + + it("keeps the inherited setting disabled when multiple spaces conflict", () => { + const rules = resolveEffectiveVideoRules({ + videoSettings: { disableTranscript: false }, + organizationSettings: { disableTranscript: false }, + spaces: [ + { + id: "space-1", + name: "Design", + settings: { disableTranscript: false }, + }, + { + id: "space-2", + name: "Legal", + settings: { disableTranscript: true }, + }, + ], + }); + + expect(rules.settings.disableTranscript).toBe(true); + expect(rules.inheritedSettings.disableTranscript).toEqual([ + { id: "space-2", name: "Legal" }, + ]); + }); + + it("uses video settings before organization settings when there is no space rule", () => { + const rules = resolveEffectiveVideoRules({ + videoSettings: { disableCaptions: false }, + organizationSettings: { disableCaptions: true }, + spaces: [], + }); + + expect(rules.settings.disableCaptions).toBe(false); + expect(rules.inheritedSettings.disableCaptions).toBeUndefined(); + }); + + it("uses organization settings when video settings are unset", () => { + const rules = resolveEffectiveVideoRules({ + videoSettings: {}, + organizationSettings: { disableSummary: true }, + spaces: [], + }); + + expect(rules.settings.disableSummary).toBe(true); + }); + + it("reports inherited password sources", () => { + const rules = resolveEffectiveVideoRules({ + videoSettings: {}, + organizationSettings: {}, + spaces: [ + { id: "space-1", name: "Design", hasPassword: true }, + { id: "space-2", name: "Marketing", hasPassword: false }, + ], + }); + + expect(rules.hasInheritedPassword).toBe(true); + expect(rules.inheritedPasswordSources).toEqual([ + { id: "space-1", name: "Design" }, + ]); + }); +}); From 0d9678acea90cf86df2bbc89e4dd5d5401394d90 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 10 May 2026 22:44:51 +0100 Subject: [PATCH 16/18] test(web): cover space password handling in videos policy --- apps/web/__tests__/unit/videos-policy.test.ts | 92 ++++++++++++++++++- 1 file changed, 89 insertions(+), 3 deletions(-) diff --git a/apps/web/__tests__/unit/videos-policy.test.ts b/apps/web/__tests__/unit/videos-policy.test.ts index bbf4e9a064f..35e1147b07f 100644 --- a/apps/web/__tests__/unit/videos-policy.test.ts +++ b/apps/web/__tests__/unit/videos-policy.test.ts @@ -43,6 +43,7 @@ function makeVideo( function makeDeps(config: { video: Video.Video | null; password?: Option.Option; + spacePasswords?: string[]; orgMembership?: boolean; spaceMembership?: boolean; allowedEmailDomain?: Option.Option; @@ -50,6 +51,7 @@ function makeDeps(config: { const { video, password = Option.none(), + spacePasswords = [], orgMembership = false, spaceMembership = false, allowedEmailDomain = Option.none(), @@ -74,6 +76,8 @@ function makeDeps(config: { ? Option.some({ membershipId: "smem-1" }) : Option.none(), ), + passwordsForVideo: () => + Effect.succeed(spacePasswords.map((password) => ({ password }))), }, }; } @@ -81,7 +85,8 @@ function makeDeps(config: { function runCanView( deps: VideosPolicyDeps, user: Option.Option, -): Promise<"allowed" | "denied"> { + attachedPassword: Option.Option = Option.none(), +): Promise<"allowed" | "denied" | "password"> { const policy = buildCanView(deps, TEST_VIDEO_ID); const program = Effect.zipRight( @@ -89,12 +94,23 @@ function runCanView( Effect.succeed("allowed" as const), ).pipe( Effect.catchTag("PolicyDenied", () => Effect.succeed("denied" as const)), + Effect.catchTag("VerifyVideoPasswordError", () => + Effect.succeed("password" as const), + ), ); + const withPassword = Option.match(attachedPassword, { + onNone: () => program, + onSome: (password) => + Effect.provideService(program, Video.VideoPasswordAttachment, { + password: Option.some(password), + }), + }); + const withUser = user.pipe( Option.match({ - onNone: () => program, - onSome: (u) => Effect.provideService(program, CurrentUser, u), + onNone: () => withPassword, + onSome: (u) => Effect.provideService(withPassword, CurrentUser, u), }), ); @@ -135,6 +151,17 @@ describe("VideosPolicy.canView", () => { expect(await runCanView(deps, owner)).toBe("allowed"); }); + + it("does not load inherited passwords for owner bypass", async () => { + const deps = makeDeps({ + video: makeVideo({ public: false }), + }); + deps.spacesRepo.passwordsForVideo = () => + Effect.die(new Error("password lookup should not run")); + const owner = makeUser("owner@anything.com", TEST_OWNER_ID); + + expect(await runCanView(deps, owner)).toBe("allowed"); + }); }); describe("explicit org membership", () => { @@ -207,6 +234,65 @@ describe("VideosPolicy.canView", () => { }); }); + describe("inherited space passwords", () => { + it("requires a space password for anonymous public-link viewers", async () => { + const deps = makeDeps({ + video: makeVideo({ public: true }), + spacePasswords: ["space-hash"], + }); + + expect(await runCanView(deps, noUser)).toBe("password"); + }); + + it("requires a space password for space members", async () => { + const deps = makeDeps({ + video: makeVideo({ public: false }), + spaceMembership: true, + spacePasswords: ["space-hash"], + }); + + expect(await runCanView(deps, makeUser("member@company.com"))).toBe( + "password", + ); + }); + + it("allows the owner without an inherited password attachment", async () => { + const deps = makeDeps({ + video: makeVideo({ public: false }), + spacePasswords: ["space-hash"], + }); + const owner = makeUser("owner@anything.com", TEST_OWNER_ID); + + expect(await runCanView(deps, owner)).toBe("allowed"); + }); + + it("allows access with any inherited space password hash", async () => { + const deps = makeDeps({ + video: makeVideo({ public: true }), + spacePasswords: ["space-one-hash", "space-two-hash"], + }); + + expect( + await runCanView(deps, noUser, Option.some("space-two-hash")), + ).toBe("allowed"); + }); + + it("allows access with either video or space password hash", async () => { + const deps = makeDeps({ + video: makeVideo({ public: true }), + password: Option.some("video-hash"), + spacePasswords: ["space-hash"], + }); + + expect(await runCanView(deps, noUser, Option.some("video-hash"))).toBe( + "allowed", + ); + expect(await runCanView(deps, noUser, Option.some("space-hash"))).toBe( + "allowed", + ); + }); + }); + describe("private video without membership", () => { it("denies logged-in user without membership", async () => { const deps = makeDeps({ From df3efeab414360ab1523c89a53d2184fbf4301dd Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 13:00:56 +0100 Subject: [PATCH 17/18] fix: address space rule review feedback --- apps/web/actions/organization/create-space.ts | 32 +++------------ .../actions/organization/space-settings.ts | 36 +++++++++++++++++ apps/web/actions/organization/update-space.ts | 40 ++++--------------- apps/web/app/(org)/dashboard/caps/page.tsx | 10 ++++- .../(org)/dashboard/spaces/[spaceId]/page.tsx | 16 +++++++- apps/web/lib/folder.ts | 25 +++++++++++- 6 files changed, 95 insertions(+), 64 deletions(-) create mode 100644 apps/web/actions/organization/space-settings.ts diff --git a/apps/web/actions/organization/create-space.ts b/apps/web/actions/organization/create-space.ts index 13f519de7b7..132bb383f57 100644 --- a/apps/web/actions/organization/create-space.ts +++ b/apps/web/actions/organization/create-space.ts @@ -15,32 +15,12 @@ import { } from "@cap/web-domain"; import { and, eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { + getSpaceSettingsFromFormData, + hasProSpaceSettingsEnabled, +} from "./space-settings"; import { uploadSpaceIcon } from "./upload-space-icon"; -const settingKeys = [ - "disableSummary", - "disableCaptions", - "disableChapters", - "disableReactions", - "disableTranscript", - "disableComments", -] as const; - -const getSettingsFromFormData = (formData: FormData) => - Object.fromEntries( - settingKeys.map((key) => [key, formData.get(key) === "true"]), - ); - -const proSettingKeys = [ - "disableSummary", - "disableChapters", - "disableTranscript", -] as const; - -const hasProSettingsEnabled = ( - settings: ReturnType, -) => proSettingKeys.some((key) => settings[key]); - interface CreateSpaceResponse { success: boolean; spaceId?: string; @@ -65,7 +45,7 @@ export async function createSpace( const name = formData.get("name") as string; const passwordEnabled = formData.get("passwordEnabled") === "true"; const password = formData.get("password") as string | null; - const settings = getSettingsFromFormData(formData); + const settings = getSpaceSettingsFromFormData(formData); const canUseProFeatures = userIsPro(user); if (!name) { @@ -89,7 +69,7 @@ export async function createSpace( }; } - if (!canUseProFeatures && hasProSettingsEnabled(settings)) { + if (!canUseProFeatures && hasProSpaceSettingsEnabled(settings)) { return { success: false, error: "Upgrade required to change these viewer rules", diff --git a/apps/web/actions/organization/space-settings.ts b/apps/web/actions/organization/space-settings.ts new file mode 100644 index 00000000000..22627f7b841 --- /dev/null +++ b/apps/web/actions/organization/space-settings.ts @@ -0,0 +1,36 @@ +export const spaceSettingKeys = [ + "disableSummary", + "disableCaptions", + "disableChapters", + "disableReactions", + "disableTranscript", + "disableComments", +] as const; + +export type SpaceSettingKey = (typeof spaceSettingKeys)[number]; +export type SpaceSettings = Partial>; + +export const proSpaceSettingKeys = [ + "disableSummary", + "disableChapters", + "disableTranscript", +] as const; + +export const getSpaceSettingsFromFormData = (formData: FormData) => + Object.fromEntries( + spaceSettingKeys.map((key) => [key, formData.get(key) === "true"]), + ) as Record; + +export const hasProSpaceSettingsEnabled = ( + settings: Record, +) => proSpaceSettingKeys.some((key) => settings[key]); + +export const preserveProSpaceSettings = ( + submittedSettings: Record, + existingSettings: SpaceSettings | null | undefined, +) => ({ + ...submittedSettings, + ...Object.fromEntries( + proSpaceSettingKeys.map((key) => [key, existingSettings?.[key] ?? false]), + ), +}); diff --git a/apps/web/actions/organization/update-space.ts b/apps/web/actions/organization/update-space.ts index d8232ca4c3a..7af0039075e 100644 --- a/apps/web/actions/organization/update-space.ts +++ b/apps/web/actions/organization/update-space.ts @@ -17,38 +17,12 @@ import { and, eq } from "drizzle-orm"; import { Effect, Option } from "effect"; import { revalidatePath } from "next/cache"; import { runPromise } from "@/lib/server"; +import { + getSpaceSettingsFromFormData, + preserveProSpaceSettings, +} from "./space-settings"; import { uploadSpaceIcon } from "./upload-space-icon"; -const settingKeys = [ - "disableSummary", - "disableCaptions", - "disableChapters", - "disableReactions", - "disableTranscript", - "disableComments", -] as const; - -const getSettingsFromFormData = (formData: FormData) => - Object.fromEntries( - settingKeys.map((key) => [key, formData.get(key) === "true"]), - ); - -const proSettingKeys = [ - "disableSummary", - "disableChapters", - "disableTranscript", -] as const; - -const preserveProSettings = ( - submittedSettings: ReturnType, - existingSettings: (typeof spaces.$inferSelect)["settings"], -) => ({ - ...submittedSettings, - ...Object.fromEntries( - proSettingKeys.map((key) => [key, existingSettings?.[key] ?? false]), - ), -}); - export async function updateSpace(formData: FormData) { const user = await getCurrentUser(); if (!user) return { success: false, error: "Unauthorized" }; @@ -89,14 +63,14 @@ export async function updateSpace(formData: FormData) { return { success: false, error: "Unauthorized" }; } - const submittedSettings = getSettingsFromFormData(formData); + const submittedSettings = getSpaceSettingsFromFormData(formData); const canUseProFeatures = userIsPro(user); const settings = canUseProFeatures ? submittedSettings - : preserveProSettings(submittedSettings, space.settings); + : preserveProSpaceSettings(submittedSettings, space.settings); const spaceUpdate: { name: string; - settings: ReturnType; + settings: ReturnType; password?: string | null; } = { name, settings }; diff --git a/apps/web/app/(org)/dashboard/caps/page.tsx b/apps/web/app/(org)/dashboard/caps/page.tsx index 8d5681c4ae7..91a52c00ed7 100644 --- a/apps/web/app/(org)/dashboard/caps/page.tsx +++ b/apps/web/app/(org)/dashboard/caps/page.tsx @@ -241,6 +241,14 @@ export default async function CapsPage(props: PageProps<"/dashboard/caps">) { const videoIds = videoData.map((video) => video.id); const sharedSpacesMap = await getSharedSpacesForVideos(videoIds).pipe(runPromise); + const [organizationSettingsRow] = user.activeOrganizationId + ? await db() + .select({ settings: organizations.settings }) + .from(organizations) + .where(eq(organizations.id, user.activeOrganizationId)) + .limit(1) + : []; + const organizationSettings = organizationSettingsRow?.settings ?? null; const processedVideoData = await Effect.all( videoData.map( @@ -252,7 +260,7 @@ export default async function CapsPage(props: PageProps<"/dashboard/caps">) { const sharedSpaces = sharedSpacesMap[video.id] ?? []; const rules = resolveEffectiveVideoRules({ videoSettings: video.settings, - organizationSettings: null, + organizationSettings, spaces: sharedSpaces.filter((space) => !space.isOrg), }); diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx index e4d1727d3c9..851168e2fbf 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx @@ -307,13 +307,19 @@ export default async function SharedCapsPage(props: { const sharedSpacesMap = await fetchSharedSpacesForVideos( spaceVideoData.map((video) => Video.VideoId.make(video.id)), ); + const [organizationSettingsRow] = await db() + .select({ settings: organizations.settings }) + .from(organizations) + .where(eq(organizations.id, space.organizationId)) + .limit(1); + const organizationSettings = organizationSettingsRow?.settings ?? null; const processedVideoData = spaceVideoData.map((video) => { const { effectiveDate: _effectiveDate, ...videoWithoutEffectiveDate } = video; const sharedSpaces = sharedSpacesMap[video.id] ?? []; const rules = resolveEffectiveVideoRules({ videoSettings: video.settings, - organizationSettings: null, + organizationSettings, spaces: sharedSpaces, }); return { @@ -432,13 +438,19 @@ export default async function SharedCapsPage(props: { const sharedSpacesMap = await fetchSharedSpacesForVideos( orgVideoData.map((video) => Video.VideoId.make(video.id)), ); + const [organizationSettingsRow] = await db() + .select({ settings: organizations.settings }) + .from(organizations) + .where(eq(organizations.id, organization.id)) + .limit(1); + const organizationSettings = organizationSettingsRow?.settings ?? null; const processedVideoData = orgVideoData.map((video) => { const { effectiveDate: _effectiveDate, ...videoWithoutEffectiveDate } = video; const sharedSpaces = sharedSpacesMap[video.id] ?? []; const rules = resolveEffectiveVideoRules({ videoSettings: video.settings, - organizationSettings: null, + organizationSettings, spaces: sharedSpaces, }); return { diff --git a/apps/web/lib/folder.ts b/apps/web/lib/folder.ts index a0aa5c1a89b..f855a36b1ec 100644 --- a/apps/web/lib/folder.ts +++ b/apps/web/lib/folder.ts @@ -18,7 +18,7 @@ import { } from "@cap/web-backend"; import type { ImageUpload, Organisation, Space, Video } from "@cap/web-domain"; import { CurrentUser, Folder } from "@cap/web-domain"; -import { and, desc, eq, isNull } from "drizzle-orm"; +import { and, desc, eq, inArray, isNull } from "drizzle-orm"; import { sql } from "drizzle-orm/sql"; import { Effect } from "effect"; @@ -188,6 +188,7 @@ export const getVideosByFolderId = Effect.fn(function* ( metadata: videos.metadata, duration: videos.duration, settings: videos.settings, + orgId: videos.orgId, totalComments: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'text' THEN ${comments.id} END)`, totalReactions: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'emoji' THEN ${comments.id} END)`, sharedOrganizations: sql< @@ -248,6 +249,7 @@ export const getVideosByFolderId = Effect.fn(function* ( videos.metadata, videos.duration, videos.settings, + videos.orgId, videos.password, users.name, ) @@ -257,6 +259,25 @@ export const getVideosByFolderId = Effect.fn(function* ( // Fetch shared spaces data for all videos const videoIds = videoData.map((video) => video.id); const sharedSpacesMap = yield* getSharedSpacesForVideos(videoIds); + const orgIds = Array.from(new Set(videoData.map((video) => video.orgId))); + const organizationSettingsRows = + orgIds.length > 0 + ? yield* db.use((db) => + db + .select({ + id: organizations.id, + settings: organizations.settings, + }) + .from(organizations) + .where(inArray(organizations.id, orgIds)), + ) + : []; + const organizationSettingsById = Object.fromEntries( + organizationSettingsRows.map((organization) => [ + organization.id, + organization.settings, + ]), + ); // Process the video data to match the expected format const processedVideoData = yield* Effect.all( @@ -265,7 +286,7 @@ export const getVideosByFolderId = Effect.fn(function* ( const sharedSpaces = sharedSpacesMap[video.id] ?? []; const rules = resolveEffectiveVideoRules({ videoSettings: video.settings, - organizationSettings: null, + organizationSettings: organizationSettingsById[video.orgId] ?? null, spaces: sharedSpaces.filter((space) => !space.isOrg), }); const resolvedSharedSpaces = yield* Effect.all( From 41d4ee5239e7b10850c85b5dacf853a1e0b0da00 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 13:08:33 +0100 Subject: [PATCH 18/18] fix: restore typecheck setup --- package.json | 2 +- pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 6d96a8c7ec4..3b8398e5f24 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "format": "pnpm exec biome check --write", "lint": "pnpm exec biome lint", "tauri:build": "node scripts/build-cap-muxer.mjs && dotenv -e .env -- pnpm --dir apps/desktop tauri build --verbose", - "typecheck": "pnpm tsc -b", + "typecheck": "pnpm --dir apps/web exec next typegen && pnpm tsc -b", "test": "turbo run test", "test:web": "pnpm --filter=@cap/web test", "test:discover": "pnpm with-env cargo run -p cap-test -- discover", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb43a78d38c..3fae818d59d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -802,6 +802,9 @@ importers: autoprefixer: specifier: ^10.4.14 version: 10.4.21(postcss@8.5.3) + cross-env: + specifier: ^7.0.3 + version: 7.0.3 eslint: specifier: ^9.30.1 version: 9.30.1(jiti@2.6.1)