From 51e7277d38ce6225a03f2922e5c6a3397201f785 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:53:11 +0000 Subject: [PATCH 01/38] feat(db): add hasProSeat column to organization_members --- .../migrations/0012_lethal_lilith.sql | 109 + .../migrations/meta/0012_snapshot.json | 2774 +++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + packages/database/schema.ts | 1 + 4 files changed, 2891 insertions(+) create mode 100644 packages/database/migrations/0012_lethal_lilith.sql create mode 100644 packages/database/migrations/meta/0012_snapshot.json diff --git a/packages/database/migrations/0012_lethal_lilith.sql b/packages/database/migrations/0012_lethal_lilith.sql new file mode 100644 index 0000000000..ddc45b9fa3 --- /dev/null +++ b/packages/database/migrations/0012_lethal_lilith.sql @@ -0,0 +1,109 @@ +CREATE TABLE `developer_api_keys` ( + `id` varchar(15) NOT NULL, + `appId` varchar(15) NOT NULL, + `keyType` varchar(8) NOT NULL, + `keyPrefix` varchar(12) NOT NULL, + `keyHash` varchar(64) NOT NULL, + `encryptedKey` text NOT NULL, + `lastUsedAt` timestamp, + `revokedAt` timestamp, + `createdAt` timestamp NOT NULL DEFAULT (now()), + CONSTRAINT `developer_api_keys_id` PRIMARY KEY(`id`), + CONSTRAINT `key_hash_idx` UNIQUE(`keyHash`) +); +--> statement-breakpoint +CREATE TABLE `developer_app_domains` ( + `id` varchar(15) NOT NULL, + `appId` varchar(15) NOT NULL, + `domain` varchar(253) NOT NULL, + `createdAt` timestamp NOT NULL DEFAULT (now()), + CONSTRAINT `developer_app_domains_id` PRIMARY KEY(`id`), + CONSTRAINT `app_domain_unique` UNIQUE(`appId`,`domain`) +); +--> statement-breakpoint +CREATE TABLE `developer_apps` ( + `id` varchar(15) NOT NULL, + `ownerId` varchar(15) NOT NULL, + `name` varchar(255) NOT NULL, + `environment` varchar(16) NOT NULL, + `logoUrl` varchar(1024), + `deletedAt` timestamp, + `createdAt` timestamp NOT NULL DEFAULT (now()), + `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `developer_apps_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `developer_credit_accounts` ( + `id` varchar(15) NOT NULL, + `appId` varchar(15) NOT NULL, + `ownerId` varchar(15) NOT NULL, + `balanceMicroCredits` bigint unsigned NOT NULL DEFAULT 0, + `stripeCustomerId` varchar(255), + `stripePaymentMethodId` varchar(255), + `autoTopUpEnabled` boolean NOT NULL DEFAULT false, + `autoTopUpThresholdMicroCredits` bigint unsigned NOT NULL DEFAULT 0, + `autoTopUpAmountCents` int NOT NULL DEFAULT 0, + `createdAt` timestamp NOT NULL DEFAULT (now()), + `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `developer_credit_accounts_id` PRIMARY KEY(`id`), + CONSTRAINT `app_id_unique` UNIQUE(`appId`) +); +--> statement-breakpoint +CREATE TABLE `developer_credit_transactions` ( + `id` varchar(15) NOT NULL, + `accountId` varchar(15) NOT NULL, + `type` varchar(16) NOT NULL, + `amountMicroCredits` bigint NOT NULL, + `balanceAfterMicroCredits` bigint unsigned NOT NULL, + `referenceId` varchar(255), + `referenceType` varchar(32), + `metadata` json, + `createdAt` timestamp NOT NULL DEFAULT (now()), + CONSTRAINT `developer_credit_transactions_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `developer_daily_storage_snapshots` ( + `id` varchar(15) NOT NULL, + `appId` varchar(15) NOT NULL, + `snapshotDate` varchar(10) NOT NULL, + `totalDurationMinutes` float NOT NULL DEFAULT 0, + `videoCount` int NOT NULL DEFAULT 0, + `microCreditsCharged` bigint unsigned NOT NULL DEFAULT 0, + `processedAt` timestamp, + `createdAt` timestamp NOT NULL DEFAULT (now()), + CONSTRAINT `developer_daily_storage_snapshots_id` PRIMARY KEY(`id`), + CONSTRAINT `app_date_unique` UNIQUE(`appId`,`snapshotDate`) +); +--> statement-breakpoint +CREATE TABLE `developer_videos` ( + `id` varchar(15) NOT NULL, + `appId` varchar(15) NOT NULL, + `externalUserId` varchar(255), + `name` varchar(255) NOT NULL DEFAULT 'Untitled', + `duration` float, + `width` int, + `height` int, + `fps` int, + `s3Key` varchar(512), + `transcriptionStatus` varchar(16), + `metadata` json, + `deletedAt` timestamp, + `createdAt` timestamp NOT NULL DEFAULT (now()), + `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `developer_videos_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +ALTER TABLE `organization_members` ADD `hasProSeat` boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE `developer_api_keys` ADD CONSTRAINT `developer_api_keys_appId_developer_apps_id_fk` FOREIGN KEY (`appId`) REFERENCES `developer_apps`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `developer_app_domains` ADD CONSTRAINT `developer_app_domains_appId_developer_apps_id_fk` FOREIGN KEY (`appId`) REFERENCES `developer_apps`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `developer_credit_accounts` ADD CONSTRAINT `developer_credit_accounts_appId_developer_apps_id_fk` FOREIGN KEY (`appId`) REFERENCES `developer_apps`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `developer_credit_transactions` ADD CONSTRAINT `dev_credit_txn_account_fk` FOREIGN KEY (`accountId`) REFERENCES `developer_credit_accounts`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `developer_daily_storage_snapshots` ADD CONSTRAINT `developer_daily_storage_snapshots_appId_developer_apps_id_fk` FOREIGN KEY (`appId`) REFERENCES `developer_apps`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `developer_videos` ADD CONSTRAINT `developer_videos_appId_developer_apps_id_fk` FOREIGN KEY (`appId`) REFERENCES `developer_apps`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX `app_key_type_idx` ON `developer_api_keys` (`appId`,`keyType`);--> statement-breakpoint +CREATE INDEX `owner_deleted_idx` ON `developer_apps` (`ownerId`,`deletedAt`);--> statement-breakpoint +CREATE INDEX `account_type_created_idx` ON `developer_credit_transactions` (`accountId`,`type`,`createdAt`);--> statement-breakpoint +CREATE INDEX `account_ref_dedup_idx` ON `developer_credit_transactions` (`accountId`,`referenceId`,`referenceType`);--> statement-breakpoint +CREATE INDEX `app_created_idx` ON `developer_videos` (`appId`,`createdAt`);--> statement-breakpoint +CREATE INDEX `app_user_idx` ON `developer_videos` (`appId`,`externalUserId`);--> statement-breakpoint +CREATE INDEX `app_deleted_idx` ON `developer_videos` (`appId`,`deletedAt`); \ No newline at end of file diff --git a/packages/database/migrations/meta/0012_snapshot.json b/packages/database/migrations/meta/0012_snapshot.json new file mode 100644 index 0000000000..3d3bd27a36 --- /dev/null +++ b/packages/database/migrations/meta/0012_snapshot.json @@ -0,0 +1,2774 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "d5a6a5a2-9653-4a3e-9c8a-c2886980e3fb", + "prevId": "620e2ba5-ee1b-47a7-ae58-a1dd8ea1f867", + "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(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true, + "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 + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "notifications_id": { + "name": "notifications_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organization_invites": { + "name": "organization_invites", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invitedEmail": { + "name": "invitedEmail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invitedByUserId": { + "name": "invitedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "invited_email_idx": { + "name": "invited_email_idx", + "columns": ["invitedEmail"], + "isUnique": false + }, + "invited_by_user_id_idx": { + "name": "invited_by_user_id_idx", + "columns": ["invitedByUserId"], + "isUnique": false + }, + "status_idx": { + "name": "status_idx", + "columns": ["status"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organization_invites_id": { + "name": "organization_invites_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organization_members": { + "name": "organization_members", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hasProSeat": { + "name": "hasProSeat", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "user_id_organization_id_idx": { + "name": "user_id_organization_id_idx", + "columns": ["userId", "organizationId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organization_members_id": { + "name": "organization_members_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tombstoneAt": { + "name": "tombstoneAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowedEmailDomain": { + "name": "allowedEmailDomain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customDomain": { + "name": "customDomain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "domainVerified": { + "name": "domainVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iconUrl": { + "name": "iconUrl", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "workosOrganizationId": { + "name": "workosOrganizationId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workosConnectionId": { + "name": "workosConnectionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "owner_id_tombstone_idx": { + "name": "owner_id_tombstone_idx", + "columns": ["ownerId", "tombstoneAt"], + "isUnique": false + }, + "custom_domain_idx": { + "name": "custom_domain_idx", + "columns": ["customDomain"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organizations_id": { + "name": "organizations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "s3_buckets": { + "name": "s3_buckets", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "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')" + } + }, + "indexes": {}, + "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 + }, + "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": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastName": { + "name": "lastName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thirdPartyStripeSubscriptionId": { + "name": "thirdPartyStripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionStatus": { + "name": "stripeSubscriptionStatus", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionPriceId": { + "name": "stripeSubscriptionPriceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "preferences": { + "name": "preferences", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('null')" + }, + "activeOrganizationId": { + "name": "activeOrganizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "onboardingSteps": { + "name": "onboardingSteps", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customBucket": { + "name": "customBucket", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inviteQuota": { + "name": "inviteQuota", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "defaultOrgId": { + "name": "defaultOrgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "email_idx": { + "name": "email_idx", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "verification_tokens": { + "name": "verification_tokens", + "columns": { + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_tokens_identifier": { + "name": "verification_tokens_identifier", + "columns": ["identifier"] + } + }, + "uniqueConstraints": { + "verification_tokens_token_unique": { + "name": "verification_tokens_token_unique", + "columns": ["token"] + } + }, + "checkConstraint": {} + }, + "video_uploads": { + "name": "video_uploads", + "columns": { + "video_id": { + "name": "video_id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded": { + "name": "uploaded", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total": { + "name": "total", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "mode": { + "name": "mode", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase": { + "name": "phase", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'uploading'" + }, + "processing_progress": { + "name": "processing_progress", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "processing_message": { + "name": "processing_message", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_file_key": { + "name": "raw_file_key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "video_uploads_video_id": { + "name": "video_uploads_video_id", + "columns": ["video_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "videos": { + "name": "videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "orgId": { + "name": "orgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'My Video'" + }, + "bucket": { + "name": "bucket", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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 + }, + "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 + }, + "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": {}, + "compositePrimaryKeys": { + "videos_id": { + "name": "videos_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index 40761045f5..6e4b82a207 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1771325011010, "tag": "0011_public_inhumans", "breakpoints": true + }, + { + "idx": 12, + "version": "5", + "when": 1772534950328, + "tag": "0012_lethal_lilith", + "breakpoints": true } ] } diff --git a/packages/database/schema.ts b/packages/database/schema.ts index 7d214cb648..ad0f836a5b 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -223,6 +223,7 @@ export const organizationMembers = mysqlTable( role: varchar("role", { length: 255 }) .notNull() .$type(), + hasProSeat: boolean("hasProSeat").default(false).notNull(), createdAt: timestamp("createdAt").notNull().defaultNow(), updatedAt: timestamp("updatedAt").notNull().defaultNow().onUpdateNow(), }, From 7673b00bcfc426d8af50ae57ff5dad0e72bd91ae Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:53:14 +0000 Subject: [PATCH 02/38] feat(utils): add calculateProSeats helper for Pro seat tracking --- apps/web/utils/organization.ts | 37 +++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/apps/web/utils/organization.ts b/apps/web/utils/organization.ts index e8a40e6fa5..40a02a67c6 100644 --- a/apps/web/utils/organization.ts +++ b/apps/web/utils/organization.ts @@ -1,26 +1,39 @@ import { buildEnv } from "@cap/env"; -/** - * Calculate organization seats information - */ +export function calculateProSeats(organization: { + inviteQuota?: number; + members?: { id: string; hasProSeat?: boolean }[]; +}) { + const proSeatsTotal = organization?.inviteQuota ?? 1; + const proSeatsUsed = + organization?.members?.filter((m) => m.hasProSeat).length ?? 0; + const proSeatsRemaining = buildEnv.NEXT_PUBLIC_IS_CAP + ? Math.max(0, proSeatsTotal - proSeatsUsed) + : Number.MAX_SAFE_INTEGER; + + return { + proSeatsTotal, + proSeatsUsed, + proSeatsRemaining, + }; +} + export function calculateSeats(organization: { inviteQuota?: number; - members?: { id: string }[]; + members?: { id: string; hasProSeat?: boolean }[]; invites?: { id: string }[]; }) { - const inviteQuota = organization?.inviteQuota ?? 1; + const { proSeatsTotal, proSeatsUsed, proSeatsRemaining } = + calculateProSeats(organization); + const memberCount = organization?.members?.length ?? 0; const pendingInvitesCount = organization?.invites?.length ?? 0; - const totalUsedSeats = memberCount + pendingInvitesCount; - const remainingSeats = buildEnv.NEXT_PUBLIC_IS_CAP - ? Math.max(0, inviteQuota - totalUsedSeats) - : Number.MAX_SAFE_INTEGER; return { - inviteQuota, memberCount, pendingInvitesCount, - totalUsedSeats, - remainingSeats, + proSeatsTotal, + proSeatsUsed, + proSeatsRemaining, }; } From 179ead4e27a774c031672253d9da199ff1079c0d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:53:20 +0000 Subject: [PATCH 03/38] refactor(invites): rewrite send-invites with transactions and dedup --- apps/web/actions/organization/send-invites.ts | 128 +++++++++++++----- 1 file changed, 97 insertions(+), 31 deletions(-) diff --git a/apps/web/actions/organization/send-invites.ts b/apps/web/actions/organization/send-invites.ts index 183a115b24..8fbebfcdce 100644 --- a/apps/web/actions/organization/send-invites.ts +++ b/apps/web/actions/organization/send-invites.ts @@ -5,10 +5,15 @@ import { getCurrentUser } from "@cap/database/auth/session"; import { sendEmail } from "@cap/database/emails/config"; import { OrganizationInvite } from "@cap/database/emails/organization-invite"; import { nanoId } from "@cap/database/helpers"; -import { organizationInvites, organizations } from "@cap/database/schema"; +import { + organizationInvites, + organizationMembers, + organizations, + users, +} from "@cap/database/schema"; import { serverEnv } from "@cap/env"; import type { Organisation } from "@cap/web-domain"; -import { eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; export async function sendOrganizationInvites( @@ -21,48 +26,109 @@ export async function sendOrganizationInvites( throw new Error("Unauthorized"); } - const organization = await db() + const [organization] = await db() .select() .from(organizations) .where(eq(organizations.id, organizationId)); - if (!organization || organization.length === 0) { + if (!organization) { throw new Error("Organization not found"); } - if (organization[0]?.ownerId !== user.id) { - throw new Error("Only the owner can send organization invites"); + if (organization.ownerId !== user.id) { + throw new Error("Only the organization owner can send invites"); + } + + const MAX_INVITES = 50; + if (invitedEmails.length > MAX_INVITES) { + throw new Error(`Cannot send more than ${MAX_INVITES} invites at once`); } const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - const validEmails = invitedEmails.filter((email) => - emailRegex.test(email.trim()), - ); + const validEmails = invitedEmails + .map((email) => email.trim().toLowerCase()) + .filter((email) => emailRegex.test(email)); - for (const email of validEmails) { - const inviteId = nanoId(); - await db().insert(organizationInvites).values({ - id: inviteId, - organizationId: organizationId, - invitedEmail: email.trim(), - invitedByUserId: user.id, - role: "member", - }); - - // Send invitation email - const inviteUrl = `${serverEnv().WEB_URL}/invite/${inviteId}`; - await sendEmail({ - email: email.trim(), - subject: `Invitation to join ${organization[0].name} on Cap`, - react: OrganizationInvite({ - email: email.trim(), - url: inviteUrl, - organizationName: organization[0].name, - }), - }); + if (validEmails.length === 0) { + return { success: true, failedEmails: [] as string[] }; } + const inviteRecords = await db().transaction(async (tx) => { + const [existingInvites, existingMembers] = await Promise.all([ + tx + .select({ invitedEmail: organizationInvites.invitedEmail }) + .from(organizationInvites) + .where( + and( + eq(organizationInvites.organizationId, organizationId), + inArray(organizationInvites.invitedEmail, validEmails), + ), + ), + tx + .select({ email: users.email }) + .from(organizationMembers) + .innerJoin(users, eq(organizationMembers.userId, users.id)) + .where( + and( + eq(organizationMembers.organizationId, organizationId), + inArray(users.email, validEmails), + ), + ), + ]); + + const existingInviteEmails = new Set( + existingInvites.map((i) => i.invitedEmail.toLowerCase()), + ); + + const existingMemberEmails = new Set( + existingMembers.map((m) => m.email.toLowerCase()), + ); + + const emailsToInvite = validEmails.filter( + (email) => + !existingInviteEmails.has(email) && !existingMemberEmails.has(email), + ); + + const records = emailsToInvite.map((email) => ({ + id: nanoId(), + email, + })); + + if (records.length > 0) { + await tx.insert(organizationInvites).values( + records.map((r) => ({ + id: r.id, + organizationId: organizationId, + invitedEmail: r.email, + invitedByUserId: user.id, + role: "member" as const, + })), + ); + } + + return records; + }); + + const emailResults = await Promise.allSettled( + inviteRecords.map((record) => { + const inviteUrl = `${serverEnv().WEB_URL}/invite/${record.id}`; + return sendEmail({ + email: record.email, + subject: `Invitation to join ${organization.name} on Cap`, + react: OrganizationInvite({ + email: record.email, + url: inviteUrl, + organizationName: organization.name, + }), + }); + }), + ); + + const failedEmails = inviteRecords + .filter((_, i) => emailResults[i]?.status === "rejected") + .map((r) => r.email); + revalidatePath("/dashboard/settings/organization"); - return { success: true }; + return { success: true, failedEmails }; } From 6f5ad5cb4a4faa9267500d745e6e725a6cd3095d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:53:31 +0000 Subject: [PATCH 04/38] feat(org): add get-subscription-details server action --- .../organization/get-subscription-details.ts | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 apps/web/actions/organization/get-subscription-details.ts diff --git a/apps/web/actions/organization/get-subscription-details.ts b/apps/web/actions/organization/get-subscription-details.ts new file mode 100644 index 0000000000..edeb8a59f0 --- /dev/null +++ b/apps/web/actions/organization/get-subscription-details.ts @@ -0,0 +1,75 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { organizations, users } from "@cap/database/schema"; +import { stripe } from "@cap/utils"; +import type { Organisation } from "@cap/web-domain"; +import { eq } from "drizzle-orm"; + +export type SubscriptionDetails = { + planName: string; + status: string; + billingInterval: "month" | "year"; + pricePerSeat: number; + currentQuantity: number; + currentPeriodEnd: number; + currency: string; +}; + +export async function getSubscriptionDetails( + organizationId: Organisation.OrganisationId, +): Promise { + 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) throw new Error("Organization not found"); + if (organization.ownerId !== user.id) + throw new Error("Only the owner can view subscription details"); + + const [owner] = await db() + .select({ + stripeSubscriptionId: users.stripeSubscriptionId, + stripeSubscriptionStatus: users.stripeSubscriptionStatus, + }) + .from(users) + .where(eq(users.id, user.id)) + .limit(1); + + if (!owner?.stripeSubscriptionId) { + return null; + } + + const subscription = await stripe().subscriptions.retrieve( + owner.stripeSubscriptionId, + ); + + if (subscription.status !== "active" && subscription.status !== "trialing") { + return null; + } + + const item = subscription.items.data[0]; + if (!item) return null; + + const price = item.price; + const unitAmount = price.unit_amount ?? 0; + const interval = price.recurring?.interval === "year" ? "year" : "month"; + const pricePerSeat = + interval === "year" ? unitAmount / 100 / 12 : unitAmount / 100; + + return { + planName: "Cap Pro", + status: subscription.status, + billingInterval: interval, + pricePerSeat, + currentQuantity: item.quantity || 1, + currentPeriodEnd: subscription.current_period_end, + currency: price.currency, + }; +} From 2d1354efdaa4eb1dd191e900fec612bb9ea0164e Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:53:32 +0000 Subject: [PATCH 05/38] feat(org): add toggle-pro-seat server action --- .../actions/organization/toggle-pro-seat.ts | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 apps/web/actions/organization/toggle-pro-seat.ts diff --git a/apps/web/actions/organization/toggle-pro-seat.ts b/apps/web/actions/organization/toggle-pro-seat.ts new file mode 100644 index 0000000000..29e52a71cc --- /dev/null +++ b/apps/web/actions/organization/toggle-pro-seat.ts @@ -0,0 +1,125 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { + organizationMembers, + organizations, + users, +} from "@cap/database/schema"; +import type { Organisation } from "@cap/web-domain"; +import { and, eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { calculateProSeats } from "@/utils/organization"; + +export async function toggleProSeat( + memberId: string, + organizationId: Organisation.OrganisationId, + enable: boolean, +) { + 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) { + throw new Error("Organization not found"); + } + + if (organization.ownerId !== user.id) { + throw new Error("Only the owner can manage Pro seats"); + } + + await db().transaction(async (tx) => { + const [member] = await tx + .select() + .from(organizationMembers) + .where( + and( + eq(organizationMembers.id, memberId), + eq(organizationMembers.organizationId, organizationId), + ), + ) + .for("update"); + + if (!member) { + throw new Error("Member not found"); + } + + if (member.userId === organization.ownerId) { + throw new Error("Cannot toggle Pro seat for the organization owner"); + } + + if (enable) { + const allMembers = await tx + .select({ + id: organizationMembers.id, + hasProSeat: organizationMembers.hasProSeat, + }) + .from(organizationMembers) + .where(eq(organizationMembers.organizationId, organizationId)); + + const [owner] = await tx + .select({ + inviteQuota: users.inviteQuota, + stripeSubscriptionId: users.stripeSubscriptionId, + }) + .from(users) + .where(eq(users.id, organization.ownerId)) + .limit(1); + + const { proSeatsRemaining } = calculateProSeats({ + inviteQuota: owner?.inviteQuota ?? 1, + members: allMembers, + }); + + if (proSeatsRemaining <= 0) { + throw new Error( + "No Pro seats remaining. Purchase more seats to continue.", + ); + } + + await tx + .update(organizationMembers) + .set({ hasProSeat: true }) + .where(eq(organizationMembers.id, memberId)); + + if (owner?.stripeSubscriptionId) { + await tx + .update(users) + .set({ thirdPartyStripeSubscriptionId: owner.stripeSubscriptionId }) + .where(eq(users.id, member.userId)); + } + } else { + await tx + .update(organizationMembers) + .set({ hasProSeat: false }) + .where(eq(organizationMembers.id, memberId)); + + const otherProSeats = await tx + .select({ id: organizationMembers.id }) + .from(organizationMembers) + .where( + and( + eq(organizationMembers.userId, member.userId), + eq(organizationMembers.hasProSeat, true), + ), + ) + .limit(1); + + if (otherProSeats.length === 0) { + await tx + .update(users) + .set({ thirdPartyStripeSubscriptionId: null }) + .where(eq(users.id, member.userId)); + } + } + }); + + revalidatePath("/dashboard/settings/organization"); + return { success: true }; +} From 272923c4b74c40d1c392f87c6d2a96ed197a12fc Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:53:32 +0000 Subject: [PATCH 06/38] feat(org): add seat quantity management server action --- .../organization/update-seat-quantity.ts | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 apps/web/actions/organization/update-seat-quantity.ts diff --git a/apps/web/actions/organization/update-seat-quantity.ts b/apps/web/actions/organization/update-seat-quantity.ts new file mode 100644 index 0000000000..78749d94a6 --- /dev/null +++ b/apps/web/actions/organization/update-seat-quantity.ts @@ -0,0 +1,150 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { + organizationMembers, + organizations, + users, +} from "@cap/database/schema"; +import { stripe } from "@cap/utils"; +import type { Organisation } from "@cap/web-domain"; +import { eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { calculateProSeats } from "@/utils/organization"; + +async function getOwnerSubscription( + organizationId: Organisation.OrganisationId, +) { + 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) throw new Error("Organization not found"); + if (organization.ownerId !== user.id) + throw new Error("Only the owner can manage seats"); + + const [owner] = await db() + .select({ + stripeSubscriptionId: users.stripeSubscriptionId, + stripeCustomerId: users.stripeCustomerId, + inviteQuota: users.inviteQuota, + }) + .from(users) + .where(eq(users.id, user.id)) + .limit(1); + + if (!owner?.stripeSubscriptionId || !owner.stripeCustomerId) { + throw new Error("No active subscription found"); + } + + const subscription = await stripe().subscriptions.retrieve( + owner.stripeSubscriptionId, + ); + + const subscriptionItem = subscription.items.data[0]; + if (!subscriptionItem) { + throw new Error("No subscription item found"); + } + + const allMembers = await db() + .select({ + id: organizationMembers.id, + hasProSeat: organizationMembers.hasProSeat, + }) + .from(organizationMembers) + .where(eq(organizationMembers.organizationId, organizationId)); + + const { proSeatsUsed } = calculateProSeats({ + inviteQuota: owner.inviteQuota ?? 1, + members: allMembers, + }); + + return { owner, subscription, subscriptionItem, proSeatsUsed, user }; +} + +const MAX_SEATS = 500; + +function validateQuantity(quantity: number): void { + if (!Number.isInteger(quantity) || quantity < 1 || quantity > MAX_SEATS) { + throw new Error(`Quantity must be an integer between 1 and ${MAX_SEATS}`); + } +} + +export async function previewSeatChange( + organizationId: Organisation.OrganisationId, + newQuantity: number, +) { + validateQuantity(newQuantity); + const { owner, subscriptionItem, proSeatsUsed } = + await getOwnerSubscription(organizationId); + + if (newQuantity < proSeatsUsed) { + throw new Error( + `Cannot reduce below ${proSeatsUsed} seats (currently assigned)`, + ); + } + + const preview = await stripe().invoices.retrieveUpcoming({ + customer: owner.stripeCustomerId, + subscription: owner.stripeSubscriptionId, + subscription_items: [ + { + id: subscriptionItem.id, + quantity: newQuantity, + }, + ], + subscription_proration_behavior: "create_prorations", + }); + + const currentQuantity = subscriptionItem.quantity || 1; + const proratedAmount = preview.amount_due; + const nextPaymentDate = preview.period_end; + + return { + proratedAmount, + nextPaymentDate, + currentQuantity, + newQuantity, + currency: preview.currency, + }; +} + +export async function updateSeatQuantity( + organizationId: Organisation.OrganisationId, + newQuantity: number, +) { + validateQuantity(newQuantity); + const { subscription, subscriptionItem, proSeatsUsed, user } = + await getOwnerSubscription(organizationId); + + if (newQuantity < proSeatsUsed) { + throw new Error( + `Cannot reduce below ${proSeatsUsed} seats (currently assigned)`, + ); + } + + await stripe().subscriptions.update(subscription.id, { + items: [ + { + id: subscriptionItem.id, + quantity: newQuantity, + }, + ], + proration_behavior: "create_prorations", + }); + + await db() + .update(users) + .set({ inviteQuota: newQuantity }) + .where(eq(users.id, user.id)); + + revalidatePath("/dashboard/settings/organization"); + + return { success: true, newQuantity }; +} From 7e7520f55b4734ff0f5ecf45ffb356094ad8536b Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:53:36 +0000 Subject: [PATCH 07/38] refactor(invites): rewrite invite accept with transactions and Pro seat assignment --- apps/web/app/api/invite/accept/route.ts | 184 ++++++++++++++++-------- 1 file changed, 122 insertions(+), 62 deletions(-) diff --git a/apps/web/app/api/invite/accept/route.ts b/apps/web/app/api/invite/accept/route.ts index 71d216a795..aae8e3c3a3 100644 --- a/apps/web/app/api/invite/accept/route.ts +++ b/apps/web/app/api/invite/accept/route.ts @@ -4,10 +4,12 @@ import { nanoId } from "@cap/database/helpers"; import { organizationInvites, organizationMembers, + organizations, users, } from "@cap/database/schema"; import { and, eq } from "drizzle-orm"; import { type NextRequest, NextResponse } from "next/server"; +import { calculateProSeats } from "@/utils/organization"; export async function POST(request: NextRequest) { const user = await getCurrentUser(); @@ -17,78 +19,136 @@ export async function POST(request: NextRequest) { const { inviteId } = await request.json(); + if (typeof inviteId !== "string" || !inviteId) { + return NextResponse.json({ error: "Invalid invite ID" }, { status: 400 }); + } + try { - // Find the invite - const [invite] = await db() - .select() - .from(organizationInvites) - .where(eq(organizationInvites.id, inviteId)); - - if (!invite) { - return NextResponse.json({ error: "Invite not found" }, { status: 404 }); - } + await db().transaction(async (tx) => { + const [invite] = await tx + .select() + .from(organizationInvites) + .where(eq(organizationInvites.id, inviteId)) + .for("update"); - if (user.email !== invite.invitedEmail) { - return NextResponse.json({ error: "Email mismatch" }, { status: 403 }); - } + if (!invite) { + throw new Error("INVITE_NOT_FOUND"); + } - const [organizationOwner] = await db() - .select({ - stripeSubscriptionId: users.stripeSubscriptionId, - }) - .from(users) - .where(eq(users.id, invite.invitedByUserId)); - - if (!organizationOwner || !organizationOwner.stripeSubscriptionId) { - return NextResponse.json( - { error: "Organization owner not found or has no subscription" }, - { status: 404 }, - ); - } + if (user.email.toLowerCase() !== invite.invitedEmail.toLowerCase()) { + throw new Error("EMAIL_MISMATCH"); + } - const [existingMembership] = await db() - .select({ id: organizationMembers.id }) - .from(organizationMembers) - .where( - and( - eq(organizationMembers.organizationId, invite.organizationId), - eq(organizationMembers.userId, user.id), - ), - ) - .limit(1); - - if (!existingMembership) { - await db().insert(organizationMembers).values({ - id: nanoId(), - organizationId: invite.organizationId, - userId: user.id, - role: invite.role, - }); - } + const [existingMembership] = await tx + .select({ id: organizationMembers.id }) + .from(organizationMembers) + .where( + and( + eq(organizationMembers.organizationId, invite.organizationId), + eq(organizationMembers.userId, user.id), + ), + ) + .limit(1); + + let memberId = existingMembership?.id; + + if (!existingMembership) { + const newId = nanoId(); + await tx.insert(organizationMembers).values({ + id: newId, + organizationId: invite.organizationId, + userId: user.id, + role: invite.role, + }); + memberId = newId; + } + + const [org] = await tx + .select({ ownerId: organizations.ownerId }) + .from(organizations) + .where(eq(organizations.id, invite.organizationId)) + .limit(1); - const onboardingSteps = { - ...(user.onboardingSteps ?? {}), - organizationSetup: true, - customDomain: true, - inviteTeam: true, - }; - - await db() - .update(users) - .set({ - thirdPartyStripeSubscriptionId: organizationOwner.stripeSubscriptionId, - activeOrganizationId: invite.organizationId, - defaultOrgId: invite.organizationId, + if (org && memberId) { + const [owner] = await tx + .select({ + inviteQuota: users.inviteQuota, + stripeSubscriptionId: users.stripeSubscriptionId, + }) + .from(users) + .where(eq(users.id, org.ownerId)) + .limit(1); + + if (owner?.stripeSubscriptionId) { + const allMembers = await tx + .select({ + id: organizationMembers.id, + hasProSeat: organizationMembers.hasProSeat, + }) + .from(organizationMembers) + .where( + eq(organizationMembers.organizationId, invite.organizationId), + ) + .for("update"); + + const { proSeatsRemaining } = calculateProSeats({ + inviteQuota: owner.inviteQuota ?? 1, + members: allMembers, + }); + + if (proSeatsRemaining > 0) { + await tx + .update(organizationMembers) + .set({ hasProSeat: true }) + .where(eq(organizationMembers.id, memberId)); + + await tx + .update(users) + .set({ + thirdPartyStripeSubscriptionId: owner.stripeSubscriptionId, + }) + .where(eq(users.id, user.id)); + } + } + } + + const onboardingSteps = { + ...(user.onboardingSteps ?? {}), + organizationSetup: true, + customDomain: true, + inviteTeam: true, + }; + + const userUpdate: Partial = { onboardingSteps, - }) - .where(eq(users.id, user.id)); + }; + if (!user.activeOrganizationId) { + userUpdate.activeOrganizationId = invite.organizationId; + } + if (!user.defaultOrgId) { + userUpdate.defaultOrgId = invite.organizationId; + } - await db() - .delete(organizationInvites) - .where(eq(organizationInvites.id, inviteId)); + await tx.update(users).set(userUpdate).where(eq(users.id, user.id)); + + await tx + .delete(organizationInvites) + .where(eq(organizationInvites.id, inviteId)); + }); return NextResponse.json({ success: true }); } catch (error) { + if (error instanceof Error) { + if (error.message === "INVITE_NOT_FOUND") { + return NextResponse.json( + { error: "Invite not found" }, + { status: 404 }, + ); + } + if (error.message === "EMAIL_MISMATCH") { + return NextResponse.json({ error: "Email mismatch" }, { status: 403 }); + } + } console.error("Error accepting invite:", error); return NextResponse.json( { error: "Internal server error" }, From a6c7cb41e6389b357662043781344572e54e216f Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:53:39 +0000 Subject: [PATCH 08/38] feat(dashboard): add invite dialog state to dashboard context --- apps/web/app/(org)/dashboard/Contexts.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/web/app/(org)/dashboard/Contexts.tsx b/apps/web/app/(org)/dashboard/Contexts.tsx index faa2a7f135..44c86c4c0c 100644 --- a/apps/web/app/(org)/dashboard/Contexts.tsx +++ b/apps/web/app/(org)/dashboard/Contexts.tsx @@ -4,6 +4,7 @@ import { buildEnv } from "@cap/env"; import Cookies from "js-cookie"; import { redirect, usePathname } from "next/navigation"; import { createContext, useContext, useEffect, useState } from "react"; +import { InviteDialog } from "@/app/(org)/dashboard/settings/organization/components/InviteDialog"; import { type CurrentUser, useCurrentUser } from "@/app/Layout/AuthContext"; import { UpgradeModal } from "@/components/UpgradeModal"; import type { @@ -30,6 +31,8 @@ type SharedContext = { sidebarCollapsed: boolean; upgradeModalOpen: boolean; setUpgradeModalOpen: (open: boolean) => void; + inviteDialogOpen: boolean; + setInviteDialogOpen: (open: boolean) => void; referClickedState: boolean; setReferClickedStateHandler: (referClicked: boolean) => void; isDeveloperSection: boolean; @@ -86,6 +89,7 @@ export function DashboardContexts({ initialSidebarCollapsed, ); const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); + const [inviteDialogOpen, setInviteDialogOpen] = useState(false); const [referClickedState, setReferClickedState] = useState(referClicked); const [developerApps, setDeveloperApps] = useState( null, @@ -182,6 +186,8 @@ export function DashboardContexts({ sidebarCollapsed, upgradeModalOpen, setUpgradeModalOpen, + inviteDialogOpen, + setInviteDialogOpen, referClickedState, setReferClickedStateHandler, isDeveloperSection, @@ -191,7 +197,11 @@ export function DashboardContexts({ > {children} - {/* Global upgrade modal that persists regardless of navigation state */} + + {buildEnv.NEXT_PUBLIC_IS_CAP && ( Date: Tue, 3 Mar 2026 14:53:43 +0000 Subject: [PATCH 09/38] refactor(org): simplify InviteDialog and remove seat-limit checks --- .../organization/components/InviteDialog.tsx | 157 +++++------------- 1 file changed, 42 insertions(+), 115 deletions(-) diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/InviteDialog.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/InviteDialog.tsx index 86e32d8d3a..0b34c46557 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/components/InviteDialog.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/components/InviteDialog.tsx @@ -9,41 +9,26 @@ import { DialogTitle, Input, } from "@cap/ui"; -import type { Organisation } from "@cap/web-domain"; import { faUserGroup } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useMutation } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; -import { useState } from "react"; +import { useId, useState } from "react"; import { toast } from "sonner"; import { sendOrganizationInvites } from "@/actions/organization/send-invites"; -import { calculateSeats } from "@/utils/organization"; import { useDashboardContext } from "../../../Contexts"; interface InviteDialogProps { isOpen: boolean; - setIsOpen: (isOpen: boolean) => void; - isOwner: boolean; - showOwnerToast: () => void; - handleManageBilling: () => Promise; + setIsOpen: (open: boolean) => void; } -export const InviteDialog = ({ - isOpen, - setIsOpen, - isOwner, - showOwnerToast, - handleManageBilling, -}: InviteDialogProps) => { +export const InviteDialog = ({ isOpen, setIsOpen }: InviteDialogProps) => { const router = useRouter(); const { activeOrganization } = useDashboardContext(); const [inviteEmails, setInviteEmails] = useState([]); const [emailInput, setEmailInput] = useState(""); - const [upgradeLoading, setUpgradeLoading] = useState(false); - - const { inviteQuota, remainingSeats } = calculateSeats( - activeOrganization || {}, - ); + const emailInputId = useId(); const handleAddEmails = () => { const newEmails = emailInput @@ -51,13 +36,6 @@ export const InviteDialog = ({ .map((email) => email.trim()) .filter((email) => email !== ""); - if (inviteEmails.length + newEmails.length > remainingSeats) { - toast.error( - `Not enough seats available. You have ${remainingSeats} seats remaining.`, - ); - return; - } - setInviteEmails([...new Set([...inviteEmails, ...newEmails])]); setEmailInput(""); }; @@ -66,37 +44,14 @@ export const InviteDialog = ({ setInviteEmails(inviteEmails.filter((e) => e !== email)); }; - const handleUpgradePlan = async () => { - if (!isOwner) { - showOwnerToast(); - return; - } - - setUpgradeLoading(true); - setIsOpen(false); - try { - await handleManageBilling(); - } catch (_error) { - setUpgradeLoading(false); - } - }; - const sendInvites = useMutation({ mutationFn: async () => { - if (!isOwner) { - showOwnerToast(); - throw new Error("Not authorized"); - } - - if (inviteEmails.length > remainingSeats) { - throw new Error( - `Not enough seats available. You have ${remainingSeats} seats remaining.`, - ); + if (!activeOrganization?.organization.id) { + throw new Error("No active organization"); } - return await sendOrganizationInvites( inviteEmails, - activeOrganization?.organization.id as Organisation.OrganisationId, + activeOrganization.organization.id, ); }, onSuccess: () => { @@ -130,66 +85,42 @@ export const InviteDialog = ({
- {remainingSeats > 0 ? ( - <> - setEmailInput(e.target.value)} - placeholder="name@company.com" - onBlur={handleAddEmails} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === ",") { - e.preventDefault(); - handleAddEmails(); + setEmailInput(e.target.value)} + placeholder="name@company.com" + onBlur={handleAddEmails} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + handleAddEmails(); + } + }} + /> +
+ {inviteEmails.map((email) => ( +
+ {email} + -
- ))} + type="button" + variant="destructive" + size="xs" + onClick={() => handleRemoveEmail(email)} + > + Remove +
- - ) : ( -
-

No Seats Available

-

- You've reached your seat limit. Please upgrade your plan or - remove existing members to invite new ones. -

- -
- )} + ))} +
- - )} - - + - {"Member"} - {"Email"} - {"Role"} - {"Joined"} - {"Status"} - {"Actions"} + Member + Email + Role + {buildEnv.NEXT_PUBLIC_IS_CAP && Pro} + Joined + Status + Actions - {activeOrganization?.members?.map((member) => ( - - {member.user.name} - {member.user.email} - - {isMemberOwner(member.user.id) ? "Owner" : "Member"} - - {format(member.createdAt, "MMM d, yyyy")} - Active - - {!isMemberOwner(member.user.id) ? ( - - ) : ( - "-" + {activeOrganization?.members?.map((member) => { + const memberIsOwner = isMemberOwner(member.user.id); + return ( + + {member.user.name} + {member.user.email} + {memberIsOwner ? "Owner" : "Member"} + {buildEnv.NEXT_PUBLIC_IS_CAP && ( + + {memberIsOwner ? ( + - + ) : ( + + toggleProSeatMutation.mutate({ + memberId: member.id, + enable: checked, + }) + } + disabled={ + !isOwner || + (toggleProSeatMutation.isPending && + toggleProSeatMutation.variables?.memberId === + member.id) || + (!member.hasProSeat && proSeatsRemaining <= 0) + } + /> + )} + )} - - - ))} + + {format(member.createdAt, "MMM d, yyyy")} + + Active + + {!memberIsOwner ? ( + + ) : ( + "-" + )} + + + ); + })} {activeOrganization?.invites?.map((invite) => ( - {invite.id} + Pending {invite.invitedEmail} Member + {buildEnv.NEXT_PUBLIC_IS_CAP && -} - Invited @@ -258,14 +284,16 @@ export const MembersCard = ({ variant="destructive" onClick={() => { if (isOwner) { - handleDeleteInvite(invite.id); + deleteInviteMutation.mutate(invite.id); } else { showOwnerToast(); } }} - disabled={!isOwner} + disabled={!isOwner || deletingInviteId === invite.id} > - Delete Invite + {deletingInviteId === invite.id + ? "Deleting..." + : "Delete Invite"} From da98ca656ba8d34ef56c4340dce34864359cdfe8 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:53:47 +0000 Subject: [PATCH 11/38] feat(org): add BillingSummaryCard and SeatManagementCard components --- .../components/BillingSummaryCard.tsx | 123 ++++++++++++ .../components/SeatManagementCard.tsx | 183 ++++++++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 apps/web/app/(org)/dashboard/settings/organization/components/BillingSummaryCard.tsx create mode 100644 apps/web/app/(org)/dashboard/settings/organization/components/SeatManagementCard.tsx diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/BillingSummaryCard.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/BillingSummaryCard.tsx new file mode 100644 index 0000000000..eb5a3bb462 --- /dev/null +++ b/apps/web/app/(org)/dashboard/settings/organization/components/BillingSummaryCard.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { Button, Card, CardDescription, CardHeader, CardTitle } from "@cap/ui"; +import { useQuery } from "@tanstack/react-query"; +import { format } from "date-fns"; +import { useRouter } from "next/navigation"; +import { useCallback, useState } from "react"; +import { toast } from "sonner"; +import { + getSubscriptionDetails, + type SubscriptionDetails, +} from "@/actions/organization/get-subscription-details"; +import { manageBilling } from "@/actions/organization/manage-billing"; +import { useDashboardContext } from "@/app/(org)/dashboard/Contexts"; + +export function BillingSummaryCard() { + const { activeOrganization, setUpgradeModalOpen } = useDashboardContext(); + const router = useRouter(); + const [billingLoading, setBillingLoading] = useState(false); + const organizationId = activeOrganization?.organization.id; + + const { data: subscription, isLoading } = + useQuery({ + queryKey: ["subscription-details", organizationId], + queryFn: () => { + if (!organizationId) return null; + return getSubscriptionDetails(organizationId); + }, + enabled: !!organizationId, + staleTime: 60 * 1000, + }); + + const handleManageBilling = useCallback(async () => { + setBillingLoading(true); + try { + const url = await manageBilling(); + router.push(url); + } catch { + toast.error("An error occurred while managing billing"); + } finally { + setBillingLoading(false); + } + }, [router]); + + if (isLoading) { + return ( + +
+
+
+
+
+ + ); + } + + if (!subscription) { + return ( + + + Upgrade to Cap Pro + + Get unlimited sharing, custom domains, Cap AI, and more. + + + + + ); + } + + const statusLabel = + subscription.status === "trialing" ? "Trialing" : "Active"; + const intervalLabel = + subscription.billingInterval === "year" ? "annually" : "monthly"; + const totalAmount = subscription.pricePerSeat * subscription.currentQuantity; + const nextBillingDate = format( + new Date(subscription.currentPeriodEnd * 1000), + "MMM d, yyyy", + ); + + return ( + +
+
+
+

+ {subscription.planName} +

+ + {statusLabel} + +
+
+

+ ${subscription.pricePerSeat.toFixed(2)}/seat/mo ( + {subscription.currentQuantity}{" "} + {subscription.currentQuantity === 1 ? "seat" : "seats"} = $ + {totalAmount.toFixed(2)}/mo, billed {intervalLabel}) +

+

Next billing date: {nextBillingDate}

+
+
+ +
+
+ ); +} diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/SeatManagementCard.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/SeatManagementCard.tsx new file mode 100644 index 0000000000..33cb3f9895 --- /dev/null +++ b/apps/web/app/(org)/dashboard/settings/organization/components/SeatManagementCard.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { Button, Card, CardDescription, CardHeader, CardTitle } from "@cap/ui"; +import NumberFlow from "@number-flow/react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Minus, Plus } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import type { SubscriptionDetails } from "@/actions/organization/get-subscription-details"; +import { + previewSeatChange, + updateSeatQuantity, +} from "@/actions/organization/update-seat-quantity"; +import { useDashboardContext } from "@/app/(org)/dashboard/Contexts"; +import { calculateSeats } from "@/utils/organization"; + +const DEBOUNCE_MS = 500; + +export function SeatManagementCard() { + const { activeOrganization } = useDashboardContext(); + const router = useRouter(); + const queryClient = useQueryClient(); + const organizationId = activeOrganization?.organization.id; + + const { proSeatsUsed, proSeatsTotal } = calculateSeats( + activeOrganization || {}, + ); + + const [desiredQuantity, setDesiredQuantity] = useState(proSeatsTotal); + const [debouncedQuantity, setDebouncedQuantity] = useState(proSeatsTotal); + const debounceTimer = useRef | null>(null); + const prevProSeatsTotal = useRef(proSeatsTotal); + + useEffect(() => { + if (prevProSeatsTotal.current !== proSeatsTotal) { + setDesiredQuantity(proSeatsTotal); + setDebouncedQuantity(proSeatsTotal); + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + prevProSeatsTotal.current = proSeatsTotal; + } + }, [proSeatsTotal]); + + useEffect(() => { + return () => { + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + }; + }, []); + + const updateDesiredQuantity = (newQuantity: number) => { + setDesiredQuantity(newQuantity); + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + debounceTimer.current = setTimeout(() => { + setDebouncedQuantity(newQuantity); + }, DEBOUNCE_MS); + }; + + const hasChanges = desiredQuantity !== proSeatsTotal; + const debouncedHasChanges = debouncedQuantity !== proSeatsTotal; + + const { data: preview, isFetching: previewLoading } = useQuery({ + queryKey: ["seat-preview", organizationId, debouncedQuantity], + queryFn: () => { + if (!organizationId) return null; + return previewSeatChange(organizationId, debouncedQuantity); + }, + enabled: !!organizationId && debouncedHasChanges, + staleTime: 30 * 1000, + }); + + const updateMutation = useMutation({ + mutationFn: () => { + if (!organizationId) throw new Error("No organization"); + return updateSeatQuantity(organizationId, desiredQuantity); + }, + onSuccess: (result) => { + toast.success(`Seat quantity updated to ${result.newQuantity}`); + queryClient.setQueriesData( + { queryKey: ["subscription-details", organizationId] }, + (old) => (old ? { ...old, currentQuantity: result.newQuantity } : old), + ); + router.refresh(); + }, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : "Failed to update seats", + ); + }, + }); + + const canDecrease = desiredQuantity > Math.max(1, proSeatsUsed); + + return ( + + + Pro Seats + + Manage how many Pro seats are available for your team. + + +
+
+ + {proSeatsUsed} of{" "} + {proSeatsTotal}{" "} + Pro seats assigned + +
+ +
+
+ Seats: + + + +
+ + {hasChanges && ( +
+ {previewLoading ? ( + Calculating... + ) : preview ? ( + + Prorated charge: ${(preview.proratedAmount / 100).toFixed(2)}{" "} + {preview.currency.toUpperCase()} + + ) : null} + + +
+ )} +
+
+
+ ); +} From 3e55f5f5519b00300117c213fd8308ef8db88f4d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:53:50 +0000 Subject: [PATCH 12/38] feat(org): add settings nav and layout with auth guard --- .../organization/_components/SettingsNav.tsx | 57 +++++++++++++++++++ .../settings/organization/layout.tsx | 46 +++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 apps/web/app/(org)/dashboard/settings/organization/_components/SettingsNav.tsx create mode 100644 apps/web/app/(org)/dashboard/settings/organization/layout.tsx diff --git a/apps/web/app/(org)/dashboard/settings/organization/_components/SettingsNav.tsx b/apps/web/app/(org)/dashboard/settings/organization/_components/SettingsNav.tsx new file mode 100644 index 0000000000..12eb1d7bdb --- /dev/null +++ b/apps/web/app/(org)/dashboard/settings/organization/_components/SettingsNav.tsx @@ -0,0 +1,57 @@ +"use client"; + +import clsx from "clsx"; +import { motion } from "framer-motion"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +const tabs = [ + { label: "General", href: "/dashboard/settings/organization" }, + { + label: "Preferences", + href: "/dashboard/settings/organization/preferences", + }, + { + label: "Billing & Members", + href: "/dashboard/settings/organization/billing", + }, +] as const; + +export function SettingsNav() { + const pathname = usePathname(); + + return ( +
+ {tabs.map((tab) => { + const isActive = pathname === tab.href; + + return ( +
+ +

+ {tab.label} +

+ + {isActive && ( + + )} +
+ ); + })} +
+ ); +} diff --git a/apps/web/app/(org)/dashboard/settings/organization/layout.tsx b/apps/web/app/(org)/dashboard/settings/organization/layout.tsx new file mode 100644 index 0000000000..ecd5459b2e --- /dev/null +++ b/apps/web/app/(org)/dashboard/settings/organization/layout.tsx @@ -0,0 +1,46 @@ +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { organizationMembers, organizations } from "@cap/database/schema"; +import { and, eq } from "drizzle-orm"; +import { redirect } from "next/navigation"; +import { SettingsNav } from "./_components/SettingsNav"; + +export default async function OrganizationSettingsLayout({ + children, +}: { + children: React.ReactNode; +}) { + const user = await getCurrentUser(); + + if (!user) { + 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), + ), + ) + .limit(1); + + if (!member || member.role !== "owner") { + redirect("/dashboard/caps"); + } + + return ( +
+ + {children} +
+ ); +} From 8611afda5fe701b9c2436f2f8152ce5daa65d503 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:53:56 +0000 Subject: [PATCH 13/38] refactor(org): restructure organization settings into sub-pages --- .../settings/organization/GeneralPage.tsx | 15 + .../settings/organization/Organization.tsx | 97 ------ .../settings/organization/billing/page.tsx | 38 ++ .../organization/components/BillingCard.tsx | 37 -- .../components/SeatsInfoCards.tsx | 43 --- .../settings/organization/loading.tsx | 328 ++---------------- .../settings/organization/members/page.tsx | 5 + .../dashboard/settings/organization/page.tsx | 38 +- .../organization/preferences/page.tsx | 10 + 9 files changed, 95 insertions(+), 516 deletions(-) create mode 100644 apps/web/app/(org)/dashboard/settings/organization/GeneralPage.tsx delete mode 100644 apps/web/app/(org)/dashboard/settings/organization/Organization.tsx create mode 100644 apps/web/app/(org)/dashboard/settings/organization/billing/page.tsx delete mode 100644 apps/web/app/(org)/dashboard/settings/organization/components/BillingCard.tsx delete mode 100644 apps/web/app/(org)/dashboard/settings/organization/components/SeatsInfoCards.tsx create mode 100644 apps/web/app/(org)/dashboard/settings/organization/members/page.tsx create mode 100644 apps/web/app/(org)/dashboard/settings/organization/preferences/page.tsx diff --git a/apps/web/app/(org)/dashboard/settings/organization/GeneralPage.tsx b/apps/web/app/(org)/dashboard/settings/organization/GeneralPage.tsx new file mode 100644 index 0000000000..e6eb7fc73f --- /dev/null +++ b/apps/web/app/(org)/dashboard/settings/organization/GeneralPage.tsx @@ -0,0 +1,15 @@ +"use client"; + +import DeleteOrg from "./components/DeleteOrg"; +import { OrganizationDetailsCard } from "./components/OrganizationDetailsCard"; + +export function GeneralPage() { + return ( +
+
+ +
+ +
+ ); +} diff --git a/apps/web/app/(org)/dashboard/settings/organization/Organization.tsx b/apps/web/app/(org)/dashboard/settings/organization/Organization.tsx deleted file mode 100644 index 8d6fa89ee4..0000000000 --- a/apps/web/app/(org)/dashboard/settings/organization/Organization.tsx +++ /dev/null @@ -1,97 +0,0 @@ -"use client"; - -import { useRouter } from "next/navigation"; -import { - type Dispatch, - type SetStateAction, - useCallback, - useRef, - useState, -} from "react"; -import { toast } from "sonner"; -import { manageBilling } from "@/actions/organization/manage-billing"; -import { useDashboardContext } from "@/app/(org)/dashboard/Contexts"; -import { BillingCard } from "./components/BillingCard"; -import CapSettingsCard from "./components/CapSettingsCard"; -import DeleteOrg from "./components/DeleteOrg"; -import { InviteDialog } from "./components/InviteDialog"; -import { MembersCard } from "./components/MembersCard"; -import { OrganizationDetailsCard } from "./components/OrganizationDetailsCard"; -import { SeatsInfoCards } from "./components/SeatsInfoCards"; - -export const Organization = () => { - const { activeOrganization, user } = useDashboardContext(); - const router = useRouter(); - const [loading, setLoading] = useState(false); - const [billingLoading, setBillingLoading] = useState(false); - const isOwner = user?.id === activeOrganization?.organization.ownerId; - const [isInviteDialogOpen, setIsInviteDialogOpen] = useState(false); - const ownerToastShown = useRef(false); - - const showOwnerToast = useCallback(() => { - if (!ownerToastShown.current) { - toast.error("Only the owner can make changes"); - ownerToastShown.current = true; - setTimeout(() => { - ownerToastShown.current = false; - }, 3000); - } - }, []); - - const handleManageBilling = useCallback( - async (loadingDispatch: Dispatch>) => { - if (!isOwner) { - showOwnerToast(); - return; - } - loadingDispatch(true); - try { - const url = await manageBilling(); - router.push(url); - } catch (error) { - console.error("Error managing billing:", error); - toast.error("An error occurred while managing billing"); - loadingDispatch(false); - } - }, - [isOwner, showOwnerToast, router], - ); - - return ( -
- - -
- -
- -
- -
- - handleManageBilling(setLoading)} - showOwnerToast={showOwnerToast} - setIsInviteDialogOpen={setIsInviteDialogOpen} - /> - - handleManageBilling(setBillingLoading)} - /> - - handleManageBilling(setLoading)} - /> - - - - ); -}; diff --git a/apps/web/app/(org)/dashboard/settings/organization/billing/page.tsx b/apps/web/app/(org)/dashboard/settings/organization/billing/page.tsx new file mode 100644 index 0000000000..f7ec256b21 --- /dev/null +++ b/apps/web/app/(org)/dashboard/settings/organization/billing/page.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { buildEnv } from "@cap/env"; +import { useCallback, useRef } from "react"; +import { toast } from "sonner"; +import { useDashboardContext } from "@/app/(org)/dashboard/Contexts"; +import { BillingSummaryCard } from "../components/BillingSummaryCard"; +import { MembersCard } from "../components/MembersCard"; +import { SeatManagementCard } from "../components/SeatManagementCard"; + +export default function BillingAndMembersPage() { + const { activeOrganization, user, setInviteDialogOpen } = + useDashboardContext(); + const isOwner = user?.id === activeOrganization?.organization.ownerId; + const ownerToastShown = useRef(false); + + const showOwnerToast = useCallback(() => { + if (!ownerToastShown.current) { + toast.error("Only the owner can make changes"); + ownerToastShown.current = true; + setTimeout(() => { + ownerToastShown.current = false; + }, 3000); + } + }, []); + + return ( +
+ {buildEnv.NEXT_PUBLIC_IS_CAP && } + {buildEnv.NEXT_PUBLIC_IS_CAP && } + +
+ ); +} diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/BillingCard.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/BillingCard.tsx deleted file mode 100644 index 9d595e3e9f..0000000000 --- a/apps/web/app/(org)/dashboard/settings/organization/components/BillingCard.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client"; - -import { Button, Card, CardDescription, CardHeader, CardTitle } from "@cap/ui"; - -interface BillingCardProps { - isOwner: boolean; - loading: boolean; - handleManageBilling: () => Promise; -} - -export const BillingCard = ({ - isOwner, - loading, - handleManageBilling, -}: BillingCardProps) => { - return ( - - - View and manage your billing details - - View and edit your billing details, as well as manage your - subscription. - - - - - ); -}; diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/SeatsInfoCards.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/SeatsInfoCards.tsx deleted file mode 100644 index 1654c2e87b..0000000000 --- a/apps/web/app/(org)/dashboard/settings/organization/components/SeatsInfoCards.tsx +++ /dev/null @@ -1,43 +0,0 @@ -"use client"; - -import { Card } from "@cap/ui"; -import { faChair, faUserGroup } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { calculateSeats } from "@/utils/organization"; - -import { useDashboardContext } from "../../../Contexts"; - -export const SeatsInfoCards = () => { - const { activeOrganization } = useDashboardContext(); - const { inviteQuota, remainingSeats } = calculateSeats( - activeOrganization || {}, - ); - - return ( -
- -
- -
-

- Seats Remaining - - {remainingSeats} - -

-
- -
- -
-

- Seats Capacity - {inviteQuota} -

-
-
- ); -}; diff --git a/apps/web/app/(org)/dashboard/settings/organization/loading.tsx b/apps/web/app/(org)/dashboard/settings/organization/loading.tsx index 8865cfb742..4c6fb97ba9 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/loading.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/loading.tsx @@ -7,325 +7,45 @@ export default function Loading() { (
- {/* Seats stats cards */} -
- {/* Seats Remaining card */} -
-
- -
-
- - - - - - -
-
- - {/* Seats Capacity card */} -
-
- -
-
- - - - - - -
-
-
- - {/* Organization details wrapper (matches Organization.tsx) */} -
- {/* Organization Details Card */} -
- {/* Card Header */} -
- - -
- - {/* Two-column settings grid */} -
- {/* Left column: Name */} -
-
- - -
-
- - -
-
- - {/* Right column: Custom Domain */} -
-
- - -
-
- - -
-
- - {/* Left column: Access email domain */} -
-
- - -
-
- - -
-
- - {/* Right column: Organization Icon */} -
-
- - -
-
-
- -
- -
-
-
-
-
- - {/* Members Card */} -
- {/* Card Header */} -
-
- - -
-
- - -
+
+
+ +
- - {/* Members List */} -
- {Array(3) +
+ {Array(4) .fill(0) - .map((_, index) => ( -
-
- -
- - -
-
-
+ .map((_, i) => ( +
+
+
))}
- - {/* Billing Card */} -
- {/* Card Header */} -
-
- - -
- -
- - {/* Billing Info */} -
-
-
- - -
- -
-
-
)} /> diff --git a/apps/web/app/(org)/dashboard/settings/organization/members/page.tsx b/apps/web/app/(org)/dashboard/settings/organization/members/page.tsx new file mode 100644 index 0000000000..27a2682817 --- /dev/null +++ b/apps/web/app/(org)/dashboard/settings/organization/members/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function MembersPage() { + redirect("/dashboard/settings/organization/billing"); +} diff --git a/apps/web/app/(org)/dashboard/settings/organization/page.tsx b/apps/web/app/(org)/dashboard/settings/organization/page.tsx index 8e134c645f..21dbcf17ec 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/page.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/page.tsx @@ -1,42 +1,10 @@ -import { db } from "@cap/database"; -import { getCurrentUser } from "@cap/database/auth/session"; -import { organizationMembers, organizations } from "@cap/database/schema"; -import { and, eq } from "drizzle-orm"; import type { Metadata } from "next"; -import { redirect } from "next/navigation"; -import { Organization } from "./Organization"; +import { GeneralPage } from "./GeneralPage"; export const metadata: Metadata = { title: "Organization Settings — Cap", }; -export default async function OrganizationPage() { - const user = await getCurrentUser(); - - if (!user) { - redirect("/auth/signin"); - } - - const [member] = await db() - .select({ - role: organizationMembers.role, - }) - .from(organizationMembers) - .limit(1) - .leftJoin( - organizations, - eq(organizationMembers.organizationId, organizations.id), - ) - .where( - and( - eq(organizationMembers.userId, user.id), - eq(organizations.id, user.activeOrganizationId), - ), - ); - - if (!member || member.role !== "owner") { - redirect("/dashboard/caps"); - } - - return ; +export default function OrganizationPage() { + return ; } diff --git a/apps/web/app/(org)/dashboard/settings/organization/preferences/page.tsx b/apps/web/app/(org)/dashboard/settings/organization/preferences/page.tsx new file mode 100644 index 0000000000..3c6b8b1033 --- /dev/null +++ b/apps/web/app/(org)/dashboard/settings/organization/preferences/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import CapSettingsCard from "../components/CapSettingsCard"; + +export const metadata: Metadata = { + title: "Organization Preferences — Cap", +}; + +export default function PreferencesPage() { + return ; +} From 20b5e464879c0ee51c24bcfbb974c82ab3a4b515 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:53:59 +0000 Subject: [PATCH 14/38] feat(navbar): add MemberAvatars component with invite prompt --- .../_components/Navbar/MemberAvatars.tsx | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 apps/web/app/(org)/dashboard/_components/Navbar/MemberAvatars.tsx diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/MemberAvatars.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/MemberAvatars.tsx new file mode 100644 index 0000000000..2899afe52d --- /dev/null +++ b/apps/web/app/(org)/dashboard/_components/Navbar/MemberAvatars.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { Plus } from "lucide-react"; +import { SignedImageUrl } from "@/components/SignedImageUrl"; +import { Tooltip } from "@/components/Tooltip"; +import { useDashboardContext } from "../../Contexts"; + +const MAX_VISIBLE = 4; + +export function MemberAvatars() { + const { activeOrganization, sidebarCollapsed, setInviteDialogOpen } = + useDashboardContext(); + + if (sidebarCollapsed) return null; + + const members = activeOrganization?.members ?? []; + const visibleMembers = members.slice(0, MAX_VISIBLE); + const extraCount = members.length - MAX_VISIBLE; + const emptySlots = Math.max(0, MAX_VISIBLE - members.length); + + return ( +
+ {visibleMembers.map((member, i) => ( + +
0 ? "-ml-1.5" : ""}> + +
+
+ ))} + + {extraCount > 0 && ( +
+ + +{extraCount} + +
+ )} + + {Array.from({ length: emptySlots }).map((_, i) => ( + + + + ))} +
+ ); +} From c081e041cce1d06b0f0fbda4eb1c0cdce495308d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:54:00 +0000 Subject: [PATCH 15/38] feat(navbar): integrate MemberAvatars and org settings sub-routes --- apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx | 3 +++ apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx | 3 +++ 2 files changed, 6 insertions(+) diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx index 424e47f807..0e7849d691 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx @@ -44,6 +44,7 @@ import { RecordIcon, } from "../AnimatedIcons"; import type { CogIconHandle } from "../AnimatedIcons/Cog"; +import { MemberAvatars } from "./MemberAvatars"; import SpacesList from "./SpacesList"; import { updateActiveOrganization } from "./server"; @@ -94,6 +95,7 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { name: "Organization Settings", href: `/dashboard/settings/organization`, ownerOnly: true, + matchChildren: true, icon: , subNav: [], }, @@ -300,6 +302,7 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { +
)} - {Array.from({ length: emptySlots }).map((_, i) => ( - - - - ))} + + + ))}
); } From 89b0d4f4355e6827d8cd7e90378f7d1f5bfd4bb0 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:12:52 +0000 Subject: [PATCH 29/38] fix(billing): show error state instead of upsell card on query failure --- .../components/BillingSummaryCard.tsx | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/BillingSummaryCard.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/BillingSummaryCard.tsx index eb5a3bb462..4779087e92 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/components/BillingSummaryCard.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/components/BillingSummaryCard.tsx @@ -19,16 +19,19 @@ export function BillingSummaryCard() { const [billingLoading, setBillingLoading] = useState(false); const organizationId = activeOrganization?.organization.id; - const { data: subscription, isLoading } = - useQuery({ - queryKey: ["subscription-details", organizationId], - queryFn: () => { - if (!organizationId) return null; - return getSubscriptionDetails(organizationId); - }, - enabled: !!organizationId, - staleTime: 60 * 1000, - }); + const { + data: subscription, + isLoading, + isError, + } = useQuery({ + queryKey: ["subscription-details", organizationId], + queryFn: () => { + if (!organizationId) return null; + return getSubscriptionDetails(organizationId); + }, + enabled: !!organizationId, + staleTime: 60 * 1000, + }); const handleManageBilling = useCallback(async () => { setBillingLoading(true); @@ -54,6 +57,16 @@ export function BillingSummaryCard() { ); } + if (isError) { + return ( + +

+ Unable to load billing details. Please try again later. +

+
+ ); + } + if (!subscription) { return ( From aa9d32705cb7e119d609530ffa70c553f54922c9 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:13:09 +0000 Subject: [PATCH 30/38] fix(seats): surface preview errors and disable confirm on failure --- .../organization/components/SeatManagementCard.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/SeatManagementCard.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/SeatManagementCard.tsx index 89e5355492..d0a13c4c49 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/components/SeatManagementCard.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/components/SeatManagementCard.tsx @@ -64,7 +64,11 @@ export function SeatManagementCard() { const hasChanges = desiredQuantity !== proSeatsTotal; const debouncedHasChanges = debouncedQuantity !== proSeatsTotal; - const { data: preview, isFetching: previewLoading } = useQuery({ + const { + data: preview, + isFetching: previewLoading, + isError: previewError, + } = useQuery({ queryKey: ["seat-preview", organizationId, debouncedQuantity], queryFn: () => { if (!organizationId) return null; @@ -148,6 +152,10 @@ export function SeatManagementCard() {
{previewLoading ? ( Calculating... + ) : previewError ? ( + + Unable to calculate preview + ) : preview ? ( Prorated charge: ${(preview.proratedAmount / 100).toFixed(2)}{" "} @@ -159,7 +167,9 @@ export function SeatManagementCard() { size="sm" variant="primary" onClick={() => updateMutation.mutate()} - disabled={updateMutation.isPending || previewLoading} + disabled={ + updateMutation.isPending || previewLoading || previewError + } spinner={updateMutation.isPending} > {updateMutation.isPending ? "Updating..." : "Confirm"} From 7caa88952acba1e2ecd0a385ddaecea68af402f5 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:50:57 +0000 Subject: [PATCH 31/38] fix(seats): null out subscription ID when no remaining Pro-seat org found --- apps/web/actions/organization/toggle-pro-seat.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/web/actions/organization/toggle-pro-seat.ts b/apps/web/actions/organization/toggle-pro-seat.ts index bd60b93d06..5337a6689e 100644 --- a/apps/web/actions/organization/toggle-pro-seat.ts +++ b/apps/web/actions/organization/toggle-pro-seat.ts @@ -145,6 +145,11 @@ export async function toggleProSeat( thirdPartyStripeSubscriptionId: remainingOrg.stripeSubscriptionId, }) .where(eq(users.id, member.userId)); + } else { + await tx + .update(users) + .set({ thirdPartyStripeSubscriptionId: null }) + .where(eq(users.id, member.userId)); } } } From 1a1b41faf4b3bffa5ab3ad191b9610197c8f9c1a Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:51:02 +0000 Subject: [PATCH 32/38] fix(billing): narrow Stripe IDs to non-null before preview API call --- apps/web/actions/organization/update-seat-quantity.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/web/actions/organization/update-seat-quantity.ts b/apps/web/actions/organization/update-seat-quantity.ts index df4d841b27..f7ab0c8b68 100644 --- a/apps/web/actions/organization/update-seat-quantity.ts +++ b/apps/web/actions/organization/update-seat-quantity.ts @@ -83,6 +83,12 @@ export async function previewSeatChange( validateQuantity(newQuantity); const { owner, subscriptionItem, proSeatsUsed } = await getOwnerSubscription(organizationId); + const customerId = owner.stripeCustomerId; + const subscriptionId = owner.stripeSubscriptionId; + + if (!customerId || !subscriptionId) { + throw new Error("No active subscription found"); + } if (newQuantity < proSeatsUsed) { throw new Error( @@ -91,8 +97,8 @@ export async function previewSeatChange( } const preview = await stripe().invoices.retrieveUpcoming({ - customer: owner.stripeCustomerId, - subscription: owner.stripeSubscriptionId, + customer: customerId, + subscription: subscriptionId, subscription_items: [ { id: subscriptionItem.id, From 516031ad18f75193196ad77eef2ec1445fc2f08f Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:51:07 +0000 Subject: [PATCH 33/38] fix(invite): validate emails client-side and surface failed deliveries --- .../organization/components/InviteDialog.tsx | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/InviteDialog.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/InviteDialog.tsx index 0b34c46557..e5a7f1af2d 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/components/InviteDialog.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/components/InviteDialog.tsx @@ -29,14 +29,23 @@ export const InviteDialog = ({ isOpen, setIsOpen }: InviteDialogProps) => { const [inviteEmails, setInviteEmails] = useState([]); const [emailInput, setEmailInput] = useState(""); const emailInputId = useId(); + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const handleAddEmails = () => { const newEmails = emailInput .split(",") - .map((email) => email.trim()) + .map((email) => email.trim().toLowerCase()) .filter((email) => email !== ""); - setInviteEmails([...new Set([...inviteEmails, ...newEmails])]); + const invalidEmails = newEmails.filter((email) => !emailRegex.test(email)); + if (invalidEmails.length > 0) { + toast.error( + `Invalid email${invalidEmails.length > 1 ? "s" : ""}: ${invalidEmails.join(", ")}`, + ); + } + + const validEmails = newEmails.filter((email) => emailRegex.test(email)); + setInviteEmails([...new Set([...inviteEmails, ...validEmails])]); setEmailInput(""); }; @@ -54,8 +63,14 @@ export const InviteDialog = ({ isOpen, setIsOpen }: InviteDialogProps) => { activeOrganization.organization.id, ); }, - onSuccess: () => { - toast.success("Invites sent successfully"); + onSuccess: (result) => { + if (result.failedEmails.length > 0) { + toast.warning( + `Invites sent, but delivery failed for: ${result.failedEmails.join(", ")}`, + ); + } else { + toast.success("Invites sent successfully"); + } setInviteEmails([]); setIsOpen(false); router.refresh(); From c1c743c8ab9e37da8f8da1b801b9ad40773f012b Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:51:12 +0000 Subject: [PATCH 34/38] fix(seats): show loading state during debounce and block confirm until settled --- .../organization/components/SeatManagementCard.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/SeatManagementCard.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/SeatManagementCard.tsx index d0a13c4c49..1e5c989032 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/components/SeatManagementCard.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/components/SeatManagementCard.tsx @@ -63,6 +63,7 @@ export function SeatManagementCard() { const hasChanges = desiredQuantity !== proSeatsTotal; const debouncedHasChanges = debouncedQuantity !== proSeatsTotal; + const debounceInFlight = desiredQuantity !== debouncedQuantity; const { data: preview, @@ -150,7 +151,7 @@ export function SeatManagementCard() { {hasChanges && (
- {previewLoading ? ( + {debounceInFlight || previewLoading ? ( Calculating... ) : previewError ? ( @@ -168,7 +169,10 @@ export function SeatManagementCard() { variant="primary" onClick={() => updateMutation.mutate()} disabled={ - updateMutation.isPending || previewLoading || previewError + updateMutation.isPending || + previewLoading || + previewError || + debounceInFlight } spinner={updateMutation.isPending} > From 15d94d3a5bc3ebb2c576028f69a39d0f19574bdd Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 3 Mar 2026 17:05:11 +0000 Subject: [PATCH 35/38] fix(billing): calculate proration from line items instead of total amount_due --- .../organization/update-seat-quantity.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/web/actions/organization/update-seat-quantity.ts b/apps/web/actions/organization/update-seat-quantity.ts index f7ab0c8b68..cbdf42af5d 100644 --- a/apps/web/actions/organization/update-seat-quantity.ts +++ b/apps/web/actions/organization/update-seat-quantity.ts @@ -96,7 +96,7 @@ export async function previewSeatChange( ); } - const preview = await stripe().invoices.retrieveUpcoming({ + const previewParams = { customer: customerId, subscription: subscriptionId, subscription_items: [ @@ -105,11 +105,21 @@ export async function previewSeatChange( quantity: newQuantity, }, ], - subscription_proration_behavior: "create_prorations", - }); + subscription_proration_behavior: "create_prorations" as const, + }; + + const preview = await stripe().invoices.retrieveUpcoming(previewParams); + const previewLines = preview.lines.has_more + ? await stripe() + .invoices.listUpcomingLines(previewParams) + .autoPagingToArray({ limit: 1000 }) + : preview.lines.data; const currentQuantity = subscriptionItem.quantity ?? 1; - const proratedAmount = preview.amount_due; + const proratedAmount = previewLines.reduce((total, line) => { + if (!line.proration) return total; + return total + line.amount; + }, 0); const nextPaymentDate = preview.period_end; return { From df8a6b0d828f00eaebea1706aafa2033d2a4b5f7 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 3 Mar 2026 17:05:15 +0000 Subject: [PATCH 36/38] fix(settings): hide billing label in settings nav for self-hosted instances --- .../organization/_components/SettingsNav.tsx | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/web/app/(org)/dashboard/settings/organization/_components/SettingsNav.tsx b/apps/web/app/(org)/dashboard/settings/organization/_components/SettingsNav.tsx index 12eb1d7bdb..449c2889f6 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/_components/SettingsNav.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/_components/SettingsNav.tsx @@ -1,24 +1,24 @@ "use client"; +import { buildEnv } from "@cap/env"; import clsx from "clsx"; import { motion } from "framer-motion"; import Link from "next/link"; import { usePathname } from "next/navigation"; -const tabs = [ - { label: "General", href: "/dashboard/settings/organization" }, - { - label: "Preferences", - href: "/dashboard/settings/organization/preferences", - }, - { - label: "Billing & Members", - href: "/dashboard/settings/organization/billing", - }, -] as const; - export function SettingsNav() { const pathname = usePathname(); + const tabs = [ + { label: "General", href: "/dashboard/settings/organization" }, + { + label: "Preferences", + href: "/dashboard/settings/organization/preferences", + }, + { + label: buildEnv.NEXT_PUBLIC_IS_CAP ? "Billing & Members" : "Members", + href: "/dashboard/settings/organization/billing", + }, + ] as const; return (
From 3eeb7019bc351b1c07a912d8f1285b1454bd5d22 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 3 Mar 2026 17:05:19 +0000 Subject: [PATCH 37/38] fix(invite): reset dialog state on close and remove redundant clear --- .../settings/organization/components/InviteDialog.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/InviteDialog.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/InviteDialog.tsx index e5a7f1af2d..ca9066c225 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/components/InviteDialog.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/components/InviteDialog.tsx @@ -13,7 +13,7 @@ import { faUserGroup } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useMutation } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; -import { useId, useState } from "react"; +import { useEffect, useId, useState } from "react"; import { toast } from "sonner"; import { sendOrganizationInvites } from "@/actions/organization/send-invites"; import { useDashboardContext } from "../../../Contexts"; @@ -31,6 +31,13 @@ export const InviteDialog = ({ isOpen, setIsOpen }: InviteDialogProps) => { const emailInputId = useId(); const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + useEffect(() => { + if (!isOpen) { + setInviteEmails([]); + setEmailInput(""); + } + }, [isOpen]); + const handleAddEmails = () => { const newEmails = emailInput .split(",") @@ -71,7 +78,6 @@ export const InviteDialog = ({ isOpen, setIsOpen }: InviteDialogProps) => { } else { toast.success("Invites sent successfully"); } - setInviteEmails([]); setIsOpen(false); router.refresh(); }, From 4610f9c177b13bc20bb9d72ddcbe733281b7a864 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 3 Mar 2026 17:05:23 +0000 Subject: [PATCH 38/38] fix(seats): distinguish prorated charge, credit, and zero adjustment in preview --- .../settings/organization/components/SeatManagementCard.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/SeatManagementCard.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/SeatManagementCard.tsx index 1e5c989032..6a9aa4d53a 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/components/SeatManagementCard.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/components/SeatManagementCard.tsx @@ -159,8 +159,9 @@ export function SeatManagementCard() { ) : preview ? ( - Prorated charge: ${(preview.proratedAmount / 100).toFixed(2)}{" "} - {preview.currency.toUpperCase()} + {preview.proratedAmount === 0 + ? "No prorated adjustment" + : `${preview.proratedAmount > 0 ? "Prorated charge" : "Prorated credit"}: $${Math.abs(preview.proratedAmount / 100).toFixed(2)} ${preview.currency.toUpperCase()}`} ) : null}