From d0dc2eabd056bc77b1a9b6ddfef14cc1ae5c85f2 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 15 May 2026 17:00:33 +0100 Subject: [PATCH 01/35] feat(db): add shareable link icon column and migration --- .../migrations/0025_demonic_mother_askani.sql | 1 + .../migrations/meta/0025_snapshot.json | 3564 +++++++++++++++++ .../database/migrations/meta/_journal.json | 367 +- packages/database/schema.ts | 9 +- 4 files changed, 3759 insertions(+), 182 deletions(-) create mode 100644 packages/database/migrations/0025_demonic_mother_askani.sql create mode 100644 packages/database/migrations/meta/0025_snapshot.json diff --git a/packages/database/migrations/0025_demonic_mother_askani.sql b/packages/database/migrations/0025_demonic_mother_askani.sql new file mode 100644 index 0000000000..a2435b7405 --- /dev/null +++ b/packages/database/migrations/0025_demonic_mother_askani.sql @@ -0,0 +1 @@ +ALTER TABLE `organizations` ADD `shareableLinkIconUrl` varchar(1024); \ No newline at end of file diff --git a/packages/database/migrations/meta/0025_snapshot.json b/packages/database/migrations/meta/0025_snapshot.json new file mode 100644 index 0000000000..dbfe6ec8e6 --- /dev/null +++ b/packages/database/migrations/meta/0025_snapshot.json @@ -0,0 +1,3564 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "0df93b03-9847-42d2-b71f-9d0cbc968216", + "prevId": "ae365232-4098-4426-a0ba-b8b6aa46fb3b", + "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 + }, + "shareableLinkIconUrl": { + "name": "shareableLinkIconUrl", + "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_edits": { + "name": "video_edits", + "columns": { + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sourceKey": { + "name": "sourceKey", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "editSpec": { + "name": "editSpec", + "type": "json", + "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())" + } + }, + "indexes": {}, + "foreignKeys": { + "video_edits_videoId_videos_id_fk": { + "name": "video_edits_videoId_videos_id_fk", + "tableFrom": "video_edits", + "tableTo": "videos", + "columnsFrom": [ + "videoId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "video_edits_videoId": { + "name": "video_edits_videoId", + "columns": [ + "videoId" + ] + } + }, + "uniqueConstraints": {}, + "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": {} + } +} \ No newline at end of file diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index 77f38a7070..75e11c36a2 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -1,181 +1,188 @@ { - "version": "5", - "dialect": "mysql", - "entries": [ - { - "idx": 0, - "version": "5", - "when": 1743020179593, - "tag": "0000_brown_sunfire", - "breakpoints": true - }, - { - "idx": 1, - "version": "5", - "when": 1749268354138, - "tag": "0001_white_young_avengers", - "breakpoints": true - }, - { - "idx": 2, - "version": "5", - "when": 1750935538683, - "tag": "0002_dusty_maginty", - "breakpoints": true - }, - { - "idx": 3, - "version": "5", - "when": 1761710697286, - "tag": "0003_thin_gressill", - "breakpoints": true - }, - { - "idx": 4, - "version": "5", - "when": 1761711378574, - "tag": "0004_video-org-id", - "breakpoints": true - }, - { - "idx": 5, - "version": "5", - "when": 1761711605408, - "tag": "0005_video-org-id-required", - "breakpoints": true - }, - { - "idx": 6, - "version": "5", - "when": 1762410865419, - "tag": "0006_hesitant_stone_men", - "breakpoints": true - }, - { - "idx": 7, - "version": "5", - "when": 1762428551824, - "tag": "0007_public_toxin", - "breakpoints": true - }, - { - "idx": 8, - "version": "5", - "when": 1762428905323, - "tag": "0008_fat_ender_wiggin", - "breakpoints": true - }, - { - "idx": 9, - "version": "5", - "when": 1768139432782, - "tag": "0009_easy_ulik", - "breakpoints": true - }, - { - "idx": 10, - "version": "5", - "when": 1738505600000, - "tag": "0010_video_uploads_bigint", - "breakpoints": true - }, - { - "idx": 11, - "version": "5", - "when": 1771325011010, - "tag": "0011_public_inhumans", - "breakpoints": true - }, - { - "idx": 12, - "version": "5", - "when": 1772534950328, - "tag": "0012_lethal_lilith", - "breakpoints": true - }, - { - "idx": 13, - "version": "5", - "when": 1772573402457, - "tag": "0013_faithful_marvex", - "breakpoints": true - }, - { - "idx": 14, - "version": "5", - "when": 1772576220593, - "tag": "0014_modern_triton", - "breakpoints": true - }, - { - "idx": 15, - "version": "5", - "when": 1772582468257, - "tag": "0015_closed_tigra", - "breakpoints": true - }, - { - "idx": 16, - "version": "5", - "when": 1772641549840, - "tag": "0016_bouncy_hellcat", - "breakpoints": true - }, - { - "idx": 17, - "version": "5", - "when": 1778099532336, - "tag": "0017_productive_betty_brant", - "breakpoints": true - }, - { - "idx": 18, - "version": "5", - "when": 1778153157657, - "tag": "0018_loud_mongu", - "breakpoints": true - }, - { - "idx": 19, - "version": "5", - "when": 1778154209547, - "tag": "0019_solid_paibok", - "breakpoints": true - }, - { - "idx": 20, - "version": "5", - "when": 1778157016497, - "tag": "0020_orange_talkback", - "breakpoints": true - }, - { - "idx": 21, - "version": "5", - "when": 1778249922872, - "tag": "0021_glorious_khan", - "breakpoints": true - }, - { - "idx": 22, - "version": "5", - "when": 1778252207674, - "tag": "0022_dazzling_namor", - "breakpoints": true - }, - { - "idx": 23, - "version": "5", - "when": 1778434170430, - "tag": "0023_misty_luckman", - "breakpoints": true - }, - { - "idx": 24, - "version": "5", - "when": 1778776694053, - "tag": "0024_many_speed", - "breakpoints": true - } - ] -} + "version": "5", + "dialect": "mysql", + "entries": [ + { + "idx": 0, + "version": "5", + "when": 1743020179593, + "tag": "0000_brown_sunfire", + "breakpoints": true + }, + { + "idx": 1, + "version": "5", + "when": 1749268354138, + "tag": "0001_white_young_avengers", + "breakpoints": true + }, + { + "idx": 2, + "version": "5", + "when": 1750935538683, + "tag": "0002_dusty_maginty", + "breakpoints": true + }, + { + "idx": 3, + "version": "5", + "when": 1761710697286, + "tag": "0003_thin_gressill", + "breakpoints": true + }, + { + "idx": 4, + "version": "5", + "when": 1761711378574, + "tag": "0004_video-org-id", + "breakpoints": true + }, + { + "idx": 5, + "version": "5", + "when": 1761711605408, + "tag": "0005_video-org-id-required", + "breakpoints": true + }, + { + "idx": 6, + "version": "5", + "when": 1762410865419, + "tag": "0006_hesitant_stone_men", + "breakpoints": true + }, + { + "idx": 7, + "version": "5", + "when": 1762428551824, + "tag": "0007_public_toxin", + "breakpoints": true + }, + { + "idx": 8, + "version": "5", + "when": 1762428905323, + "tag": "0008_fat_ender_wiggin", + "breakpoints": true + }, + { + "idx": 9, + "version": "5", + "when": 1768139432782, + "tag": "0009_easy_ulik", + "breakpoints": true + }, + { + "idx": 10, + "version": "5", + "when": 1738505600000, + "tag": "0010_video_uploads_bigint", + "breakpoints": true + }, + { + "idx": 11, + "version": "5", + "when": 1771325011010, + "tag": "0011_public_inhumans", + "breakpoints": true + }, + { + "idx": 12, + "version": "5", + "when": 1772534950328, + "tag": "0012_lethal_lilith", + "breakpoints": true + }, + { + "idx": 13, + "version": "5", + "when": 1772573402457, + "tag": "0013_faithful_marvex", + "breakpoints": true + }, + { + "idx": 14, + "version": "5", + "when": 1772576220593, + "tag": "0014_modern_triton", + "breakpoints": true + }, + { + "idx": 15, + "version": "5", + "when": 1772582468257, + "tag": "0015_closed_tigra", + "breakpoints": true + }, + { + "idx": 16, + "version": "5", + "when": 1772641549840, + "tag": "0016_bouncy_hellcat", + "breakpoints": true + }, + { + "idx": 17, + "version": "5", + "when": 1778099532336, + "tag": "0017_productive_betty_brant", + "breakpoints": true + }, + { + "idx": 18, + "version": "5", + "when": 1778153157657, + "tag": "0018_loud_mongu", + "breakpoints": true + }, + { + "idx": 19, + "version": "5", + "when": 1778154209547, + "tag": "0019_solid_paibok", + "breakpoints": true + }, + { + "idx": 20, + "version": "5", + "when": 1778157016497, + "tag": "0020_orange_talkback", + "breakpoints": true + }, + { + "idx": 21, + "version": "5", + "when": 1778249922872, + "tag": "0021_glorious_khan", + "breakpoints": true + }, + { + "idx": 22, + "version": "5", + "when": 1778252207674, + "tag": "0022_dazzling_namor", + "breakpoints": true + }, + { + "idx": 23, + "version": "5", + "when": 1778434170430, + "tag": "0023_misty_luckman", + "breakpoints": true + }, + { + "idx": 24, + "version": "5", + "when": 1778776694053, + "tag": "0024_many_speed", + "breakpoints": true + }, + { + "idx": 25, + "version": "5", + "when": 1778858762935, + "tag": "0025_demonic_mother_askani", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/packages/database/schema.ts b/packages/database/schema.ts index 4ee0cfe958..ce7778766d 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -200,10 +200,15 @@ export const organizations = mysqlTable( disableReactions?: boolean; disableTranscript?: boolean; disableComments?: boolean; + hideShareableLinkCapLogo?: boolean; + shareableLinkUseOrganizationIcon?: boolean; }>(), iconUrl: varchar("iconUrl", { length: 1024, }).$type(), + shareableLinkIconUrl: varchar("shareableLinkIconUrl", { + length: 1024, + }).$type(), createdAt: timestamp("createdAt").notNull().defaultNow(), updatedAt: timestamp("updatedAt").notNull().defaultNow().onUpdateNow(), workosOrganizationId: varchar("workosOrganizationId", { length: 255 }), @@ -218,7 +223,7 @@ export const organizations = mysqlTable( }), ); -export type OrganisationMemberRole = "owner" | "member"; +export type OrganisationMemberRole = "owner" | "admin" | "member"; export const organizationMembers = mysqlTable( "organization_members", { @@ -963,7 +968,7 @@ export const spaceMembers = mysqlTable( role: varchar("role", { length: 255 }) .notNull() .default("member") - .$type<"member" | "Admin">(), + .$type<"admin" | "member">(), createdAt: timestamp("createdAt").notNull().defaultNow(), updatedAt: timestamp("updatedAt").notNull().defaultNow().onUpdateNow(), }, From bd3fc6b257428179ea94a1364ebddb7172994189 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 15 May 2026 17:00:33 +0100 Subject: [PATCH 02/35] feat(web): add organization and space role permission helpers --- apps/web/lib/permissions/roles.ts | 240 ++++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 apps/web/lib/permissions/roles.ts diff --git a/apps/web/lib/permissions/roles.ts b/apps/web/lib/permissions/roles.ts new file mode 100644 index 0000000000..2740681a1a --- /dev/null +++ b/apps/web/lib/permissions/roles.ts @@ -0,0 +1,240 @@ +export const organizationRoles = ["owner", "admin", "member"] as const; +export type OrganizationRole = (typeof organizationRoles)[number]; + +export const assignableOrganizationRoles = ["admin", "member"] as const; +export type AssignableOrganizationRole = + (typeof assignableOrganizationRoles)[number]; + +export const spaceRoles = ["admin", "member"] as const; +export type SpaceRole = (typeof spaceRoles)[number]; +export type PersistedSpaceRole = SpaceRole | "Admin"; + +const organizationRoleRank: Record = { + owner: 3, + admin: 2, + member: 1, +}; + +const spaceRoleRank: Record = { + admin: 2, + member: 1, +}; + +function includesValue( + values: T, + value: string, +): value is T[number] { + return values.includes(value); +} + +export function normalizeOrganizationRole( + role: string | null | undefined, +): OrganizationRole | null { + if (!role) return null; + const normalized = role.toLowerCase(); + return includesValue(organizationRoles, normalized) ? normalized : null; +} + +export function normalizeAssignableOrganizationRole( + role: string | null | undefined, +): AssignableOrganizationRole | null { + if (!role) return null; + const normalized = role.toLowerCase(); + return includesValue(assignableOrganizationRoles, normalized) + ? normalized + : null; +} + +export function getEffectiveOrganizationRole({ + userId, + ownerId, + memberRole, +}: { + userId: string | null | undefined; + ownerId: string | null | undefined; + memberRole: string | null | undefined; +}): OrganizationRole | null { + if (userId && ownerId && userId === ownerId) return "owner"; + const role = normalizeOrganizationRole(memberRole); + return role === "owner" ? "member" : role; +} + +export function normalizeSpaceRole( + role: string | null | undefined, +): SpaceRole | null { + if (!role) return null; + if (role === "Admin") return "admin"; + const normalized = role.toLowerCase(); + return includesValue(spaceRoles, normalized) ? normalized : null; +} + +export function getEffectiveSpaceRole({ + userId, + createdById, + memberRole, +}: { + userId: string | null | undefined; + createdById: string | null | undefined; + memberRole: string | null | undefined; +}): SpaceRole | null { + if (userId && createdById && userId === createdById) return "admin"; + return normalizeSpaceRole(memberRole); +} + +export function canViewOrganizationSettings( + role: OrganizationRole | null | undefined, +) { + return role === "owner" || role === "admin"; +} + +export function canManageOrganizationMembers( + role: OrganizationRole | null | undefined, +) { + return role === "owner" || role === "admin"; +} + +export function canManageOrganizationBilling( + role: OrganizationRole | null | undefined, +) { + return role === "owner"; +} + +export function canManageOrganizationSettings( + role: OrganizationRole | null | undefined, +) { + return role === "owner" || role === "admin"; +} + +export function isOrganizationOwnerTarget({ + targetUserId, + ownerId, + targetRole, +}: { + targetUserId: string | null | undefined; + ownerId: string | null | undefined; + targetRole: OrganizationRole | null | undefined; +}) { + return targetRole === "owner" || (!!targetUserId && targetUserId === ownerId); +} + +export function canChangeOrganizationMemberRole({ + actorRole, + actorUserId, + targetUserId, + ownerId, + targetRole, + nextRole, +}: { + actorRole: OrganizationRole | null | undefined; + actorUserId: string | null | undefined; + targetUserId: string | null | undefined; + ownerId: string | null | undefined; + targetRole: OrganizationRole | null | undefined; + nextRole: AssignableOrganizationRole | null | undefined; +}) { + if (!canManageOrganizationMembers(actorRole)) return false; + if (!nextRole) return false; + if (isOrganizationOwnerTarget({ targetUserId, ownerId, targetRole })) { + return false; + } + if (actorUserId && targetUserId && actorUserId === targetUserId) return false; + return true; +} + +export function canRemoveOrganizationMember({ + actorRole, + actorUserId, + targetUserId, + ownerId, + targetRole, +}: { + actorRole: OrganizationRole | null | undefined; + actorUserId: string | null | undefined; + targetUserId: string | null | undefined; + ownerId: string | null | undefined; + targetRole: OrganizationRole | null | undefined; +}) { + if (!canManageOrganizationMembers(actorRole)) return false; + if (isOrganizationOwnerTarget({ targetUserId, ownerId, targetRole })) { + return false; + } + if (actorUserId && targetUserId && actorUserId === targetUserId) return false; + return true; +} + +export function canManageSpace({ + organizationRole, + spaceRole, +}: { + organizationRole: OrganizationRole | null | undefined; + spaceRole: SpaceRole | null | undefined; +}) { + return ( + organizationRole === "owner" || + organizationRole === "admin" || + spaceRole === "admin" + ); +} + +export function canChangeSpaceMemberRole({ + canManage, + targetUserId, + createdById, + nextRole, +}: { + canManage: boolean; + targetUserId: string | null | undefined; + createdById: string | null | undefined; + nextRole: SpaceRole | null | undefined; +}) { + if (!canManage) return false; + if (!nextRole) return false; + if (targetUserId && createdById && targetUserId === createdById) return false; + return true; +} + +export function canRemoveSpaceMember({ + canManage, + targetUserId, + createdById, +}: { + canManage: boolean; + targetUserId: string | null | undefined; + createdById: string | null | undefined; +}) { + if (!canManage) return false; + if (targetUserId && createdById && targetUserId === createdById) return false; + return true; +} + +export function organizationRoleLabel(role: OrganizationRole) { + return role[0]?.toUpperCase() + role.slice(1); +} + +export function spaceRoleLabel(role: SpaceRole) { + return role[0]?.toUpperCase() + role.slice(1); +} + +export function compareOrganizationRoles( + a: string | null | undefined, + b: string | null | undefined, +) { + const roleA = normalizeOrganizationRole(a); + const roleB = normalizeOrganizationRole(b); + return ( + (organizationRoleRank[roleB ?? "member"] ?? 0) - + (organizationRoleRank[roleA ?? "member"] ?? 0) + ); +} + +export function compareSpaceRoles( + a: string | null | undefined, + b: string | null | undefined, +) { + const roleA = normalizeSpaceRole(a); + const roleB = normalizeSpaceRole(b); + return ( + (spaceRoleRank[roleB ?? "member"] ?? 0) - + (spaceRoleRank[roleA ?? "member"] ?? 0) + ); +} From 82a9c92d0256f93a0bab8e4d95f721d7f135129a Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 15 May 2026 17:00:33 +0100 Subject: [PATCH 03/35] feat(web-domain): normalize space admin member role casing --- packages/web-domain/src/Space.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web-domain/src/Space.ts b/packages/web-domain/src/Space.ts index 5ed4580d40..61202876d4 100644 --- a/packages/web-domain/src/Space.ts +++ b/packages/web-domain/src/Space.ts @@ -11,7 +11,7 @@ export const SpaceMemberId = Schema.String.pipe(Schema.brand("SpaceMemberId")); export type SpaceMemberId = typeof SpaceMemberId.Type; export const SpaceMemberRole = Schema.Union( - Schema.Literal("Admin"), + Schema.Literal("admin"), Schema.Literal("member"), ); export type SpaceMemberRole = typeof SpaceMemberRole.Type; From 98630d8b4e9ed016847546d80a8ae75d5afb20c8 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 15 May 2026 17:00:33 +0100 Subject: [PATCH 04/35] feat(web-api-contract): widen desktop organization role schema --- packages/web-api-contract/src/desktop.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web-api-contract/src/desktop.ts b/packages/web-api-contract/src/desktop.ts index 6c0bfd4602..990245ef9a 100644 --- a/packages/web-api-contract/src/desktop.ts +++ b/packages/web-api-contract/src/desktop.ts @@ -15,7 +15,7 @@ export const DesktopOrganization = z.object({ id: z.string(), name: z.string(), ownerId: z.string(), - role: z.enum(["owner", "member"]), + role: z.enum(["owner", "admin", "member"]), canEditBrand: z.boolean(), iconUrl: z.string().nullable(), brandColors: OrganizationBrandColors, From 06d15a7de518ca52ee59de064929a5bf6c37e29b Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 15 May 2026 17:00:33 +0100 Subject: [PATCH 05/35] feat(web-backend): add organization admin policy and membership lookups --- .../src/Organisations/OrganisationsPolicy.ts | 14 +++++++- .../src/Organisations/OrganisationsRepo.ts | 35 ++++++++++++++++--- .../web-backend/src/Organisations/index.ts | 4 +-- 3 files changed, 45 insertions(+), 8 deletions(-) diff --git a/packages/web-backend/src/Organisations/OrganisationsPolicy.ts b/packages/web-backend/src/Organisations/OrganisationsPolicy.ts index da7a34e928..fb2203e203 100644 --- a/packages/web-backend/src/Organisations/OrganisationsPolicy.ts +++ b/packages/web-backend/src/Organisations/OrganisationsPolicy.ts @@ -28,7 +28,19 @@ export class OrganisationsPolicy extends Effect.Service()( ), ); - return { isMember, isOwner }; + const isAdminOrOwner = (orgId: Organisation.OrganisationId) => + Policy.policy((user) => + repo.membership(user.id, orgId).pipe( + Effect.map((v) => + v.pipe( + Option.filter((v) => v.role === "owner" || v.role === "admin"), + Option.isSome, + ), + ), + ), + ); + + return { isMember, isOwner, isAdminOrOwner }; }), dependencies: [ OrganisationsRepo.Default, diff --git a/packages/web-backend/src/Organisations/OrganisationsRepo.ts b/packages/web-backend/src/Organisations/OrganisationsRepo.ts index 73c75ccc17..19db188436 100644 --- a/packages/web-backend/src/Organisations/OrganisationsRepo.ts +++ b/packages/web-backend/src/Organisations/OrganisationsRepo.ts @@ -37,17 +37,44 @@ export class OrganisationsRepo extends Effect.Service()( db .select({ membershipId: Db.organizationMembers.id, + ownerId: Db.organizations.ownerId, role: Db.organizationMembers.role, }) - .from(Db.organizationMembers) - .where( + .from(Db.organizations) + .leftJoin( + Db.organizationMembers, Dz.and( + Dz.eq( + Db.organizationMembers.organizationId, + Db.organizations.id, + ), Dz.eq(Db.organizationMembers.userId, userId), - Dz.eq(Db.organizationMembers.organizationId, orgId), + ), + ) + .where( + Dz.and( + Dz.eq(Db.organizations.id, orgId), + Dz.or( + Dz.eq(Db.organizations.ownerId, userId), + Dz.eq(Db.organizationMembers.userId, userId), + ), ), ), ) - .pipe(Effect.map(Array.get(0))), + .pipe( + Effect.map(Array.get(0)), + Effect.map( + Option.map((row) => ({ + membershipId: row.membershipId, + role: + row.ownerId === userId + ? ("owner" as const) + : row.role === "owner" + ? ("member" as const) + : row.role, + })), + ), + ), allowedEmailDomain: (orgId: Organisation.OrganisationId) => db .use((db) => diff --git a/packages/web-backend/src/Organisations/index.ts b/packages/web-backend/src/Organisations/index.ts index 0e6c22bc20..7ebf594bc8 100644 --- a/packages/web-backend/src/Organisations/index.ts +++ b/packages/web-backend/src/Organisations/index.ts @@ -31,7 +31,7 @@ export class Organisations extends Effect.Service()( "NoSuchElementException", () => new Organisation.NotFoundError(), ), - Policy.withPolicy(policy.isOwner(payload.id)), + Policy.withPolicy(policy.isAdminOrOwner(payload.id)), ); if (payload.image) { @@ -55,7 +55,6 @@ export class Organisations extends Effect.Service()( yield* Policy.withPolicy(policy.isOwner(id))(Effect.void); - // Perform tombstone, find other org, and update user in a single transaction yield* db.use((db) => db.transaction(async (tx) => { await tx @@ -63,7 +62,6 @@ export class Organisations extends Effect.Service()( .set({ tombstoneAt: new Date() }) .where(Dz.eq(Db.organizations.id, id)); - // Find another active organization owned by the user const [otherOrg] = await tx .select({ id: Db.organizations.id }) .from(Db.organizations) From b7c4c89a9f3b40107a5761d4fc4ca04d763f08a9 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 15 May 2026 17:00:33 +0100 Subject: [PATCH 06/35] feat(web-backend): add space admin checks and organization admin access --- .../web-backend/src/Spaces/SpacesPolicy.ts | 20 ++++++++++++++++++- packages/web-backend/src/Spaces/index.ts | 7 ++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/web-backend/src/Spaces/SpacesPolicy.ts b/packages/web-backend/src/Spaces/SpacesPolicy.ts index a342ecfcbc..f79bd94743 100644 --- a/packages/web-backend/src/Spaces/SpacesPolicy.ts +++ b/packages/web-backend/src/Spaces/SpacesPolicy.ts @@ -29,7 +29,25 @@ export class SpacesPolicy extends Effect.Service()( const isMember = (spaceId: Space.SpaceIdOrOrganisationId) => Policy.any(isOwner(spaceId), hasMembership(spaceId)); - return { isMember, isOwner }; + const isAdmin = (spaceId: Space.SpaceIdOrOrganisationId) => + Policy.any( + isOwner(spaceId), + Policy.policy((user) => + repo.membership(user.id, spaceId).pipe( + Effect.map((v) => + v.pipe( + Option.filter((v) => { + const role = String(v.role); + return role === "admin" || role === "Admin"; + }), + Option.isSome, + ), + ), + ), + ), + ); + + return { isMember, isOwner, isAdmin }; }), dependencies: [ OrganisationsRepo.Default, diff --git a/packages/web-backend/src/Spaces/index.ts b/packages/web-backend/src/Spaces/index.ts index 606094dc16..7634d93756 100644 --- a/packages/web-backend/src/Spaces/index.ts +++ b/packages/web-backend/src/Spaces/index.ts @@ -55,7 +55,12 @@ export class Spaces extends Effect.Service()("Spaces", { ]); if (space) return yield* Effect.succeed({ variant: "space" as const, space }).pipe( - Policy.withPolicy(spacesPolicy.isMember(space.id)), + Policy.withPolicy( + Policy.any( + spacesPolicy.isMember(space.id), + orgsPolicy.isAdminOrOwner(space.organizationId), + ), + ), ); if (org) return yield* Effect.succeed({ From 910d909030bec68a37e7d3fe756bed2c6d4003e2 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 15 May 2026 17:00:33 +0100 Subject: [PATCH 07/35] feat(web-backend): gate folder creation on space or organization managers --- .../web-backend/src/Folders/FoldersPolicy.ts | 27 +++++++++++++++---- packages/web-backend/src/Folders/index.ts | 4 +++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/packages/web-backend/src/Folders/FoldersPolicy.ts b/packages/web-backend/src/Folders/FoldersPolicy.ts index df5a8b8caa..9576c931c5 100644 --- a/packages/web-backend/src/Folders/FoldersPolicy.ts +++ b/packages/web-backend/src/Folders/FoldersPolicy.ts @@ -1,4 +1,4 @@ -import { type Folder, Policy } from "@cap/web-domain"; +import { type Folder, Policy, type Space } from "@cap/web-domain"; import { Effect } from "effect"; import { Database } from "../Database.ts"; @@ -15,6 +15,22 @@ export class FoldersPolicy extends Effect.Service()( const spacesPolicy = yield* SpacesPolicy; const orgsPolicy = yield* OrganisationsPolicy; const spaces = yield* Spaces; + const canManageSpaceOrOrg = (spaceId: Space.SpaceIdOrOrganisationId) => + Effect.gen(function* () { + const spaceOrOrg = yield* spaces.getSpaceOrOrg(spaceId); + if (!spaceOrOrg) return false; + + if (spaceOrOrg.variant === "space") { + yield* Policy.any( + spacesPolicy.isAdmin(spaceOrOrg.space.id), + orgsPolicy.isAdminOrOwner(spaceOrOrg.space.organizationId), + ); + } else { + yield* orgsPolicy.isAdminOrOwner(spaceOrOrg.organization.id); + } + + return true; + }); const canEdit = (id: Folder.FolderId) => Policy.policy((user) => @@ -31,15 +47,16 @@ export class FoldersPolicy extends Effect.Service()( const spaceOrOrg = yield* spaces.getSpaceOrOrg(folder.spaceId); if (!spaceOrOrg) return false; - if (spaceOrOrg.variant === "space") - yield* spacesPolicy.isMember(spaceOrOrg.space.id); - else yield* orgsPolicy.isOwner(spaceOrOrg.organization.id); + yield* canManageSpaceOrOrg(folder.spaceId); return true; }), ); - return { canEdit }; + const canCreateIn = (spaceId: Space.SpaceIdOrOrganisationId) => + Policy.policy(() => canManageSpaceOrOrg(spaceId)); + + return { canEdit, canCreateIn }; }), dependencies: [ FoldersRepo.Default, diff --git a/packages/web-backend/src/Folders/index.ts b/packages/web-backend/src/Folders/index.ts index 7adb23843a..3000cf94b9 100644 --- a/packages/web-backend/src/Folders/index.ts +++ b/packages/web-backend/src/Folders/index.ts @@ -80,6 +80,10 @@ export class Folders extends Effect.Service()("Folders", { }) { const user = yield* CurrentUser; + if (Option.isSome(data.spaceId)) { + yield* policy.canCreateIn(data.spaceId.value); + } + if (Option.isSome(data.parentId)) { const parentId = data.parentId.value; From cc7dd1b7db12e2be7635a7965a3e7dff15e70e90 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 15 May 2026 17:00:33 +0100 Subject: [PATCH 08/35] feat(web): add server-side space manager access helpers --- .../organization/space-authorization.ts | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 apps/web/actions/organization/space-authorization.ts diff --git a/apps/web/actions/organization/space-authorization.ts b/apps/web/actions/organization/space-authorization.ts new file mode 100644 index 0000000000..ca49d65548 --- /dev/null +++ b/apps/web/actions/organization/space-authorization.ts @@ -0,0 +1,95 @@ +"use server"; + +import { db } from "@cap/database"; +import { + organizationMembers, + organizations, + spaceMembers, + spaces, +} from "@cap/database/schema"; +import type { Organisation, Space, User } from "@cap/web-domain"; +import { and, eq, isNull } from "drizzle-orm"; +import { + canManageSpace, + getEffectiveOrganizationRole, + getEffectiveSpaceRole, + type OrganizationRole, + type SpaceRole, +} from "@/lib/permissions/roles"; + +export type SpaceAccess = { + spaceId: Space.SpaceIdOrOrganisationId; + organizationId: Organisation.OrganisationId; + organizationOwnerId: User.UserId; + createdById: User.UserId; + organizationRole: OrganizationRole | null; + spaceRole: SpaceRole | null; + canManage: boolean; +}; + +export async function getSpaceAccess( + userId: User.UserId, + spaceId: Space.SpaceIdOrOrganisationId, +): Promise { + const [space] = await db() + .select({ + id: spaces.id, + organizationId: spaces.organizationId, + createdById: spaces.createdById, + ownerId: organizations.ownerId, + organizationMemberRole: organizationMembers.role, + spaceMemberRole: spaceMembers.role, + }) + .from(spaces) + .innerJoin(organizations, eq(spaces.organizationId, organizations.id)) + .leftJoin( + organizationMembers, + and( + eq(organizationMembers.organizationId, spaces.organizationId), + eq(organizationMembers.userId, userId), + ), + ) + .leftJoin( + spaceMembers, + and(eq(spaceMembers.spaceId, spaces.id), eq(spaceMembers.userId, userId)), + ) + .where(and(eq(spaces.id, spaceId), isNull(organizations.tombstoneAt))) + .limit(1); + + if (!space) return null; + + const organizationRole = getEffectiveOrganizationRole({ + userId, + ownerId: space.ownerId, + memberRole: space.organizationMemberRole, + }); + const spaceRole = getEffectiveSpaceRole({ + userId, + createdById: space.createdById, + memberRole: space.spaceMemberRole, + }); + + return { + spaceId: space.id, + organizationId: space.organizationId, + organizationOwnerId: space.ownerId, + createdById: space.createdById, + organizationRole, + spaceRole, + canManage: canManageSpace({ organizationRole, spaceRole }), + }; +} + +export async function requireSpaceManager( + userId: User.UserId, + spaceId: Space.SpaceIdOrOrganisationId, +) { + const access = await getSpaceAccess(userId, spaceId); + if (!access) throw new Error("Space not found"); + if (!access.canManage) { + throw new Error( + "Only space admins, organization admins, and owners can manage this space", + ); + } + return access; +} From 83d7c50e8e366145b3e5d8f302fec18652b9a250 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 15 May 2026 17:00:33 +0100 Subject: [PATCH 09/35] feat(web): extend organization authorization for owner and admin roles --- .../web/actions/organization/authorization.ts | 89 ++++++++++++++++++- 1 file changed, 85 insertions(+), 4 deletions(-) diff --git a/apps/web/actions/organization/authorization.ts b/apps/web/actions/organization/authorization.ts index 73d27ce7b4..b055fbae9f 100644 --- a/apps/web/actions/organization/authorization.ts +++ b/apps/web/actions/organization/authorization.ts @@ -2,13 +2,32 @@ import { db } from "@cap/database"; import { organizationMembers, organizations } from "@cap/database/schema"; import type { Organisation, User } from "@cap/web-domain"; import { and, eq, isNull, or } from "drizzle-orm"; +import { + canManageOrganizationBilling, + canManageOrganizationSettings, + canViewOrganizationSettings, + getEffectiveOrganizationRole, + type OrganizationRole, +} from "@/lib/permissions/roles"; -export async function requireOrganizationAccess( +export type OrganizationAccess = { + id: Organisation.OrganisationId; + ownerId: User.UserId; + memberId: string | null; + role: OrganizationRole; +}; + +export async function getOrganizationAccess( userId: User.UserId, organizationId: Organisation.OrganisationId, -) { +): Promise { const [organization] = await db() - .select({ id: organizations.id }) + .select({ + id: organizations.id, + ownerId: organizations.ownerId, + memberId: organizationMembers.id, + memberRole: organizationMembers.role, + }) .from(organizations) .leftJoin( organizationMembers, @@ -29,5 +48,67 @@ export async function requireOrganizationAccess( ) .limit(1); - if (!organization) throw new Error("Forbidden"); + if (!organization) return null; + + const role = getEffectiveOrganizationRole({ + userId, + ownerId: organization.ownerId, + memberRole: organization.memberRole, + }); + + if (!role) return null; + + return { + id: organization.id, + ownerId: organization.ownerId, + memberId: organization.memberId, + role, + }; +} + +export async function requireOrganizationAccess( + userId: User.UserId, + organizationId: Organisation.OrganisationId, +) { + const access = await getOrganizationAccess(userId, organizationId); + if (!access) throw new Error("Forbidden"); + return access; +} + +export async function requireOrganizationSettingsAccess( + userId: User.UserId, + organizationId: Organisation.OrganisationId, +) { + const access = await requireOrganizationAccess(userId, organizationId); + if (!canViewOrganizationSettings(access.role)) { + throw new Error( + "Organization settings are only available to admins and owners", + ); + } + return access; +} + +export async function requireOrganizationSettingsManager( + userId: User.UserId, + organizationId: Organisation.OrganisationId, +) { + const access = await requireOrganizationSettingsAccess( + userId, + organizationId, + ); + if (!canManageOrganizationSettings(access.role)) { + throw new Error("Only admins and owners can manage organization settings"); + } + return access; +} + +export async function requireOrganizationOwner( + userId: User.UserId, + organizationId: Organisation.OrganisationId, +) { + const access = await requireOrganizationAccess(userId, organizationId); + if (!canManageOrganizationBilling(access.role)) { + throw new Error("Only the owner can manage this organization setting"); + } + return access; } From 58452fd2fb9c3afc6d37ae44b4f239f5a1b4a99e Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 15 May 2026 17:00:33 +0100 Subject: [PATCH 10/35] feat(web): add shareable link icon server actions for pro orgs --- .../organization/shareable-link-icon.ts | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 apps/web/actions/organization/shareable-link-icon.ts diff --git a/apps/web/actions/organization/shareable-link-icon.ts b/apps/web/actions/organization/shareable-link-icon.ts new file mode 100644 index 0000000000..02f8dc0677 --- /dev/null +++ b/apps/web/actions/organization/shareable-link-icon.ts @@ -0,0 +1,162 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { organizations } from "@cap/database/schema"; +import { userIsPro } from "@cap/utils"; +import { ImageUploads } from "@cap/web-backend"; +import { Organisation } from "@cap/web-domain"; +import { eq } from "drizzle-orm"; +import { Effect, Option } from "effect"; +import { revalidatePath } from "next/cache"; +import { runPromise } from "@/lib/server"; +import { requireOrganizationSettingsManager } from "./authorization"; + +const allowedImageTypes = new Set(["image/jpeg", "image/png"]); +const maxIconSizeBytes = 1024 * 1024; + +async function getManageableProOrganization( + organizationId: Organisation.OrganisationId, +) { + const user = await getCurrentUser(); + + if (!user) { + throw new Error("Unauthorized"); + } + + await requireOrganizationSettingsManager(user.id, organizationId); + + if (!userIsPro(user)) { + throw new Error("Upgrade required to customize shareable link branding"); + } + + const [organization] = await db() + .select({ + id: organizations.id, + iconUrl: organizations.iconUrl, + settings: organizations.settings, + shareableLinkIconUrl: organizations.shareableLinkIconUrl, + }) + .from(organizations) + .where(eq(organizations.id, organizationId)) + .limit(1); + + if (!organization) { + throw new Error("Organization not found"); + } + + return organization; +} + +function validateIcon(file: File) { + if (!file || file.size === 0) { + throw new Error("No file provided"); + } + + if (!allowedImageTypes.has(file.type.toLowerCase())) { + throw new Error("Please select a PNG or JPEG image"); + } + + if (file.size > maxIconSizeBytes) { + throw new Error("File size must be 1MB or less"); + } +} + +function revalidateOrganizationBrandingPaths() { + revalidatePath("/dashboard/caps"); + revalidatePath("/dashboard/settings/organization"); + revalidatePath("/dashboard/settings/organization/preferences"); +} + +export async function uploadShareableLinkIcon(formData: FormData) { + const organizationId = Organisation.OrganisationId.make( + String(formData.get("organizationId")), + ); + const file = formData.get("icon"); + + if (!(file instanceof File)) { + throw new Error("No file provided"); + } + + validateIcon(file); + const organization = await getManageableProOrganization(organizationId); + const arrayBuffer = await file.arrayBuffer(); + + await Effect.gen(function* () { + const imageUploads = yield* ImageUploads; + + yield* imageUploads.applyUpdate({ + payload: Option.some({ + contentType: file.type, + fileName: file.name, + data: new Uint8Array(arrayBuffer), + }), + existing: Option.fromNullable(organization.shareableLinkIconUrl), + keyPrefix: `organizations/${organization.id}/shareable-links`, + update: (db, urlOrKey) => + db + .update(organizations) + .set({ shareableLinkIconUrl: urlOrKey }) + .where(eq(organizations.id, organization.id)), + }); + }).pipe(runPromise); + + revalidateOrganizationBrandingPaths(); + + return { success: true }; +} + +export async function removeShareableLinkIcon( + organizationId: Organisation.OrganisationId, +) { + const organization = await getManageableProOrganization(organizationId); + + await Effect.gen(function* () { + const imageUploads = yield* ImageUploads; + + yield* imageUploads.applyUpdate({ + payload: Option.none(), + existing: Option.fromNullable(organization.shareableLinkIconUrl), + keyPrefix: `organizations/${organization.id}/shareable-links`, + update: (db, urlOrKey) => + db + .update(organizations) + .set({ shareableLinkIconUrl: urlOrKey }) + .where(eq(organizations.id, organization.id)), + }); + }).pipe(runPromise); + + revalidateOrganizationBrandingPaths(); + + return { success: true }; +} + +export async function updateShareableLinkIconPreference({ + organizationId, + useOrganizationIcon, +}: { + organizationId: Organisation.OrganisationId; + useOrganizationIcon: boolean; +}) { + const organization = await getManageableProOrganization(organizationId); + + if (useOrganizationIcon && !organization.iconUrl) { + throw new Error( + "Add an organization icon before using it for shareable links", + ); + } + + await db() + .update(organizations) + .set({ + settings: { + ...(organization.settings ?? {}), + shareableLinkUseOrganizationIcon: useOrganizationIcon, + }, + }) + .where(eq(organizations.id, organization.id)); + + revalidateOrganizationBrandingPaths(); + + return { success: true }; +} From 5e04db74f44b50db5c6984e7df0592081b932d83 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 15 May 2026 17:00:33 +0100 Subject: [PATCH 11/35] feat(web): add update organization member role server action --- .../organization/update-member-role.ts | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 apps/web/actions/organization/update-member-role.ts diff --git a/apps/web/actions/organization/update-member-role.ts b/apps/web/actions/organization/update-member-role.ts new file mode 100644 index 0000000000..f315d0f518 --- /dev/null +++ b/apps/web/actions/organization/update-member-role.ts @@ -0,0 +1,77 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { organizationMembers } from "@cap/database/schema"; +import type { Organisation } from "@cap/web-domain"; +import { and, eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { + canChangeOrganizationMemberRole, + getEffectiveOrganizationRole, + normalizeAssignableOrganizationRole, +} from "@/lib/permissions/roles"; +import { requireOrganizationSettingsManager } from "./authorization"; + +export async function updateOrganizationMemberRole( + memberId: string, + organizationId: Organisation.OrganisationId, + roleInput: string, +) { + const user = await getCurrentUser(); + if (!user) throw new Error("Unauthorized"); + + const nextRole = normalizeAssignableOrganizationRole(roleInput); + if (!nextRole) throw new Error("Invalid organization role"); + + const actor = await requireOrganizationSettingsManager( + user.id, + organizationId, + ); + + const [member] = await db() + .select({ + id: organizationMembers.id, + userId: organizationMembers.userId, + role: organizationMembers.role, + }) + .from(organizationMembers) + .where( + and( + eq(organizationMembers.id, memberId), + eq(organizationMembers.organizationId, organizationId), + ), + ) + .limit(1); + + if (!member) throw new Error("Member not found"); + + const targetRole = getEffectiveOrganizationRole({ + userId: member.userId, + ownerId: actor.ownerId, + memberRole: member.role, + }); + + if ( + !canChangeOrganizationMemberRole({ + actorRole: actor.role, + actorUserId: user.id, + targetUserId: member.userId, + ownerId: actor.ownerId, + targetRole, + nextRole, + }) + ) { + throw new Error("You do not have permission to update this member role"); + } + + await db() + .update(organizationMembers) + .set({ role: nextRole }) + .where(eq(organizationMembers.id, memberId)); + + revalidatePath("/dashboard/settings/organization"); + revalidatePath("/dashboard"); + + return { success: true }; +} From b50ec9489f71a91f8e8e3ba8354b027ebe9a6ed1 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 15 May 2026 17:00:33 +0100 Subject: [PATCH 12/35] feat(web): enforce roles and pro defaults in organization mutations --- apps/web/actions/organization/check-domain.ts | 7 +- apps/web/actions/organization/create-space.ts | 10 +- apps/web/actions/organization/delete-space.ts | 18 +-- .../web/actions/organization/remove-domain.ts | 7 +- .../web/actions/organization/remove-invite.ts | 5 +- .../web/actions/organization/remove-member.ts | 104 ++++++++++++------ apps/web/actions/organization/send-invites.ts | 70 +++++++++--- apps/web/actions/organization/settings.ts | 47 +++++++- apps/web/actions/organization/storage.ts | 34 +++--- .../actions/organization/update-details.ts | 5 +- .../web/actions/organization/update-domain.ts | 7 +- apps/web/actions/organization/update-space.ts | 28 +++-- .../actions/organization/upload-space-icon.ts | 10 +- 13 files changed, 225 insertions(+), 127 deletions(-) diff --git a/apps/web/actions/organization/check-domain.ts b/apps/web/actions/organization/check-domain.ts index c7041c6439..81b7087c59 100644 --- a/apps/web/actions/organization/check-domain.ts +++ b/apps/web/actions/organization/check-domain.ts @@ -5,6 +5,7 @@ import { getCurrentUser } from "@cap/database/auth/session"; import { organizations } from "@cap/database/schema"; import type { Organisation } from "@cap/web-domain"; import { eq } from "drizzle-orm"; +import { requireOrganizationSettingsManager } from "./authorization"; import { checkDomainStatus } from "./domain-utils"; export async function checkOrganizationDomain( @@ -21,9 +22,9 @@ export async function checkOrganizationDomain( .from(organizations) .where(eq(organizations.id, organizationId)); - if (!organization || organization.ownerId !== user.id) { - throw new Error("Only the owner can check domain status"); - } + if (!organization) throw new Error("Organization not found"); + + await requireOrganizationSettingsManager(user.id, organizationId); if (!organization.customDomain) { throw new Error("No custom domain set"); diff --git a/apps/web/actions/organization/create-space.ts b/apps/web/actions/organization/create-space.ts index 132bb383f5..032e9e4c4f 100644 --- a/apps/web/actions/organization/create-space.ts +++ b/apps/web/actions/organization/create-space.ts @@ -76,7 +76,6 @@ export async function createSpace( }; } - // Check for duplicate space name in the same organization const existingSpace = await db() .select({ id: spaces.id }) .from(spaces) @@ -95,7 +94,6 @@ 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 = @@ -104,7 +102,6 @@ export async function createSpace( : null; await db().transaction(async (tx) => { - // Create the space first await tx.insert(spaces).values({ id: spaceId, name, @@ -115,8 +112,6 @@ export async function createSpace( password: hashedPassword, }); - // --- Member Management Logic --- - // Collect member user IDs from formData const memberUserIds: string[] = []; for (const entry of formData.getAll("members[]")) { if (typeof entry === "string" && entry.length > 0) { @@ -124,16 +119,13 @@ export async function createSpace( } } - // Always add the creator as Admin (if not already in the list) if (!memberUserIds.includes(user.id)) { memberUserIds.push(user.id); } - // Create space members if (memberUserIds.length > 0) { const spaceMembersToInsert = memberUserIds.map((userId) => { - // Creator is always Admin, others are member - const role: SpaceMemberRole = userId === user.id ? "Admin" : "member"; + const role: SpaceMemberRole = userId === user.id ? "admin" : "member"; return { id: SpaceMemberId.make(nanoId()), spaceId, diff --git a/apps/web/actions/organization/delete-space.ts b/apps/web/actions/organization/delete-space.ts index 4d2c5ef3be..24239eefd3 100644 --- a/apps/web/actions/organization/delete-space.ts +++ b/apps/web/actions/organization/delete-space.ts @@ -14,6 +14,7 @@ import { eq } from "drizzle-orm"; import { Effect, Option } from "effect"; import { revalidatePath } from "next/cache"; import { runPromise } from "@/lib/server"; +import { requireSpaceManager } from "./space-authorization"; interface DeleteSpaceResponse { success: boolean; @@ -33,7 +34,6 @@ export async function deleteSpace( }; } - // Check if the space exists and belongs to the user's organization const space = await db() .select() .from(spaces) @@ -47,28 +47,23 @@ export async function deleteSpace( }; } - // Check if user has permission to delete the space - // Only the space creator or organization owner should be able to delete spaces const spaceData = space[0]; - if (!spaceData || spaceData.createdById !== user.id) { + const access = await requireSpaceManager(user.id, spaceId).catch( + () => null, + ); + if (!spaceData || !access) { return { success: false, error: "You don't have permission to delete this space", }; } - // Delete in order to maintain referential integrity: - - // 1. First delete all space videos await db().delete(spaceVideos).where(eq(spaceVideos.spaceId, spaceId)); - // 2. Delete all space members await db().delete(spaceMembers).where(eq(spaceMembers.spaceId, spaceId)); - // 3. Delete all space folders await db().delete(folders).where(eq(folders.spaceId, spaceId)); - // 4. Delete space icons from S3 try { await Effect.gen(function* () { const [bucket] = yield* S3Buckets.getBucketAccess(Option.none()); @@ -89,11 +84,8 @@ export async function deleteSpace( ); } }).pipe(runPromise); - - // List all objects with the space prefix } catch (error) { console.error("Error deleting space icons from S3:", error); - // Continue with space deletion even if S3 deletion fails } await db().delete(spaces).where(eq(spaces.id, spaceId)); diff --git a/apps/web/actions/organization/remove-domain.ts b/apps/web/actions/organization/remove-domain.ts index 1f4cdd97a3..5d1c051bcf 100644 --- a/apps/web/actions/organization/remove-domain.ts +++ b/apps/web/actions/organization/remove-domain.ts @@ -6,6 +6,7 @@ import { organizations } from "@cap/database/schema"; import type { Organisation } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { requireOrganizationSettingsManager } from "./authorization"; export async function removeOrganizationDomain( organizationId: Organisation.OrganisationId, @@ -21,9 +22,9 @@ export async function removeOrganizationDomain( .from(organizations) .where(eq(organizations.id, organizationId)); - if (!organization || organization.ownerId !== user.id) { - throw new Error("Only the owner can remove the custom domain"); - } + if (!organization) throw new Error("Organization not found"); + + await requireOrganizationSettingsManager(user.id, organizationId); try { if (organization.customDomain) { diff --git a/apps/web/actions/organization/remove-invite.ts b/apps/web/actions/organization/remove-invite.ts index 81db8dfd61..1eb795f5c7 100644 --- a/apps/web/actions/organization/remove-invite.ts +++ b/apps/web/actions/organization/remove-invite.ts @@ -6,6 +6,7 @@ import { organizationInvites, organizations } from "@cap/database/schema"; import type { Organisation } from "@cap/web-domain"; import { and, eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { requireOrganizationSettingsManager } from "./authorization"; export async function removeOrganizationInvite( inviteId: string, @@ -27,9 +28,7 @@ export async function removeOrganizationInvite( throw new Error("Organization not found"); } - if (organization[0]?.ownerId !== user.id) { - throw new Error("Only the owner can remove organization invites"); - } + await requireOrganizationSettingsManager(user.id, organizationId); const [result] = await db() .delete(organizationInvites) diff --git a/apps/web/actions/organization/remove-member.ts b/apps/web/actions/organization/remove-member.ts index a01e4af294..1299c3af13 100644 --- a/apps/web/actions/organization/remove-member.ts +++ b/apps/web/actions/organization/remove-member.ts @@ -2,16 +2,20 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; -import { organizationMembers, organizations } from "@cap/database/schema"; +import { + organizationMembers, + spaceMembers, + spaces, +} from "@cap/database/schema"; import type { Organisation } from "@cap/web-domain"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { + canRemoveOrganizationMember, + getEffectiveOrganizationRole, +} from "@/lib/permissions/roles"; +import { requireOrganizationSettingsManager } from "./authorization"; -/** - * Remove a member from an organization. Only the owner can perform this action. - * @param memberId The organizationMembers.id to remove - * @param organizationId The organization to remove from - */ export async function removeOrganizationMember( memberId: string, organizationId: Organisation.OrganisationId, @@ -19,22 +23,17 @@ export async function removeOrganizationMember( const user = await getCurrentUser(); if (!user) throw new Error("Unauthorized"); - const organization = await db() - .select() - .from(organizations) - .where(eq(organizations.id, organizationId)) - .limit(1); - - if (!organization || organization.length === 0) { - throw new Error("Organization not found"); - } - if (organization[0]?.ownerId !== user.id) { - throw new Error("Only the owner can remove organization members"); - } + const actor = await requireOrganizationSettingsManager( + user.id, + organizationId, + ); - // Prevent owner from removing themselves - const member = await db() - .select() + const [member] = await db() + .select({ + id: organizationMembers.id, + userId: organizationMembers.userId, + role: organizationMembers.role, + }) .from(organizationMembers) .where( and( @@ -43,25 +42,60 @@ export async function removeOrganizationMember( ), ) .limit(1); - if (!member || member.length === 0) { + + if (!member) { throw new Error("Member not found"); } - if (member[0]?.userId === user.id) { - // Defensive: this should never happen due to the above check, but TS wants safety - throw new Error("Owner cannot remove themselves"); + + const targetRole = getEffectiveOrganizationRole({ + userId: member.userId, + ownerId: actor.ownerId, + memberRole: member.role, + }); + + if ( + !canRemoveOrganizationMember({ + actorRole: actor.role, + actorUserId: user.id, + targetUserId: member.userId, + ownerId: actor.ownerId, + targetRole, + }) + ) { + throw new Error("You do not have permission to remove this member"); } - const [result] = await db() - .delete(organizationMembers) - .where( - and( - eq(organizationMembers.id, memberId), - eq(organizationMembers.organizationId, organizationId), - ), - ); + await db().transaction(async (tx) => { + const organizationSpaces = await tx + .select({ id: spaces.id }) + .from(spaces) + .where(eq(spaces.organizationId, organizationId)); + const spaceIds = organizationSpaces.map((space) => space.id); + + if (spaceIds.length > 0) { + await tx + .delete(spaceMembers) + .where( + and( + eq(spaceMembers.userId, member.userId), + inArray(spaceMembers.spaceId, spaceIds), + ), + ); + } + + const [result] = await tx + .delete(organizationMembers) + .where( + and( + eq(organizationMembers.id, memberId), + eq(organizationMembers.organizationId, organizationId), + ), + ); - if (result.affectedRows === 0) throw new Error("Member not found"); + if (result.affectedRows === 0) throw new Error("Member not found"); + }); revalidatePath("/dashboard/settings/organization"); + revalidatePath("/dashboard"); return { success: true }; } diff --git a/apps/web/actions/organization/send-invites.ts b/apps/web/actions/organization/send-invites.ts index 6ec05bb770..800369c0a7 100644 --- a/apps/web/actions/organization/send-invites.ts +++ b/apps/web/actions/organization/send-invites.ts @@ -15,10 +15,21 @@ import { serverEnv } from "@cap/env"; import type { Organisation } from "@cap/web-domain"; import { and, eq, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { + type AssignableOrganizationRole, + normalizeAssignableOrganizationRole, +} from "@/lib/permissions/roles"; +import { requireOrganizationSettingsManager } from "./authorization"; + +type OrganizationInviteInput = { + email: string; + role?: string | null; +}; export async function sendOrganizationInvites( - invitedEmails: string[], + inviteInputs: string[] | OrganizationInviteInput[], organizationId: Organisation.OrganisationId, + roleInput = "member", ) { const user = await getCurrentUser(); @@ -26,6 +37,11 @@ export async function sendOrganizationInvites( throw new Error("Unauthorized"); } + const role = normalizeAssignableOrganizationRole(roleInput); + if (!role) { + throw new Error("Invalid organization role"); + } + const [organization] = await db() .select() .from(organizations) @@ -35,23 +51,39 @@ export async function sendOrganizationInvites( throw new Error("Organization not found"); } - if (organization.ownerId !== user.id) { - throw new Error("Only the organization owner can send invites"); - } + await requireOrganizationSettingsManager(user.id, organizationId); const MAX_INVITES = 50; - if (invitedEmails.length > MAX_INVITES) { + if (inviteInputs.length > MAX_INVITES) { throw new Error(`Cannot send more than ${MAX_INVITES} invites at once`); } const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - const validEmails = Array.from( - new Set( - invitedEmails - .map((email) => email.trim().toLowerCase()) - .filter((email) => emailRegex.test(email)), - ), - ); + const inviteMap = new Map(); + + for (const inviteInput of inviteInputs) { + const email = + typeof inviteInput === "string" ? inviteInput : inviteInput.email; + const normalizedEmail = email.trim().toLowerCase(); + if (!emailRegex.test(normalizedEmail)) continue; + + const inviteRole = + typeof inviteInput === "string" || !inviteInput.role + ? role + : normalizeAssignableOrganizationRole(inviteInput.role); + + if (!inviteRole) { + throw new Error("Invalid organization role"); + } + + inviteMap.set(normalizedEmail, inviteRole); + } + + const validInvites = Array.from(inviteMap, ([email, inviteRole]) => ({ + email, + role: inviteRole, + })); + const validEmails = validInvites.map((invite) => invite.email); if (validEmails.length === 0) { return { success: true, failedEmails: [] as string[] }; @@ -88,14 +120,16 @@ export async function sendOrganizationInvites( existingMembers.map((m) => m.email.toLowerCase()), ); - const emailsToInvite = validEmails.filter( - (email) => - !existingInviteEmails.has(email) && !existingMemberEmails.has(email), + const invitesToSend = validInvites.filter( + (invite) => + !existingInviteEmails.has(invite.email) && + !existingMemberEmails.has(invite.email), ); - const records = emailsToInvite.map((email) => ({ + const records = invitesToSend.map((invite) => ({ id: nanoId(), - email, + email: invite.email, + role: invite.role, })); if (records.length > 0) { @@ -105,7 +139,7 @@ export async function sendOrganizationInvites( organizationId: organizationId, invitedEmail: r.email, invitedByUserId: user.id, - role: "member" as const, + role: r.role, })), ); } diff --git a/apps/web/actions/organization/settings.ts b/apps/web/actions/organization/settings.ts index 212b712fb1..6e3b8901a4 100644 --- a/apps/web/actions/organization/settings.ts +++ b/apps/web/actions/organization/settings.ts @@ -3,17 +3,46 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { organizations } from "@cap/database/schema"; +import { userIsPro } from "@cap/utils"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { requireOrganizationSettingsManager } from "./authorization"; -export async function updateOrganizationSettings(settings: { +type OrganizationSettingsInput = { disableSummary?: boolean; disableCaptions?: boolean; disableChapters?: boolean; disableReactions?: boolean; disableTranscript?: boolean; disableComments?: boolean; -}) { + hideShareableLinkCapLogo?: boolean; + shareableLinkUseOrganizationIcon?: boolean; +}; + +const proOrganizationSettingKeys = [ + "disableSummary", + "disableChapters", + "disableTranscript", + "hideShareableLinkCapLogo", + "shareableLinkUseOrganizationIcon", +] as const satisfies readonly (keyof OrganizationSettingsInput)[]; + +const preserveProSettings = ( + submittedSettings: OrganizationSettingsInput, + existingSettings: OrganizationSettingsInput | null | undefined, +) => ({ + ...submittedSettings, + ...Object.fromEntries( + proOrganizationSettingKeys.map((key) => [ + key, + existingSettings?.[key] ?? false, + ]), + ), +}); + +export async function updateOrganizationSettings( + settings: OrganizationSettingsInput, +) { const user = await getCurrentUser(); if (!user) { @@ -24,6 +53,10 @@ export async function updateOrganizationSettings(settings: { throw new Error("Settings are required"); } + if (!user.activeOrganizationId) { + throw new Error("Organization not found"); + } + const [organization] = await db() .select() .from(organizations) @@ -33,12 +66,20 @@ export async function updateOrganizationSettings(settings: { throw new Error("Organization not found"); } + await requireOrganizationSettingsManager(user.id, user.activeOrganizationId); + + const nextSettings = userIsPro(user) + ? settings + : preserveProSettings(settings, organization.settings); + await db() .update(organizations) - .set({ settings }) + .set({ settings: nextSettings }) .where(eq(organizations.id, user.activeOrganizationId)); revalidatePath("/dashboard/caps"); + revalidatePath("/dashboard/settings/organization"); + revalidatePath("/dashboard/settings/organization/preferences"); return { success: true }; } diff --git a/apps/web/actions/organization/storage.ts b/apps/web/actions/organization/storage.ts index b1da6ac6ab..a394480336 100644 --- a/apps/web/actions/organization/storage.ts +++ b/apps/web/actions/organization/storage.ts @@ -26,6 +26,7 @@ import { type Organisation, S3Bucket, Storage } from "@cap/web-domain"; import { and, desc, eq, isNull } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { runPromise } from "@/lib/server"; +import { requireOrganizationSettingsManager } from "./authorization"; const googleDriveProvider = "googleDrive"; const settingsPath = "/dashboard/settings/organization/integrations"; @@ -85,7 +86,7 @@ export type OrganizationGoogleDriveFolder = { const googleDriveFolderMimeType = "application/vnd.google-apps.folder"; const driveApiBase = "https://www.googleapis.com/drive/v3"; -const requireOrganizationOwner = async ( +const requireOrganizationStorageManager = async ( organizationId: Organisation.OrganisationId, ) => { const user = await getCurrentUser(); @@ -107,17 +108,15 @@ const requireOrganizationOwner = async ( .limit(1); if (!organization) throw new Error("Organization not found"); - if (organization.ownerId !== user.id) { - throw new Error("Only the owner can manage organization storage"); - } + await requireOrganizationSettingsManager(user.id, organizationId); return { user, organization }; }; -const requireOrganizationOwnerPro = async ( +const requireOrganizationStorageManagerPro = async ( organizationId: Organisation.OrganisationId, ) => { - const result = await requireOrganizationOwner(organizationId); + const result = await requireOrganizationStorageManager(organizationId); if (!userIsPro(result.user)) throw new Error(proRequiredMessage); return result; }; @@ -344,7 +343,8 @@ const createGoogleDriveState = ( export async function getOrganizationStorageSettings( organizationId: Organisation.OrganisationId, ): Promise { - const { organization } = await requireOrganizationOwner(organizationId); + const { organization } = + await requireOrganizationStorageManager(organizationId); const [bucket, drive, activeDrive] = await Promise.all([ getOrganizationBucket(organizationId), getOrganizationDrive(organizationId), @@ -381,7 +381,9 @@ export async function getOrganizationStorageSettings( } export async function saveOrganizationS3Config(input: S3ConfigInput) { - const { user } = await requireOrganizationOwnerPro(input.organizationId); + const { user } = await requireOrganizationStorageManagerPro( + input.organizationId, + ); const credentials = await getS3InputCredentials(input); const encryptedConfig = { provider: input.provider, @@ -413,7 +415,7 @@ export async function saveOrganizationS3Config(input: S3ConfigInput) { export async function removeOrganizationS3Config( organizationId: Organisation.OrganisationId, ) { - await requireOrganizationOwnerPro(organizationId); + await requireOrganizationStorageManagerPro(organizationId); await db() .update(s3Buckets) .set({ active: false }) @@ -423,7 +425,7 @@ export async function removeOrganizationS3Config( } export async function testOrganizationS3Config(input: S3ConfigInput) { - await requireOrganizationOwnerPro(input.organizationId); + await requireOrganizationStorageManagerPro(input.organizationId); const credentials = await getS3InputCredentials(input); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); @@ -455,7 +457,7 @@ export async function setOrganizationStorageProvider({ organizationId: Organisation.OrganisationId; provider: OrganizationStorageProvider; }) { - await requireOrganizationOwnerPro(organizationId); + await requireOrganizationStorageManagerPro(organizationId); if (provider === "s3") { const bucket = await getOrganizationBucket(organizationId); @@ -495,7 +497,7 @@ export async function setOrganizationStorageProvider({ export async function connectOrganizationGoogleDrive( organizationId: Organisation.OrganisationId, ) { - const { user } = await requireOrganizationOwnerPro(organizationId); + const { user } = await requireOrganizationStorageManagerPro(organizationId); const state = createGoogleDriveState(user.id, organizationId); return { url: getGoogleDriveAuthUrl({ state }) }; } @@ -503,7 +505,7 @@ export async function connectOrganizationGoogleDrive( export async function disconnectOrganizationGoogleDrive( organizationId: Organisation.OrganisationId, ) { - await requireOrganizationOwnerPro(organizationId); + await requireOrganizationStorageManagerPro(organizationId); await db() .update(storageIntegrations) .set({ @@ -529,7 +531,7 @@ export async function disconnectOrganizationGoogleDrive( export async function getOrganizationGoogleDrivePickerToken( organizationId: Organisation.OrganisationId, ) { - await requireOrganizationOwnerPro(organizationId); + await requireOrganizationStorageManagerPro(organizationId); const drive = await getOrganizationDrive(organizationId); if (!drive || drive.status !== "active") { throw new Error("Google Drive is not connected"); @@ -556,7 +558,7 @@ export async function listOrganizationGoogleDriveFolders({ organizationId: Organisation.OrganisationId; parentId?: string; }) { - await requireOrganizationOwnerPro(organizationId); + await requireOrganizationStorageManagerPro(organizationId); const drive = await getOrganizationDrive(organizationId); if (!drive || drive.status !== "active") { throw new Error("Google Drive is not connected"); @@ -618,7 +620,7 @@ export async function setOrganizationGoogleDriveLocation({ driveId?: string | null; driveName?: string | null; }) { - const { user } = await requireOrganizationOwnerPro(organizationId); + const { user } = await requireOrganizationStorageManagerPro(organizationId); const drive = await getOrganizationDrive(organizationId); if (!drive || drive.status !== "active") { throw new Error("Google Drive is not connected"); diff --git a/apps/web/actions/organization/update-details.ts b/apps/web/actions/organization/update-details.ts index 0156e2654c..f156ca215c 100644 --- a/apps/web/actions/organization/update-details.ts +++ b/apps/web/actions/organization/update-details.ts @@ -6,6 +6,7 @@ import { organizations } from "@cap/database/schema"; import type { Organisation } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { requireOrganizationSettingsManager } from "./authorization"; export async function updateOrganizationDetails({ organizationName, @@ -31,9 +32,7 @@ export async function updateOrganizationDetails({ throw new Error("Organization not found"); } - if (organization[0]?.ownerId !== user.id) { - throw new Error("Only the owner can update organization details"); - } + await requireOrganizationSettingsManager(user.id, organizationId); if (organizationName) { await db() diff --git a/apps/web/actions/organization/update-domain.ts b/apps/web/actions/organization/update-domain.ts index d51adb7ceb..7ca542c9c4 100644 --- a/apps/web/actions/organization/update-domain.ts +++ b/apps/web/actions/organization/update-domain.ts @@ -7,6 +7,7 @@ import { userIsPro } from "@cap/utils"; import type { Organisation } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { requireOrganizationSettingsManager } from "./authorization"; import { addDomain, checkDomainStatus } from "./domain-utils"; export async function updateDomain( @@ -28,9 +29,9 @@ export async function updateDomain( .from(organizations) .where(eq(organizations.id, organizationId)); - if (!organization || organization.ownerId !== user.id) { - throw new Error("Only the owner can update the custom domain"); - } + if (!organization) throw new Error("Organization not found"); + + await requireOrganizationSettingsManager(user.id, organizationId); // Check if domain is already being used by another organization const existingDomain = await db() diff --git a/apps/web/actions/organization/update-space.ts b/apps/web/actions/organization/update-space.ts index 7af0039075..9dd84e615e 100644 --- a/apps/web/actions/organization/update-space.ts +++ b/apps/web/actions/organization/update-space.ts @@ -13,10 +13,12 @@ import { type SpaceMemberRole, type User, } from "@cap/web-domain"; -import { and, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { Effect, Option } from "effect"; import { revalidatePath } from "next/cache"; +import { normalizeSpaceRole } from "@/lib/permissions/roles"; import { runPromise } from "@/lib/server"; +import { requireSpaceManager } from "./space-authorization"; import { getSpaceSettingsFromFormData, preserveProSpaceSettings, @@ -52,14 +54,8 @@ export async function updateSpace(formData: FormData) { return { success: false, error: "Space not found" }; } - const isCreator = space.createdById === user.id; - const [membership] = await db() - .select({ role: spaceMembers.role }) - .from(spaceMembers) - .where(and(eq(spaceMembers.spaceId, id), eq(spaceMembers.userId, user.id))) - .limit(1); - - if (!isCreator && membership?.role !== "Admin") { + const access = await requireSpaceManager(user.id, id).catch(() => null); + if (!access) { return { success: false, error: "Unauthorized" }; } @@ -92,6 +88,16 @@ export async function updateSpace(formData: FormData) { await db().update(spaces).set(spaceUpdate).where(eq(spaces.id, id)); const memberIds = Array.from(new Set([...members, space.createdById])); + const existingMembers = await db() + .select({ userId: spaceMembers.userId, role: spaceMembers.role }) + .from(spaceMembers) + .where(eq(spaceMembers.spaceId, id)); + const existingRoleByUserId = new Map( + existingMembers.map((member) => [ + member.userId, + normalizeSpaceRole(member.role) ?? "member", + ]), + ); await db().delete(spaceMembers).where(eq(spaceMembers.spaceId, id)); await db() @@ -99,7 +105,9 @@ export async function updateSpace(formData: FormData) { .values( memberIds.map((userId) => { const role: SpaceMemberRole = - userId === space.createdById ? "Admin" : "member"; + userId === space.createdById + ? "admin" + : (existingRoleByUserId.get(userId) ?? "member"); return { id: SpaceMemberId.make(nanoId()), spaceId: id, diff --git a/apps/web/actions/organization/upload-space-icon.ts b/apps/web/actions/organization/upload-space-icon.ts index 30a431c8e9..a72d8263ce 100644 --- a/apps/web/actions/organization/upload-space-icon.ts +++ b/apps/web/actions/organization/upload-space-icon.ts @@ -10,6 +10,7 @@ import { Option } from "effect"; import { revalidatePath } from "next/cache"; import { sanitizeFile } from "@/lib/sanitizeFile"; import { runPromise } from "@/lib/server"; +import { requireSpaceManager } from "./space-authorization"; export async function uploadSpaceIcon( formData: FormData, @@ -21,7 +22,6 @@ export async function uploadSpaceIcon( throw new Error("Unauthorized"); } - // Fetch the space and check permissions const spaceArr = await db() .select() .from(spaces) @@ -36,9 +36,7 @@ export async function uploadSpaceIcon( throw new Error("Space not found"); } - if (space.organizationId !== user.activeOrganizationId) { - throw new Error("You do not have permission to update this space"); - } + await requireSpaceManager(user.id, spaceId); const file = formData.get("icon") as File; if (!file) { @@ -51,7 +49,6 @@ export async function uploadSpaceIcon( throw new Error("File size must be less than 1MB"); } - // Prepare new file key const fileExtension = file.name.split(".").pop(); const fileKey = ImageUpload.ImageKey.make( `organizations/${ @@ -64,9 +61,7 @@ export async function uploadSpaceIcon( ); try { - // Remove previous icon if exists if (space.iconUrl) { - // Extract the S3 key (it might already be a key or could be a legacy URL) const key = space.iconUrl.startsWith("organizations/") ? space.iconUrl : space.iconUrl.match(/organizations\/.+/)?.[0]; @@ -74,7 +69,6 @@ export async function uploadSpaceIcon( try { await bucket.deleteObject(key).pipe(runPromise); } catch (e) { - // Log and continue console.warn("Failed to delete old space icon from S3", e); } } From a90954640ff8128d8c42b40791cd22cffdfdf749 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 15 May 2026 17:00:33 +0100 Subject: [PATCH 13/35] feat(web): gate space membership video actions behind manage access --- apps/web/actions/spaces/add-videos.ts | 15 +++++++++++++-- apps/web/actions/spaces/remove-videos.ts | 18 +++++++++++++++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/apps/web/actions/spaces/add-videos.ts b/apps/web/actions/spaces/add-videos.ts index 55cbf8d5f9..84bb043ff8 100644 --- a/apps/web/actions/spaces/add-videos.ts +++ b/apps/web/actions/spaces/add-videos.ts @@ -7,6 +7,8 @@ import { sharedVideos, spaceVideos, videos } from "@cap/database/schema"; import type { Space, Video } from "@cap/web-domain"; import { and, eq, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { requireOrganizationSettingsManager } from "@/actions/organization/authorization"; +import { getSpaceAccess } from "@/actions/organization/space-authorization"; export async function addVideosToSpace( spaceId: Space.SpaceIdOrOrganisationId, @@ -25,6 +27,17 @@ export async function addVideosToSpace( const isAllSpacesEntry = user.activeOrganizationId === spaceId; + if (isAllSpacesEntry) { + await requireOrganizationSettingsManager(user.id, spaceId); + } else { + const access = await getSpaceAccess(user.id, spaceId); + if (!access?.canManage) { + throw new Error( + "You don't have permission to add videos to this space", + ); + } + } + const userVideos = await db() .select({ id: videos.id }) .from(videos) @@ -64,7 +77,6 @@ export async function addVideosToSpace( ); } - // Insert new videos if (newVideoIds.length > 0) { const sharedVideoEntries = newVideoIds.map((videoId) => ({ id: nanoId(), @@ -75,7 +87,6 @@ export async function addVideosToSpace( await db().insert(sharedVideos).values(sharedVideoEntries); } } else { - // Check which videos already exist in spaceVideos const existingSpaceVideos = await db() .select({ videoId: spaceVideos.videoId }) .from(spaceVideos) diff --git a/apps/web/actions/spaces/remove-videos.ts b/apps/web/actions/spaces/remove-videos.ts index fb732ad87e..bc4397be8f 100644 --- a/apps/web/actions/spaces/remove-videos.ts +++ b/apps/web/actions/spaces/remove-videos.ts @@ -6,6 +6,8 @@ import { sharedVideos, spaceVideos, videos } from "@cap/database/schema"; import type { Space, Video } from "@cap/web-domain"; import { and, eq, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { requireOrganizationSettingsManager } from "@/actions/organization/authorization"; +import { getSpaceAccess } from "@/actions/organization/space-authorization"; export async function removeVideosFromSpace( spaceId: Space.SpaceIdOrOrganisationId, @@ -22,7 +24,19 @@ export async function removeVideosFromSpace( throw new Error("Missing required data"); } - // Only allow removing videos the user owns + const isAllSpacesEntry = user.activeOrganizationId === spaceId; + + if (isAllSpacesEntry) { + await requireOrganizationSettingsManager(user.id, spaceId); + } else { + const access = await getSpaceAccess(user.id, spaceId); + if (!access?.canManage) { + throw new Error( + "You don't have permission to remove videos from this space", + ); + } + } + const userVideos = await db() .select({ id: videos.id }) .from(videos) @@ -34,8 +48,6 @@ export async function removeVideosFromSpace( throw new Error("No valid videos found"); } - const isAllSpacesEntry = user.activeOrganizationId === spaceId; - if (isAllSpacesEntry) { await db() .delete(sharedVideos) From 40dcd580d4077c188cf561a342ab8072261ea6eb Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 15 May 2026 17:00:33 +0100 Subject: [PATCH 14/35] fix(web): use lowercase admin role for loom import spaces --- apps/web/actions/loom.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/actions/loom.ts b/apps/web/actions/loom.ts index 057d9bd9ea..1627bd5ddb 100644 --- a/apps/web/actions/loom.ts +++ b/apps/web/actions/loom.ts @@ -568,7 +568,7 @@ async function getOrCreateImportSpace({ id: SpaceMemberId.make(nanoId()), spaceId, userId: createdById, - role: "Admin", + role: "admin", }); }); From e6610bda54d204a390a05e19284f3520c606a16d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 15 May 2026 17:00:33 +0100 Subject: [PATCH 15/35] feat(web): map admin roles on desktop organizations and branding edits --- .../[...route]/organization-branding.ts | 25 +++++++++++++------ apps/web/app/api/desktop/[...route]/root.ts | 2 +- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/apps/web/app/api/desktop/[...route]/organization-branding.ts b/apps/web/app/api/desktop/[...route]/organization-branding.ts index ab78d4f0c5..5380e87aab 100644 --- a/apps/web/app/api/desktop/[...route]/organization-branding.ts +++ b/apps/web/app/api/desktop/[...route]/organization-branding.ts @@ -7,6 +7,10 @@ import { OrganizationHexColor, type OrganizationLogoUpdate, } from "@cap/web-api-contract"; +import { + getEffectiveOrganizationRole, + normalizeOrganizationRole, +} from "@/lib/permissions/roles"; export const EMPTY_ORGANIZATION_BRAND_COLORS = { primary: null, @@ -39,7 +43,7 @@ export type DesktopOrganizationRow = { tombstoneAt: Date | null; iconUrl: string | null; metadata: unknown; - role: "owner" | "member" | null; + role: "owner" | "admin" | "member" | null; }; export type DecodedOrganizationLogoUpdate = @@ -160,7 +164,7 @@ export function filterAccessibleOrganizationRows( return rows.filter( (row) => row.tombstoneAt === null && - (row.ownerId === userId || row.role === "owner" || row.role === "member"), + (row.ownerId === userId || normalizeOrganizationRole(row.role) !== null), ); } @@ -170,14 +174,18 @@ export function toDesktopOrganization( iconUrl: string | null, ): DesktopOrganization { const role = - row.ownerId === userId || row.role === "owner" ? "owner" : "member"; + getEffectiveOrganizationRole({ + userId, + ownerId: row.ownerId, + memberRole: row.role, + }) ?? "member"; return DesktopOrganizationSchema.parse({ id: row.id, name: row.name, ownerId: row.ownerId, role, - canEditBrand: role === "owner", + canEditBrand: role === "owner" || role === "admin", iconUrl, brandColors: organizationBrandColorsFromMetadata(row.metadata), }); @@ -187,9 +195,12 @@ export function canEditOrganizationBranding( row: DesktopOrganizationRow, userId: string, ) { - return ( - row.tombstoneAt === null && (row.ownerId === userId || row.role === "owner") - ); + const role = getEffectiveOrganizationRole({ + userId, + ownerId: row.ownerId, + memberRole: row.role, + }); + return row.tombstoneAt === null && (role === "owner" || role === "admin"); } export function normalizeOrganizationBrandingPatchBody( diff --git a/apps/web/app/api/desktop/[...route]/root.ts b/apps/web/app/api/desktop/[...route]/root.ts index 9ee5f729d2..f2e9927e33 100644 --- a/apps/web/app/api/desktop/[...route]/root.ts +++ b/apps/web/app/api/desktop/[...route]/root.ts @@ -505,7 +505,7 @@ app.patch( if (!canEditOrganizationBranding(row, user.id)) { return c.json( - { error: "Only organization owners can edit branding" }, + { error: "Only organization admins and owners can edit branding" }, { status: 403 }, ); } From 4a397dc675ea600dd9de05271c3eda10b856bc48 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 15 May 2026 17:00:34 +0100 Subject: [PATCH 16/35] feat(web): propagate roles share access and branding in dashboard data --- .../web/app/(org)/dashboard/dashboard-data.ts | 74 ++++++++++++++++--- 1 file changed, 62 insertions(+), 12 deletions(-) diff --git a/apps/web/app/(org)/dashboard/dashboard-data.ts b/apps/web/app/(org)/dashboard/dashboard-data.ts index e375ca2ecd..6948e96265 100644 --- a/apps/web/app/(org)/dashboard/dashboard-data.ts +++ b/apps/web/app/(org)/dashboard/dashboard-data.ts @@ -14,11 +14,23 @@ import { Database, ImageUploads } from "@cap/web-backend"; import type { ImageUpload } from "@cap/web-domain"; import { and, count, eq, inArray, isNull, or, sql } from "drizzle-orm"; import { Effect } from "effect"; +import { + canManageOrganizationMembers, + canManageSpace, + getEffectiveOrganizationRole, + getEffectiveSpaceRole, + type OrganizationRole, + type SpaceRole, +} from "@/lib/permissions/roles"; import { runPromise } from "@/lib/server"; export type Organization = { - organization: Omit & { + organization: Omit< + typeof organizations.$inferSelect, + "iconUrl" | "shareableLinkIconUrl" + > & { iconUrl: ImageUpload.ImageUrl | null; + shareableLinkIconUrl: ImageUpload.ImageUrl | null; }; members: (typeof organizationMembers.$inferSelect & { user: Pick< @@ -43,6 +55,8 @@ export type Spaces = Omit< videoCount: number; iconUrl: ImageUpload.ImageUrl | null; hasPassword: boolean; + currentUserRole: OrganizationRole | SpaceRole | null; + currentUserCanManage: boolean; }; export type UserPreferences = (typeof users.$inferSelect)["preferences"]; @@ -82,7 +96,7 @@ export async function getDashboardData(user: typeof userSelectProps) { let spacesData: Spaces[] = []; let organizationSettings: OrganizationSettings | null = null; let userCapsCount = 0; - // Find active organization ID + let currentOrganizationRole: OrganizationRole | null = null; let activeOrganizationId = organizationIds.find( (orgId) => orgId === user.activeOrganizationId, @@ -92,9 +106,26 @@ export async function getDashboardData(user: typeof userSelectProps) { activeOrganizationId = organizationIds[0]; } - // Only fetch spaces for the active organization - if (activeOrganizationId) { + const activeOrgInfo = userOrganizations.find( + (org) => org.id === activeOrganizationId, + ); + const [activeOrgMembership] = await db() + .select({ role: organizationMembers.role }) + .from(organizationMembers) + .where( + and( + eq(organizationMembers.organizationId, activeOrganizationId), + eq(organizationMembers.userId, user.id), + ), + ) + .limit(1); + currentOrganizationRole = getEffectiveOrganizationRole({ + userId: user.id, + ownerId: activeOrgInfo?.ownerId, + memberRole: activeOrgMembership?.role, + }); + const [notification] = await db() .select({ id: notifications.id }) .from(notifications) @@ -132,6 +163,12 @@ export async function getDashboardData(user: typeof userSelectProps) { createdById: spaces.createdById, iconUrl: spaces.iconUrl, settings: spaces.settings, + currentUserSpaceRole: sql`( + SELECT space_members.role FROM space_members + WHERE space_members.spaceId = spaces.id + AND space_members.userId = ${user.id} + LIMIT 1 + )`, hasPassword: sql`${spaces.password} IS NOT NULL`.mapWith( Boolean, ), @@ -147,11 +184,8 @@ export async function getDashboardData(user: typeof userSelectProps) { and( eq(spaces.organizationId, activeOrganizationId), or( - // User is the space creator eq(spaces.createdById, user.id), - // Space is public within the organization eq(spaces.privacy, "Public"), - // User is a member of the space sql`EXISTS ( SELECT 1 FROM space_members WHERE space_members.spaceId = spaces.id @@ -165,11 +199,22 @@ export async function getDashboardData(user: typeof userSelectProps) { Effect.map((rows) => rows.map( Effect.fn(function* (row) { + const { currentUserSpaceRole, ...spaceRow } = row; + const currentUserRole = getEffectiveSpaceRole({ + userId: user.id, + createdById: row.createdById, + memberRole: currentUserSpaceRole, + }); return { - ...row, + ...spaceRow, iconUrl: row.iconUrl ? yield* imageUploads.resolveImageUrl(row.iconUrl) : null, + currentUserRole, + currentUserCanManage: canManageSpace({ + organizationRole: currentOrganizationRole, + spaceRole: currentUserRole, + }), }; }), ), @@ -178,10 +223,6 @@ export async function getDashboardData(user: typeof userSelectProps) { ); }).pipe(runPromise); - // Add a single 'All spaces' entry for the active organization - const activeOrgInfo = userOrganizations.find( - (org) => org.id === activeOrganizationId, - ); if (activeOrgInfo) { const orgMemberCountResult = await db() .select({ value: sql`COUNT(*)` }) @@ -231,6 +272,10 @@ export async function getDashboardData(user: typeof userSelectProps) { videoCount: orgVideoCount, settings: null, hasPassword: false, + currentUserRole: currentOrganizationRole, + currentUserCanManage: canManageOrganizationMembers( + currentOrganizationRole, + ), } as const; }).pipe(runPromise); @@ -322,6 +367,11 @@ export async function getDashboardData(user: typeof userSelectProps) { iconUrl: organization.iconUrl ? yield* iconImages.resolveImageUrl(organization.iconUrl) : null, + shareableLinkIconUrl: organization.shareableLinkIconUrl + ? yield* iconImages.resolveImageUrl( + organization.shareableLinkIconUrl, + ) + : null, }, members: yield* Effect.all( allMembers.map( From d0f28602351350387c961a4f9cd871dc88ca3922 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 15 May 2026 17:00:34 +0100 Subject: [PATCH 17/35] fix(web): normalize assignable roles when accepting organization invites --- apps/web/app/api/invite/accept/route.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/app/api/invite/accept/route.ts b/apps/web/app/api/invite/accept/route.ts index 82f5163085..eea2cb0af3 100644 --- a/apps/web/app/api/invite/accept/route.ts +++ b/apps/web/app/api/invite/accept/route.ts @@ -9,6 +9,7 @@ import { } from "@cap/database/schema"; import { and, eq } from "drizzle-orm"; import { type NextRequest, NextResponse } from "next/server"; +import { normalizeAssignableOrganizationRole } from "@/lib/permissions/roles"; import { calculateProSeats } from "@/utils/organization"; export async function POST(request: NextRequest) { @@ -62,11 +63,13 @@ export async function POST(request: NextRequest) { if (!existingMembership) { const newId = nanoId(); + const role = + normalizeAssignableOrganizationRole(invite.role) ?? "member"; await tx.insert(organizationMembers).values({ id: newId, organizationId: invite.organizationId, userId: user.id, - role: invite.role, + role, }); memberId = newId; } From 44d288ff91b5355c775d5b85e003ab6ec4fac039 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 15 May 2026 17:00:34 +0100 Subject: [PATCH 18/35] feat(web): reflect role and manage access across dashboard chrome --- .../(org)/dashboard/_components/MobileTab.tsx | 34 +++++++++++++------ .../dashboard/_components/Navbar/Items.tsx | 18 ++++++++-- .../_components/Navbar/MemberAvatars.tsx | 26 +++++++++++--- .../_components/Navbar/SpacesList.tsx | 8 ++--- 4 files changed, 64 insertions(+), 22 deletions(-) diff --git a/apps/web/app/(org)/dashboard/_components/MobileTab.tsx b/apps/web/app/(org)/dashboard/_components/MobileTab.tsx index eaf58e5e5a..1cc7645071 100644 --- a/apps/web/app/(org)/dashboard/_components/MobileTab.tsx +++ b/apps/web/app/(org)/dashboard/_components/MobileTab.tsx @@ -15,6 +15,10 @@ import { useState, } from "react"; import { SignedImageUrl } from "@/components/SignedImageUrl"; +import { + canViewOrganizationSettings, + getEffectiveOrganizationRole, +} from "@/lib/permissions/roles"; import { useDashboardContext } from "../Contexts"; import { CapIcon, CogIcon, LayersIcon } from "./AnimatedIcons"; import { updateActiveOrganization } from "./Navbar/server"; @@ -25,15 +29,23 @@ const Tabs = [ { icon: , href: "/dashboard/settings/organization", - ownerOnly: true, + adminOnly: true, }, ]; const MobileTab = () => { const [open, setOpen] = useState(false); - const containerRef = useRef(null); + const containerRef = useRef(null); const { activeOrganization: activeOrg, user } = useDashboardContext(); - const isOwner = activeOrg?.organization.ownerId === user.id; + const currentMember = activeOrg?.members.find( + (member) => member.userId === user.id, + ); + const currentRole = getEffectiveOrganizationRole({ + userId: user.id, + ownerId: activeOrg?.organization.ownerId, + memberRole: currentMember?.role, + }); + const canViewSettings = canViewOrganizationSettings(currentRole); const menuRef = useClickAway((e) => { if ( containerRef.current && @@ -51,7 +63,7 @@ const MobileTab = () => {
- {Tabs.filter((i) => !i.ownerOnly || isOwner).map((tab) => ( + {Tabs.filter((i) => !i.adminOnly || canViewSettings).map((tab) => ( {tab.icon} @@ -68,11 +80,12 @@ const Orgs = ({ }: { setOpen: Dispatch>; open: boolean; - containerRef: MutableRefObject; + containerRef: MutableRefObject; }) => { const { activeOrganization: activeOrg } = useDashboardContext(); return ( -
setOpen((p) => !p)} ref={containerRef} className="flex gap-1.5 items-center flex-auto max-w-[224px] p-2 rounded-full border bg-gray-3 border-gray-5" @@ -92,7 +105,7 @@ const Orgs = ({ open && "rotate-180", )} /> -
+ ); }; @@ -121,9 +134,10 @@ const OrgsMenu = ({ const isSelected = activeOrg?.organization.id === organization.organization.id; return ( -
)}
-
+ ); })} diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx index 0e7849d691..ed19a95075 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx @@ -34,6 +34,10 @@ import { NewOrganization } from "@/components/forms/NewOrganization"; import { SignedImageUrl } from "@/components/SignedImageUrl"; import { Tooltip } from "@/components/Tooltip"; import { UsageButton } from "@/components/UsageButton"; +import { + canViewOrganizationSettings, + getEffectiveOrganizationRole, +} from "@/lib/permissions/roles"; import { useDashboardContext } from "../../Contexts"; import { CapIcon, @@ -94,7 +98,7 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { { name: "Organization Settings", href: `/dashboard/settings/organization`, - ownerOnly: true, + adminOnly: true, matchChildren: true, icon: , subNav: [], @@ -120,6 +124,15 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { const [createLoading, setCreateLoading] = useState(false); const [organizationName, setOrganizationName] = useState(""); const isOwner = activeOrg?.organization.ownerId === user.id; + const currentMember = activeOrg?.members.find( + (member) => member.userId === user.id, + ); + const currentRole = getEffectiveOrganizationRole({ + userId: user.id, + ownerId: activeOrg?.organization.ownerId, + memberRole: currentMember?.role, + }); + const canViewSettings = canViewOrganizationSettings(currentRole); const [_openAIDialog, _setOpenAIDialog] = useState(false); const router = useRouter(); @@ -161,6 +174,7 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { )} role="combobox" aria-expanded={open} + tabIndex={0} >
{ > {manageNavigation .filter((item) => !item.ownerOnly || isOwner) + .filter((item) => !item.adminOnly || canViewSettings) .map((item) => (
{ }, }} layoutId="navlinks" - id="navlinks" className="absolute h-[36px] w-full rounded-xl pointer-events-none bg-gray-3" /> )} diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/MemberAvatars.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/MemberAvatars.tsx index e17555c90d..72cf717ca7 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/MemberAvatars.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/MemberAvatars.tsx @@ -3,6 +3,10 @@ import { Plus } from "lucide-react"; import { SignedImageUrl } from "@/components/SignedImageUrl"; import { Tooltip } from "@/components/Tooltip"; +import { + canManageOrganizationMembers, + getEffectiveOrganizationRole, +} from "@/lib/permissions/roles"; import { useDashboardContext } from "../../Contexts"; const MAX_VISIBLE = 4; @@ -11,7 +15,15 @@ export function MemberAvatars() { const { activeOrganization, sidebarCollapsed, setInviteDialogOpen, user } = useDashboardContext(); - const isOwner = user?.id === activeOrganization?.organization.ownerId; + const currentMember = activeOrganization?.members.find( + (member) => member.userId === user?.id, + ); + const currentRole = getEffectiveOrganizationRole({ + userId: user?.id, + ownerId: activeOrganization?.organization.ownerId, + memberRole: currentMember?.role, + }); + const canInviteMembers = canManageOrganizationMembers(currentRole); if (sidebarCollapsed) return null; @@ -19,6 +31,12 @@ export function MemberAvatars() { const visibleMembers = members.slice(0, MAX_VISIBLE); const extraCount = members.length - MAX_VISIBLE; const emptySlots = Math.max(0, MAX_VISIBLE - members.length); + const emptySlotKeys = [ + "empty-slot-1", + "empty-slot-2", + "empty-slot-3", + "empty-slot-4", + ].slice(0, emptySlots); return (
@@ -48,10 +66,10 @@ export function MemberAvatars() {
)} - {isOwner && - Array.from({ length: emptySlots }).map((_, i) => ( + {canInviteMembers && + emptySlotKeys.map((slotKey) => ( void }) => { - const { spacesData, sidebarCollapsed, user } = useDashboardContext(); + const { spacesData, sidebarCollapsed } = useDashboardContext(); const [showSpaceDialog, setShowSpaceDialog] = useState(false); const [showAllSpaces, setShowAllSpaces] = useState(false); const [activeDropTarget, setActiveDropTarget] = useState(null); @@ -107,7 +107,6 @@ const SpacesList = ({ toggleMobileNav }: { toggleMobileNav?: () => void }) => { const cap = JSON.parse(capData); - // Call the share action with just this space ID const result = await shareCap({ capId: cap.id, spaceIds: [spaceId], @@ -206,7 +205,6 @@ const SpacesList = ({ toggleMobileNav }: { toggleMobileNav?: () => void }) => { - {/* Wrapper div with overflow hidden to prevent scrollbar flash */}
void }) => { }} > {displayedSpaces.map((space: Spaces) => { - const isOwner = space.createdById === user?.id; return ( void }) => { className="ml-1.5 size-2.5 text-amber-600" /> )} - {/* Hide delete button for 'All spaces' synthetic entry */} - {!space.primary && isOwner && ( + {!space.primary && space.currentUserCanManage && ( @@ -273,7 +371,12 @@ export const MembersCard = ({ Pending {invite.invitedEmail} - Member + + {organizationRoleLabel( + normalizeAssignableOrganizationRole(invite.role) ?? + "member", + )} + {buildEnv.NEXT_PUBLIC_IS_CAP && -} - Invited @@ -283,13 +386,15 @@ export const MembersCard = ({ size="xs" variant="destructive" onClick={() => { - if (isOwner) { + if (canManageMembers) { deleteInviteMutation.mutate(invite.id); } else { - showOwnerToast(); + showMemberManagerToast(); } }} - disabled={!isOwner || deletingInviteId === invite.id} + disabled={ + !canManageMembers || deletingInviteId === invite.id + } > {deletingInviteId === invite.id ? "Deleting..." diff --git a/apps/web/app/(org)/dashboard/settings/organization/layout.tsx b/apps/web/app/(org)/dashboard/settings/organization/layout.tsx index 7f3089e124..42dfcdfe63 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/layout.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/layout.tsx @@ -1,8 +1,8 @@ -import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; -import { organizationMembers, organizations } from "@cap/database/schema"; -import { and, eq, isNull } from "drizzle-orm"; +import { Card, CardDescription, CardHeader, CardTitle } from "@cap/ui"; import { redirect } from "next/navigation"; +import { getOrganizationAccess } from "@/actions/organization/authorization"; +import { canViewOrganizationSettings } from "@/lib/permissions/roles"; import { SettingsNav } from "./_components/SettingsNav"; export default async function OrganizationSettingsLayout({ @@ -16,28 +16,30 @@ export default async function OrganizationSettingsLayout({ redirect("/auth/signin"); } - const [member] = await db() - .select({ - role: organizationMembers.role, - }) - .from(organizationMembers) - .leftJoin( - organizations, - eq(organizationMembers.organizationId, organizations.id), - ) - .where( - and( - eq(organizationMembers.userId, user.id), - eq(organizations.id, user.activeOrganizationId), - isNull(organizations.tombstoneAt), - ), - ) - .limit(1); - - if (!member || member.role !== "owner") { + if (!user.activeOrganizationId) { redirect("/dashboard/caps"); } + const access = await getOrganizationAccess( + user.id, + user.activeOrganizationId, + ); + + if (!access || !canViewOrganizationSettings(access.role)) { + return ( +
+ + + Organization settings are restricted + + Ask an admin or owner to make the change. + + + +
+ ); + } + return (
From 72b677137e8ecb424570cf0f6d226aeba58e6d80 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 15 May 2026 17:00:34 +0100 Subject: [PATCH 20/35] feat(web): add organization shareable link icon settings component --- .../components/ShareableLinkIcon.tsx | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 apps/web/app/(org)/dashboard/settings/organization/components/ShareableLinkIcon.tsx diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/ShareableLinkIcon.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/ShareableLinkIcon.tsx new file mode 100644 index 0000000000..ade1e8331a --- /dev/null +++ b/apps/web/app/(org)/dashboard/settings/organization/components/ShareableLinkIcon.tsx @@ -0,0 +1,189 @@ +"use client"; + +import { CardDescription, Label, Switch } from "@cap/ui"; +import type { Organisation } from "@cap/web-domain"; +import { useMutation } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { useEffect, useId, useState } from "react"; +import { toast } from "sonner"; +import { + removeShareableLinkIcon, + updateShareableLinkIconPreference, + uploadShareableLinkIcon, +} from "@/actions/organization/shareable-link-icon"; +import { FileInput } from "@/components/FileInput"; +import { UpgradeModal } from "@/components/UpgradeModal"; +import { useDashboardContext } from "../../../Contexts"; + +export const ShareableLinkIcon = () => { + const router = useRouter(); + const iconInputId = useId(); + const { activeOrganization, user } = useDashboardContext(); + const [showUpgradeModal, setShowUpgradeModal] = useState(false); + const organization = activeOrganization?.organization; + const organizationId = organization?.id; + const hasOrganizationIcon = Boolean(organization?.iconUrl); + const existingIconUrl = organization?.shareableLinkIconUrl ?? null; + const [useOrganizationIcon, setUseOrganizationIcon] = useState( + Boolean(organization?.settings?.shareableLinkUseOrganizationIcon), + ); + + useEffect(() => { + setUseOrganizationIcon( + Boolean(organization?.settings?.shareableLinkUseOrganizationIcon), + ); + }, [organization?.settings?.shareableLinkUseOrganizationIcon]); + + const uploadIcon = useMutation({ + mutationFn: async ({ + file, + organizationId, + }: { + organizationId: Organisation.OrganisationId; + file: File; + }) => { + const formData = new FormData(); + formData.append("organizationId", organizationId); + formData.append("icon", file); + return uploadShareableLinkIcon(formData); + }, + onSuccess: () => { + toast.success("Shareable link icon updated successfully"); + router.refresh(); + }, + onError: (error) => { + toast.error( + error instanceof Error + ? error.message + : "Failed to upload shareable link icon", + ); + }, + }); + + const removeIcon = useMutation({ + mutationFn: (organizationId: Organisation.OrganisationId) => + removeShareableLinkIcon(organizationId), + onSuccess: () => { + toast.success("Shareable link icon removed successfully"); + router.refresh(); + }, + onError: (error) => { + toast.error( + error instanceof Error + ? error.message + : "Failed to remove shareable link icon", + ); + }, + }); + + const updateIconPreference = useMutation({ + mutationFn: ({ + organizationId, + useOrganizationIcon, + }: { + organizationId: Organisation.OrganisationId; + useOrganizationIcon: boolean; + }) => + updateShareableLinkIconPreference({ + organizationId, + useOrganizationIcon, + }), + onSuccess: () => { + toast.success("Shareable link icon preference updated"); + router.refresh(); + }, + onError: (error) => { + setUseOrganizationIcon( + Boolean(organization?.settings?.shareableLinkUseOrganizationIcon), + ); + toast.error( + error instanceof Error + ? error.message + : "Failed to update shareable link icon preference", + ); + }, + }); + + const isMutating = + uploadIcon.isPending || + removeIcon.isPending || + updateIconPreference.isPending; + const useOrganizationIconChecked = useOrganizationIcon && hasOrganizationIcon; + + return ( + <> +
+
+
+ +

+ Pro +

+
+ + Use a custom logo or icon on your shareable link pages. + +
+
+
+

Use organization icon

+

+ Use the organization icon when one is available. +

+
+ { + if (!organizationId) return; + if (!user.isPro) { + setShowUpgradeModal(true); + return; + } + + setUseOrganizationIcon(checked); + updateIconPreference.mutate({ + organizationId, + useOrganizationIcon: checked, + }); + }} + /> +
+ { + if (!file || !organizationId) return; + if (!user.isPro) { + setShowUpgradeModal(true); + return; + } + uploadIcon.mutate({ organizationId, file }); + }} + disabled={!user.isPro || useOrganizationIconChecked || isMutating} + isLoading={uploadIcon.isPending} + initialPreviewUrl={ + useOrganizationIconChecked + ? (organization?.iconUrl ?? null) + : existingIconUrl + } + onRemove={() => { + if (!organizationId) return; + if (!user.isPro) { + setShowUpgradeModal(true); + return; + } + removeIcon.mutate(organizationId); + }} + maxFileSizeBytes={1024 * 1024} + /> +
+ + + ); +}; From 2ae732e7b381cdc081c83458bdf0ec8a55ab3233 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 15 May 2026 17:00:34 +0100 Subject: [PATCH 21/35] feat(web): integrate share logo and link branding in org preferences --- .../components/CapSettingsCard.tsx | 83 +++++++++++-------- .../components/OrganizationDetailsCard.tsx | 6 +- 2 files changed, 54 insertions(+), 35 deletions(-) diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/CapSettingsCard.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/CapSettingsCard.tsx index 13ce221788..9c3ea1217c 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/components/CapSettingsCard.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/components/CapSettingsCard.tsx @@ -9,7 +9,23 @@ import { updateOrganizationSettings } from "@/actions/organization/settings"; import { useDashboardContext } from "../../../Contexts"; import type { OrganizationSettings } from "../../../dashboard-data"; -const options = [ +const defaultSettings: OrganizationSettings = { + disableComments: false, + disableSummary: false, + disableCaptions: false, + disableChapters: false, + disableReactions: false, + disableTranscript: false, + hideShareableLinkCapLogo: false, + shareableLinkUseOrganizationIcon: false, +}; + +const options: Array<{ + label: string; + value: keyof OrganizationSettings; + description: string; + pro?: boolean; +}> = [ { label: "Enable comments", value: "disableComments", @@ -43,36 +59,31 @@ const options = [ description: "Enabling this also allows chapters and summary", pro: true, }, + { + label: "Show Cap logo", + value: "hideShareableLinkCapLogo", + description: "Show Cap branding at the top of shareable links", + pro: true, + }, ]; +const mergeSettings = (settings?: OrganizationSettings | null) => ({ + ...defaultSettings, + ...(settings ?? {}), +}); + const CapSettingsCard = () => { const { user, organizationSettings } = useDashboardContext(); - const [settings, setSettings] = useState( - organizationSettings || { - disableComments: false, - disableSummary: false, - disableCaptions: false, - disableChapters: false, - disableReactions: false, - disableTranscript: false, - }, - ); + const initialSettings = mergeSettings(organizationSettings); + const [settings, setSettings] = + useState(initialSettings); - const lastSavedSettings = useRef( - organizationSettings || settings, - ); + const lastSavedSettings = useRef(initialSettings); const debouncedUpdateSettings = useDebounce(settings, 1000); useEffect(() => { - const next = organizationSettings ?? { - disableComments: false, - disableSummary: false, - disableCaptions: false, - disableChapters: false, - disableReactions: false, - disableTranscript: false, - }; + const next = mergeSettings(organizationSettings); setSettings(next); lastSavedSettings.current = next; }, [organizationSettings]); @@ -103,21 +114,27 @@ const CapSettingsCard = () => { changedKeys.forEach((changedKey) => { const option = options.find((opt) => opt.value === changedKey); - const isDisabled = debouncedUpdateSettings[changedKey]; - const action = isDisabled ? "disabled" : "enabled"; - const label = option?.label.split(" ")[1] || changedKey; - toast.success( - `${label.charAt(0).toUpperCase()}${label.slice(1)} ${action}`, - ); + if (changedKey === "hideShareableLinkCapLogo") { + toast.success( + debouncedUpdateSettings[changedKey] + ? "Cap logo hidden" + : "Cap logo shown", + ); + } else { + const isDisabled = debouncedUpdateSettings[changedKey]; + const action = isDisabled ? "disabled" : "enabled"; + const label = option?.label.split(" ")[1] || changedKey; + toast.success( + `${label.charAt(0).toUpperCase()}${label.slice(1)} ${action}`, + ); + } }); lastSavedSettings.current = debouncedUpdateSettings; } catch (error) { console.error("Error updating organization settings:", error); toast.error("Failed to update settings"); - if (organizationSettings) { - setSettings(organizationSettings); - } + setSettings(mergeSettings(organizationSettings)); } }; @@ -182,9 +199,9 @@ const CapSettingsCard = () => { settings?.disableTranscript) } onCheckedChange={() => { - handleToggle(option.value as keyof OrganizationSettings); + handleToggle(option.value); }} - checked={!settings?.[option.value as keyof typeof settings]} + checked={!settings?.[option.value]} />
))} diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationDetailsCard.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationDetailsCard.tsx index 8db2aae9bc..1174ce0ff6 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationDetailsCard.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationDetailsCard.tsx @@ -5,6 +5,7 @@ import AccessEmailDomain from "./AccessEmailDomain"; import { CustomDomain } from "./CustomDomain"; import { OrganizationIcon } from "./OrganizationIcon"; import OrgName from "./OrgName"; +import { ShareableLinkIcon } from "./ShareableLinkIcon"; export const OrganizationDetailsCard = () => { return ( @@ -13,14 +14,15 @@ export const OrganizationDetailsCard = () => { Settings Set the organization name, access email domain, custom domain, and - organization icon. + organization icons.
- + +
); From f390de259777d85dd60c6d300185f15ba9237ad6 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 15 May 2026 17:00:34 +0100 Subject: [PATCH 22/35] refactor(web): tighten cap viewer settings toggle typing --- .../caps/components/SettingsDialog.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/web/app/(org)/dashboard/caps/components/SettingsDialog.tsx b/apps/web/app/(org)/dashboard/caps/components/SettingsDialog.tsx index 968edddc8b..2ea87a0972 100644 --- a/apps/web/app/(org)/dashboard/caps/components/SettingsDialog.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/SettingsDialog.tsx @@ -28,7 +28,7 @@ interface SettingsDialogProps { const options: { label: string; - value: keyof NonNullable; + value: ViewerSettingKey; description: string; pro?: boolean; }[] = [ @@ -77,7 +77,9 @@ export const SettingsDialog = ({ const { user, organizationSettings } = useDashboardContext(); const [saveLoading, setSaveLoading] = useState(false); const buildSettings = useCallback( - (data?: OrganizationSettings): OrganizationSettings => ({ + ( + data?: OrganizationSettings, + ): Partial> => ({ disableComments: data?.disableComments, disableSummary: data?.disableSummary, disableCaptions: data?.disableCaptions, @@ -117,9 +119,9 @@ export const SettingsDialog = ({ }; const toggleSettingHandler = useCallback( - (value: string) => { + (value: ViewerSettingKey) => { setSettings((prev) => { - const key = value as keyof OrganizationSettings; + const key = value; const currentValue = prev?.[key]; const orgValue = organizationSettings?.[key] ?? false; @@ -143,7 +145,7 @@ export const SettingsDialog = ({ [organizationSettings], ); - const getEffectiveValue = (key: keyof OrganizationSettings) => { + const getEffectiveValue = (key: ViewerSettingKey) => { const inheritedSources = inheritedSpaceSettings?.[key]; if (inheritedSources && inheritedSources.length > 0) return true; const videoValue = settings?.[key]; @@ -153,7 +155,7 @@ export const SettingsDialog = ({ : orgValue; }; - const getInheritedLabel = (key: keyof OrganizationSettings) => { + const getInheritedLabel = (key: ViewerSettingKey) => { const sources = inheritedSpaceSettings?.[key]; if (!sources || sources.length === 0) return null; if (sources.length === 1) return `Required by ${sources[0]?.name}`; @@ -171,7 +173,7 @@ export const SettingsDialog = ({
{options.map((option) => { - const key = option.value as keyof OrganizationSettings; + const key = option.value; const effectiveValue = getEffectiveValue(key); const orgValue = organizationSettings?.[key] ?? false; const inheritedLabel = getInheritedLabel(key); @@ -207,9 +209,7 @@ export const SettingsDialog = ({ Boolean(inheritedLabel) || (option.pro && !user.isPro) || ((key === "disableSummary" || key === "disableChapters") && - getEffectiveValue( - "disableTranscript" as keyof OrganizationSettings, - )) + getEffectiveValue("disableTranscript")) } onCheckedChange={() => toggleSettingHandler(option.value)} checked={!effectiveValue} From a0f2240a844460d1578d6e2f663bbd5281f86538 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 15 May 2026 17:00:34 +0100 Subject: [PATCH 23/35] feat(web): surface space managers and manage actions in spaces views --- .../dashboard/spaces/[spaceId]/SharedCaps.tsx | 136 ++++++++++---- .../dashboard/spaces/[spaceId]/actions.ts | 172 ++++++++++++++---- .../components/EmptySharedCapState.tsx | 4 +- .../[spaceId]/components/MembersIndicator.tsx | 128 ++++++++++--- .../components/OrganizationIndicator.tsx | 19 +- .../(org)/dashboard/spaces/[spaceId]/page.tsx | 2 +- .../(org)/dashboard/spaces/browse/page.tsx | 25 ++- 7 files changed, 377 insertions(+), 109 deletions(-) diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/SharedCaps.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/SharedCaps.tsx index 60b5af7381..ea89c6a87c 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/SharedCaps.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/SharedCaps.tsx @@ -18,6 +18,12 @@ import { import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useRouter, useSearchParams } from "next/navigation"; import { useState } from "react"; +import { + canManageOrganizationMembers, + canManageSpace, + getEffectiveOrganizationRole, + getEffectiveSpaceRole, +} from "@/lib/permissions/roles"; import { useVideosAnalyticsQuery } from "@/lib/Queries/Analytics"; import SpaceDialog from "../../_components/Navbar/SpaceDialog"; import { useDashboardContext } from "../../Contexts"; @@ -118,8 +124,33 @@ export const SharedCaps = ({ setIsAddOrganizationVideosDialogOpen, ] = useState(false); - const isSpaceOwner = spaceData?.createdById === currentUserId; - const isOrgOwner = organizationData?.ownerId === currentUserId; + const currentOrgMember = organizationMembers?.find( + (member) => member.userId === currentUserId, + ); + const currentOrganizationRole = getEffectiveOrganizationRole({ + userId: currentUserId, + ownerId: + organizationData?.ownerId ?? activeOrganization?.organization.ownerId, + memberRole: currentOrgMember?.role, + }); + const currentSpaceMember = spaceMembers?.find( + (member) => member.userId === currentUserId, + ); + const currentSpaceRole = getEffectiveSpaceRole({ + userId: currentUserId, + createdById: spaceData?.createdById, + memberRole: currentSpaceMember?.role, + }); + const canManageCurrentSpace = canManageSpace({ + organizationRole: currentOrganizationRole, + spaceRole: currentSpaceRole, + }); + const canManageCurrentOrganization = canManageOrganizationMembers( + currentOrganizationRole, + ); + const canManageCurrentSharedCollection = spaceData + ? canManageCurrentSpace + : canManageCurrentOrganization; const spaceMemberCount = spaceMembers?.length || 0; @@ -160,11 +191,13 @@ export const SharedCaps = ({ return (
{spaceSettingsDialog} - + {canManageCurrentSharedCollection && ( + + )}
{spaceData && spaceMembers && ( <> @@ -173,10 +206,14 @@ export const SharedCaps = ({ members={spaceMembers} organizationMembers={organizationMembers || []} spaceId={spaceData.id} - canManageMembers={isSpaceOwner} - onAddVideos={() => setIsAddVideosDialogOpen(true)} + canManageMembers={canManageCurrentSpace} + onAddVideos={ + canManageCurrentSpace + ? () => setIsAddVideosDialogOpen(true) + : undefined + } /> - {isSpaceOwner && ( + {canManageCurrentSpace && ( + {canManageCurrentSharedCollection && ( + + )}
setIsAddVideosDialogOpen(true) @@ -261,11 +305,13 @@ export const SharedCaps = ({
)} - + {canManageCurrentSharedCollection && ( + + )}
{spaceData && spaceMembers && ( <> @@ -274,10 +320,14 @@ export const SharedCaps = ({ members={spaceMembers} organizationMembers={organizationMembers || []} spaceId={spaceData.id} - canManageMembers={isSpaceOwner} - onAddVideos={() => setIsAddVideosDialogOpen(true)} + canManageMembers={canManageCurrentSpace} + onAddVideos={ + canManageCurrentSpace + ? () => setIsAddVideosDialogOpen(true) + : undefined + } /> - {isSpaceOwner && ( + {canManageCurrentSpace && ( + {canManageCurrentSharedCollection && ( + + )}
{folders && folders.length > 0 && ( <> diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/actions.ts b/apps/web/app/(org)/dashboard/spaces/[spaceId]/actions.ts index fdff89eb5e..3e742d3fa7 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/actions.ts +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/actions.ts @@ -3,14 +3,28 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { nanoIdLength } from "@cap/database/helpers"; -import { spaceMembers, spaces } from "@cap/database/schema"; -import { Space, User } from "@cap/web-domain"; -import { eq, inArray } from "drizzle-orm"; +import { organizationMembers, spaceMembers } from "@cap/database/schema"; +import { type Organisation, Space, User } from "@cap/web-domain"; +import { and, eq, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { v4 as uuidv4 } from "uuid"; import { z } from "zod"; - -const spaceRole = z.union([z.literal("Admin"), z.literal("member")]); +import { requireSpaceManager } from "@/actions/organization/space-authorization"; +import { + canRemoveSpaceMember, + normalizeSpaceRole, + type SpaceRole, +} from "@/lib/permissions/roles"; + +const spaceRole = z.preprocess( + (value) => (value === "Admin" ? "admin" : value), + z.union([z.literal("admin"), z.literal("member")]), +); + +const spaceMemberRoleSchema = z.object({ + userId: z.string().transform((v) => User.UserId.make(v)), + role: spaceRole, +}); const addSpaceMemberSchema = z.object({ spaceId: z.string().transform((v) => Space.SpaceId.make(v)), @@ -24,6 +38,37 @@ const addSpaceMembersSchema = z.object({ role: spaceRole, }); +async function assertUsersBelongToOrganization( + organizationId: Organisation.OrganisationId, + organizationOwnerId: User.UserId, + userIds: User.UserId[], +) { + const uniqueUserIds = Array.from(new Set(userIds)); + if (uniqueUserIds.length === 0) return; + + const rows = await db() + .select({ userId: organizationMembers.userId }) + .from(organizationMembers) + .where( + and( + eq(organizationMembers.organizationId, organizationId), + inArray( + organizationMembers.userId, + uniqueUserIds.map((id) => User.UserId.make(id)), + ), + ), + ); + const allowedUserIds = new Set([ + organizationOwnerId, + ...rows.map((row) => row.userId), + ]); + const invalidUserIds = uniqueUserIds.filter((id) => !allowedUserIds.has(id)); + + if (invalidUserIds.length > 0) { + throw new Error("All space members must belong to the organization"); + } +} + export async function addSpaceMember( data: z.infer, ) { @@ -40,6 +85,12 @@ export async function addSpaceMember( } const { spaceId, userId, role } = validation.data; + const access = await requireSpaceManager(currentUser.id, spaceId); + await assertUsersBelongToOrganization( + access.organizationId, + access.organizationOwnerId, + [userId], + ); await db() .insert(spaceMembers) @@ -71,8 +122,13 @@ export async function addSpaceMembers( } const { spaceId, userIds, role } = validation.data; + const access = await requireSpaceManager(currentUser.id, spaceId); + await assertUsersBelongToOrganization( + access.organizationId, + access.organizationOwnerId, + userIds, + ); - // Fetch existing members to avoid duplicates const existing = await db() .select({ userId: spaceMembers.userId }) .from(spaceMembers) @@ -126,7 +182,7 @@ export async function removeSpaceMember( const { memberId } = validation.data; const member = await db() - .select({ spaceId: spaceMembers.spaceId }) + .select({ spaceId: spaceMembers.spaceId, userId: spaceMembers.userId }) .from(spaceMembers) .where(eq(spaceMembers.id, memberId)) .limit(1); @@ -141,6 +197,17 @@ export async function removeSpaceMember( throw new Error("Space ID not found"); } + const access = await requireSpaceManager(currentUser.id, spaceId); + if ( + !canRemoveSpaceMember({ + canManage: access.canManage, + targetUserId: member[0]?.userId, + createdById: access.createdById, + }) + ) { + throw new Error("You do not have permission to remove this space member"); + } + await db().delete(spaceMembers).where(eq(spaceMembers.id, memberId)); revalidatePath(`/dashboard/spaces/${spaceId}`); @@ -148,13 +215,13 @@ export async function removeSpaceMember( return { success: true }; } -// Replace all members for a space const setSpaceMembersSchema = z.object({ spaceId: z .string() .transform((v) => Space.SpaceId.make(v) as Space.SpaceIdOrOrganisationId), userIds: z.array(z.string().transform((v) => User.UserId.make(v))), role: spaceRole.default("member"), + members: z.array(spaceMemberRoleSchema).optional(), }); export async function setSpaceMembers( @@ -168,39 +235,51 @@ export async function setSpaceMembers( if (!currentUser) { throw new Error("Unauthorized"); } - const { spaceId, userIds, role } = validation.data; - - // Get the space creator to ensure they're always included - const [space] = await db() - .select({ createdById: spaces.createdById }) - .from(spaces) - .where(eq(spaces.id, spaceId)) - .limit(1); - - if (!space) { - throw new Error("Space not found"); - } - - // Ensure creator is always included in the member list - const allMemberIds = Array.from(new Set([...userIds, space.createdById])); + const { spaceId, userIds, role, members } = validation.data; - // Remove all current members - await db().delete(spaceMembers).where(eq(spaceMembers.spaceId, spaceId)); + const access = await requireSpaceManager(currentUser.id, spaceId); - // Insert new members (always at least the creator) + const submittedMembers = + members?.map((member) => ({ + userId: member.userId, + role: normalizeSpaceRole(member.role) ?? "member", + })) ?? + userIds.map((userId) => ({ + userId, + role: normalizeSpaceRole(role) ?? "member", + })); + + await assertUsersBelongToOrganization( + access.organizationId, + access.organizationOwnerId, + submittedMembers.map((member) => member.userId), + ); + + const roleByUserId = new Map( + submittedMembers.map((member) => [member.userId, member.role]), + ); + const allMemberIds = Array.from( + new Set([ + ...submittedMembers.map((member) => member.userId), + access.createdById, + ]), + ); const now = new Date(); const values = allMemberIds.map((userId) => { - // Creator is always Admin, others get the specified role - const memberRole = userId === space.createdById ? "Admin" : role; return { - id: User.UserId.make(uuidv4().substring(0, nanoIdLength)), + id: uuidv4().substring(0, nanoIdLength), spaceId, userId, - role: memberRole, + role: + userId === access.createdById + ? ("admin" as const) + : ((roleByUserId.get(userId) as SpaceRole | undefined) ?? "member"), createdAt: now, updatedAt: now, }; }); + + await db().delete(spaceMembers).where(eq(spaceMembers.spaceId, spaceId)); await db().insert(spaceMembers).values(values); revalidatePath(`/dashboard/spaces/${spaceId}`); @@ -229,16 +308,39 @@ export async function batchRemoveSpaceMembers( return { success: true, removed: [] }; } - // Get spaceId for revalidation (assume all memberIds are from the same space) const members = await db() - .select({ spaceId: spaceMembers.spaceId }) + .select({ + id: spaceMembers.id, + spaceId: spaceMembers.spaceId, + userId: spaceMembers.userId, + }) .from(spaceMembers) .where(inArray(spaceMembers.id, memberIds)); const spaceId = members[0]?.spaceId; - await db().delete(spaceMembers).where(inArray(spaceMembers.id, memberIds)); - if (spaceId) { - revalidatePath(`/dashboard/spaces/${spaceId}`); + if (!spaceId) { + return { success: true, removed: [] }; + } + + if (members.some((member) => member.spaceId !== spaceId)) { + throw new Error("Cannot remove members from multiple spaces at once"); + } + + const access = await requireSpaceManager(currentUser.id, spaceId); + const protectedMember = members.find( + (member) => + !canRemoveSpaceMember({ + canManage: access.canManage, + targetUserId: member.userId, + createdById: access.createdById, + }), + ); + + if (protectedMember) { + throw new Error("You do not have permission to remove one or more members"); } + + await db().delete(spaceMembers).where(inArray(spaceMembers.id, memberIds)); + revalidatePath(`/dashboard/spaces/${spaceId}`); return { success: true, removed: memberIds }; } diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/EmptySharedCapState.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/EmptySharedCapState.tsx index fe0a4eebd5..ba3ddddd9b 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/EmptySharedCapState.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/EmptySharedCapState.tsx @@ -14,6 +14,7 @@ interface EmptySharedCapStateProps { createdById: string; }; currentUserId?: string; + canAddVideos?: boolean; onAddVideos?: () => void; } @@ -22,6 +23,7 @@ export const EmptySharedCapState: React.FC = ({ type = "organization", spaceData, currentUserId, + canAddVideos, onAddVideos, }) => { const { theme } = useTheme(); @@ -33,7 +35,7 @@ export const EmptySharedCapState: React.FC = ({ const isSpaceOwner = spaceData?.createdById === currentUserId; const showAddButton = - (type === "space" && isSpaceOwner && onAddVideos) || + (type === "space" && (isSpaceOwner || canAddVideos) && onAddVideos) || (type === "organization" && onAddVideos); return ( diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MembersIndicator.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MembersIndicator.tsx index c8ef26a187..03cdbbd591 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MembersIndicator.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MembersIndicator.tsx @@ -11,17 +11,23 @@ import { Form, FormControl, FormField, + Select, } from "@cap/ui"; import { type Space, User } from "@cap/web-domain"; import { faPlus, faUserGroup } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { useRouter } from "next/navigation"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import * as z from "zod"; import { SignedImageUrl } from "@/components/SignedImageUrl"; +import { + normalizeSpaceRole, + type SpaceRole, + spaceRoleLabel, +} from "@/lib/permissions/roles"; import { useDashboardContext } from "../../../Contexts"; import { setSpaceMembers } from "../actions"; import type { SpaceMemberData } from "../page"; @@ -48,9 +54,14 @@ export const MembersIndicator = ({ const router = useRouter(); const [open, setOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); + const [memberRoles, setMemberRoles] = useState>({}); + const roleOptions = [ + { value: "member", label: "Member" }, + { value: "admin", label: "Admin" }, + ]; const formSchema = z.object({ - members: z.array(z.string().email("Invalid email address")).optional(), + members: z.array(z.string()).optional(), }); const form = useForm>({ @@ -60,15 +71,33 @@ export const MembersIndicator = ({ }, }); + useEffect(() => { + setMemberRoles( + Object.fromEntries( + members.map((member) => [ + member.userId, + normalizeSpaceRole(member.role) ?? "member", + ]), + ), + ); + form.reset({ + members: members.map((member) => member.userId), + }); + }, [members, form]); + const handleSaveMembers = async (selectedUserIds: User.UserId[]) => { if (!canManageMembers) return; - // Compare selectedUserIds to current members' userIds (order-insensitive) const currentIds = members.map((m) => m.userId).sort(); const selectedIds = (selectedUserIds ?? []).slice().sort(); const noChange = currentIds.length === selectedIds.length && - currentIds.every((id, i) => id === selectedIds[i]); + currentIds.every((id, i) => id === selectedIds[i]) && + currentIds.every( + (id) => + (normalizeSpaceRole(members.find((m) => m.userId === id)?.role) ?? + "member") === (memberRoles[id] ?? "member"), + ); if (noChange) { toast.info("No changes were applied"); @@ -80,6 +109,10 @@ export const MembersIndicator = ({ await setSpaceMembers({ spaceId, userIds: selectedUserIds ?? [], + members: (selectedUserIds ?? []).map((userId) => ({ + userId, + role: memberRoles[userId] ?? "member", + })), role: "member", }); toast.success("Members updated!"); @@ -96,16 +129,14 @@ export const MembersIndicator = ({ const OrgMembers = useCallback( (field: { value?: string[] }) => { return organizationMembers - .filter( - (m) => (field.value ?? []).includes(m.userId) && m.userId !== user.id, - ) + .filter((m) => (field.value ?? []).includes(m.userId)) .map((m) => ({ value: m.userId, label: m.name || m.email, image: m.image || undefined, })); }, - [organizationMembers, user], + [organizationMembers], ); return ( @@ -140,20 +171,71 @@ export const MembersIndicator = ({ control={form.control} name="members" render={({ field }) => { + const selectedMembers = OrgMembers(field); return ( - { - field.onChange( - selected.map((opt) => opt.value), - ); - }} - /> +
+ { + const selectedIds = selected.map( + (opt) => opt.value, + ); + field.onChange(selectedIds); + setMemberRoles((prev) => { + const next: Record = {}; + for (const userId of selectedIds) { + next[userId] = prev[userId] ?? "member"; + } + return next; + }); + }} + /> + {selectedMembers.length > 0 && ( +
+ {selectedMembers.map((member) => ( +
+
+ + + {member.label} + +
+