diff --git a/apps/web/__tests__/integration/loom-concierge-database.test.ts b/apps/web/__tests__/integration/loom-concierge-database.test.ts
new file mode 100644
index 0000000000..f296f046cf
--- /dev/null
+++ b/apps/web/__tests__/integration/loom-concierge-database.test.ts
@@ -0,0 +1,953 @@
+import { randomUUID } from "node:crypto";
+import { db } from "@cap/database";
+import {
+ importedVideos,
+ loomMigrationImports,
+ loomMigrationRequests,
+ organizationMembers,
+ organizations,
+ users,
+ videos,
+ videoUploads,
+} from "@cap/database/schema";
+import {
+ Organisation,
+ type Storage as StorageDomain,
+ User,
+ Video,
+} from "@cap/web-domain";
+import { eq } from "drizzle-orm";
+import { createConnection } from "mysql2/promise";
+import {
+ afterAll,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+} from "vitest";
+import {
+ type LoomMigrationResult,
+ unwrapLoomMigrationResult,
+} from "@/lib/loom-migration-state";
+
+const databaseUrl = process.env.CAP_LOOM_CONCIERGE_TEST_DATABASE_URL;
+
+const fixture = vi.hoisted(() => {
+ const url = process.env.CAP_LOOM_CONCIERGE_TEST_DATABASE_URL;
+ if (url) process.env.DATABASE_URL = url;
+ return {
+ currentUserId: null as string | null,
+ sendEmail: vi.fn(async (_input: unknown) => ({})),
+ start: vi.fn(async (_workflow: unknown, _args: unknown) => ({
+ runId: "run_fixture",
+ })),
+ };
+});
+
+vi.mock("server-only", () => ({}));
+vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
+vi.mock("next/headers", () => ({
+ cookies: async () => ({ get: () => undefined }),
+ headers: async () => new Headers(),
+}));
+vi.mock("workflow/api", () => ({ start: fixture.start }));
+vi.mock("@cap/database/emails/config", () => ({
+ sendEmail: fixture.sendEmail,
+}));
+vi.mock("@cap/database/auth/session", () => ({
+ getCurrentUser: async () => {
+ if (!fixture.currentUserId) return null;
+ const [user] = await db()
+ .select()
+ .from(users)
+ .where(eq(users.id, User.UserId.make(fixture.currentUserId)))
+ .limit(1);
+ return user ?? null;
+ },
+}));
+
+const OPERATOR_EMAIL = "richie@cap.so";
+const realFetch = globalThis.fetch;
+
+type StorageUploadTarget = StorageDomain.UploadTarget;
+
+function id() {
+ return randomUUID().replaceAll("-", "").slice(0, 15);
+}
+
+function loomId() {
+ return randomUUID().replaceAll("-", "");
+}
+
+function unwrapped(
+ action: (...args: Args) => Promise>,
+) {
+ return async (...args: Args) =>
+ unwrapLoomMigrationResult(await action(...args));
+}
+
+function signInAs(userId: string | null) {
+ fixture.currentUserId = userId;
+}
+
+async function makeUser({
+ email = `${id()}@example.com`,
+ pro = false,
+ activeOrganizationId = Organisation.OrganisationId.make(id()),
+}: {
+ email?: string;
+ pro?: boolean;
+ activeOrganizationId?: Organisation.OrganisationId;
+} = {}) {
+ const userId = User.UserId.make(id());
+ await db()
+ .insert(users)
+ .values({
+ id: userId,
+ email,
+ activeOrganizationId,
+ stripeSubscriptionStatus: pro ? "active" : null,
+ stripeSubscriptionId: pro ? `sub_fixture_${id()}` : null,
+ });
+ return userId;
+}
+
+async function makeOrganization({
+ pro = true,
+ privateByDefault = false,
+}: {
+ pro?: boolean;
+ privateByDefault?: boolean;
+} = {}) {
+ const organizationId = Organisation.OrganisationId.make(id());
+ const ownerId = await makeUser({ pro, activeOrganizationId: organizationId });
+ await db()
+ .insert(organizations)
+ .values({
+ id: organizationId,
+ ownerId,
+ name: "Concierge fixture",
+ defaultVideoVisibility: privateByDefault ? "private" : null,
+ });
+ await db().insert(organizationMembers).values({
+ id: id(),
+ userId: ownerId,
+ organizationId,
+ role: "owner",
+ });
+ return { ownerId, organizationId };
+}
+
+async function addMember(
+ organizationId: Organisation.OrganisationId,
+ role: "member" | "admin",
+) {
+ const userId = await makeUser();
+ await db().insert(organizationMembers).values({
+ id: id(),
+ userId,
+ organizationId,
+ role,
+ });
+ return userId;
+}
+
+async function getOperatorId() {
+ const [existing] = await db()
+ .select({ id: users.id })
+ .from(users)
+ .where(eq(users.email, OPERATOR_EMAIL))
+ .limit(1);
+ return existing?.id ?? (await makeUser({ email: OPERATOR_EMAIL }));
+}
+
+async function getRequest(requestId: string) {
+ const [request] = await db()
+ .select()
+ .from(loomMigrationRequests)
+ .where(eq(loomMigrationRequests.id, requestId))
+ .limit(1);
+ if (!request) throw new Error("Missing request fixture");
+ return request;
+}
+
+async function setUploadPhase(
+ videoId: string,
+ phase: "uploading" | "processing" | "complete" | "error",
+) {
+ await db()
+ .update(videoUploads)
+ .set({ phase })
+ .where(eq(videoUploads.videoId, Video.VideoId.make(videoId)));
+}
+
+async function uploadToTarget(target: StorageUploadTarget) {
+ const body = new Uint8Array(4096).fill(7);
+ if (target.type === "put") {
+ const response = await realFetch(target.url, {
+ method: "PUT",
+ headers: target.headers,
+ body,
+ });
+ expect(response.ok).toBe(true);
+ } else if (target.type === "s3Post") {
+ const form = new FormData();
+ for (const [key, value] of Object.entries(target.fields)) {
+ form.append(key, value);
+ }
+ form.append("file", new Blob([body], { type: "video/mp4" }));
+ const response = await realFetch(target.url, {
+ method: "POST",
+ body: form,
+ });
+ expect(response.ok).toBe(true);
+ } else {
+ throw new Error(`Unexpected upload target ${target.type}`);
+ }
+}
+
+function stubLoomFetch() {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = typeof input === "string" ? input : input.toString();
+ if (url.includes("/transcoded-url") || url.includes("/raw-url")) {
+ return new Response(
+ JSON.stringify({ url: "https://cdn.loom.com/sessions/video.mp4" }),
+ { status: 200 },
+ );
+ }
+ if (url.startsWith("https://www.loom.com/graphql")) {
+ return Response.json({ data: { getVideo: { name: "Loom fixture" } } });
+ }
+ if (url.startsWith("https://www.loom.com/v1/oembed")) {
+ return Response.json({ duration: 12, width: 1280, height: 720 });
+ }
+ return realFetch(input, init);
+ }),
+ );
+}
+
+describe.skipIf(!databaseUrl)("Loom concierge migration database", () => {
+ let rawActions: typeof import("@/actions/loom-concierge");
+ let rawLoom: typeof import("@/actions/loom");
+ let operatorId: User.UserId;
+
+ const actions = {
+ getLoomMigrationDashboard: (
+ ...args: Parameters
+ ) => rawActions.getLoomMigrationDashboard(...args),
+ getLoomMigrationOperatorQueue: () =>
+ rawActions.getLoomMigrationOperatorQueue(),
+ requestLoomMigration: unwrapped(
+ (...args: Parameters) =>
+ rawActions.requestLoomMigration(...args),
+ ),
+ confirmLoomMigrationInvite: unwrapped((requestId: string) =>
+ rawActions.confirmLoomMigrationInvite(requestId),
+ ),
+ answerLoomMigrationQuestion: unwrapped(
+ (...args: Parameters) =>
+ rawActions.answerLoomMigrationQuestion(...args),
+ ),
+ updateLoomMigrationStatus: unwrapped(
+ (...args: Parameters) =>
+ rawActions.updateLoomMigrationStatus(...args),
+ ),
+ };
+ const loom = {
+ importFromLoomCsvForConcierge: unwrapped(
+ (...args: Parameters) =>
+ rawLoom.importFromLoomCsvForConcierge(...args),
+ ),
+ createConciergeLoomFileUpload: unwrapped(
+ (...args: Parameters) =>
+ rawLoom.createConciergeLoomFileUpload(...args),
+ ),
+ finishConciergeLoomFileUpload: unwrapped(
+ (...args: Parameters) =>
+ rawLoom.finishConciergeLoomFileUpload(...args),
+ ),
+ };
+
+ beforeAll(async () => {
+ rawActions = await import("@/actions/loom-concierge");
+ rawLoom = await import("@/actions/loom");
+ operatorId = await getOperatorId();
+ });
+
+ beforeEach(() => {
+ signInAs(null);
+ stubLoomFetch();
+ });
+
+ afterAll(() => {
+ vi.unstubAllGlobals();
+ });
+
+ async function requestMigration(
+ organizationId: Organisation.OrganisationId,
+ ownerId: User.UserId,
+ ) {
+ signInAs(ownerId);
+ const result = await actions.requestLoomMigration({
+ organizationId,
+ workspaceName: " Acme Loom ",
+ note: "Two workspaces",
+ });
+ return result.id;
+ }
+
+ it("lets a Pro organization manager request one active migration", async () => {
+ const { ownerId, organizationId } = await makeOrganization();
+ signInAs(ownerId);
+ const first = await actions.requestLoomMigration({
+ organizationId,
+ workspaceName: " Acme Loom ",
+ note: "",
+ });
+ const second = await actions.requestLoomMigration({
+ organizationId,
+ workspaceName: "Again",
+ note: "",
+ });
+
+ expect(first.alreadyRequested).toBe(false);
+ expect(second).toEqual({ id: first.id, alreadyRequested: true });
+ const request = await getRequest(first.id);
+ expect(request.workspaceName).toBe("Acme Loom");
+ expect(request.status).toBe("pending");
+ expect(request.activeOrganizationId).toBe(organizationId);
+ expect(fixture.sendEmail).toHaveBeenCalledTimes(1);
+ expect(fixture.sendEmail.mock.calls[0]?.[0]).toMatchObject({
+ email: "hello@cap.so",
+ });
+
+ const dashboard = await actions.getLoomMigrationDashboard(organizationId);
+ expect(dashboard.isPro).toBe(true);
+ expect(dashboard.requests.map((item) => item.id)).toEqual([first.id]);
+ });
+
+ it("rejects free organizations, members, outsiders, and signed-out users", async () => {
+ const free = await makeOrganization({ pro: false });
+ signInAs(free.ownerId);
+ await expect(
+ actions.requestLoomMigration({
+ organizationId: free.organizationId,
+ workspaceName: "",
+ note: "",
+ }),
+ ).rejects.toThrow("requires Cap Pro");
+
+ const { ownerId, organizationId } = await makeOrganization();
+ const requestId = await requestMigration(organizationId, ownerId);
+
+ const member = await addMember(organizationId, "member");
+ signInAs(member);
+ await expect(
+ actions.getLoomMigrationDashboard(organizationId),
+ ).rejects.toThrow();
+ await expect(
+ actions.confirmLoomMigrationInvite(requestId),
+ ).rejects.toThrow();
+
+ const outsider = await makeOrganization();
+ signInAs(outsider.ownerId);
+ await expect(
+ actions.getLoomMigrationDashboard(organizationId),
+ ).rejects.toThrow();
+ await expect(
+ actions.confirmLoomMigrationInvite(requestId),
+ ).rejects.toThrow();
+ await expect(
+ actions.answerLoomMigrationQuestion({ requestId, answer: "hi" }),
+ ).rejects.toThrow();
+
+ signInAs(null);
+ await expect(actions.confirmLoomMigrationInvite(requestId)).rejects.toThrow(
+ "Unauthorized",
+ );
+
+ const admin = await addMember(organizationId, "admin");
+ signInAs(admin);
+ await actions.confirmLoomMigrationInvite(requestId);
+ expect((await getRequest(requestId)).invitedAt).not.toBeNull();
+ });
+
+ it("limits every operator action to the Cap operator account", async () => {
+ const { ownerId, organizationId } = await makeOrganization();
+ const requestId = await requestMigration(organizationId, ownerId);
+
+ signInAs(ownerId);
+ await expect(actions.getLoomMigrationOperatorQueue()).rejects.toThrow(
+ "Unauthorized",
+ );
+ await expect(
+ actions.updateLoomMigrationStatus({
+ requestId,
+ nextStatus: "in_progress",
+ message: "",
+ verified: false,
+ expectedVideoCount: null,
+ importedVideoCount: 0,
+ }),
+ ).rejects.toThrow("Unauthorized");
+ await expect(
+ loom.importFromLoomCsvForConcierge({ requestId, rows: [] }),
+ ).rejects.toThrow("Unauthorized");
+ await expect(
+ loom.createConciergeLoomFileUpload({
+ requestId,
+ loomUrl: `https://www.loom.com/share/${loomId()}`,
+ userEmail: "someone@example.com",
+ spaceName: "",
+ videoTitle: "Title",
+ }),
+ ).rejects.toThrow("Unauthorized");
+ await expect(
+ loom.finishConciergeLoomFileUpload({
+ requestId,
+ videoId: Video.VideoId.make(id()),
+ }),
+ ).rejects.toThrow("Unauthorized");
+
+ signInAs(operatorId);
+ const queue = await actions.getLoomMigrationOperatorQueue();
+ expect(queue.find((item) => item.id === requestId)).toMatchObject({
+ organizationName: "Concierge fixture",
+ status: "pending",
+ });
+ });
+
+ it("returns expected failures as data so production clients can show them", async () => {
+ const { ownerId, organizationId } = await makeOrganization();
+ const requestId = await requestMigration(organizationId, ownerId);
+
+ signInAs(operatorId);
+ await expect(
+ rawActions.updateLoomMigrationStatus({
+ requestId,
+ nextStatus: "completed",
+ message: "",
+ verified: false,
+ expectedVideoCount: 1,
+ importedVideoCount: 1,
+ }),
+ ).resolves.toEqual({
+ ok: false,
+ error: "Verify the migrated library before marking it complete.",
+ });
+ await expect(
+ rawLoom.finishConciergeLoomFileUpload({
+ requestId: "not-a-request",
+ videoId: Video.VideoId.make(id()),
+ }),
+ ).resolves.toEqual({ ok: false, error: "Invalid migration request." });
+
+ signInAs(ownerId);
+ await expect(
+ rawLoom.importFromLoomCsvForConcierge({ requestId, rows: [] }),
+ ).resolves.toEqual({ ok: false, error: "Unauthorized" });
+ });
+
+ it("keeps an open customer question while imports run and only emails visible changes", async () => {
+ const { ownerId, organizationId } = await makeOrganization();
+ const requestId = await requestMigration(organizationId, ownerId);
+
+ signInAs(operatorId);
+ await actions.updateLoomMigrationStatus({
+ requestId,
+ nextStatus: "needs_information",
+ message: "Please share the Sales folder",
+ verified: false,
+ expectedVideoCount: null,
+ importedVideoCount: 0,
+ });
+ await loom.importFromLoomCsvForConcierge({
+ requestId,
+ rows: [
+ {
+ rowNumber: 2,
+ loomUrl: `https://www.loom.com/share/${loomId()}`,
+ userEmail: `${id()}@example.com`,
+ spaceName: "",
+ },
+ ],
+ });
+ expect((await getRequest(requestId)).status).toBe("needs_information");
+
+ fixture.sendEmail.mockClear();
+ await actions.updateLoomMigrationStatus({
+ requestId,
+ nextStatus: "needs_information",
+ message: "Please share the Sales folder",
+ verified: false,
+ expectedVideoCount: 5,
+ importedVideoCount: 1,
+ });
+ expect(fixture.sendEmail).not.toHaveBeenCalled();
+ expect((await getRequest(requestId)).expectedVideoCount).toBe(5);
+
+ signInAs(ownerId);
+ await actions.answerLoomMigrationQuestion({
+ requestId,
+ answer: "Shared it",
+ });
+ expect((await getRequest(requestId)).status).toBe("pending");
+ });
+
+ it("round-trips an information request with the customer", async () => {
+ const { ownerId, organizationId } = await makeOrganization();
+ const requestId = await requestMigration(organizationId, ownerId);
+
+ signInAs(operatorId);
+ await expect(
+ actions.updateLoomMigrationStatus({
+ requestId,
+ nextStatus: "needs_information",
+ message: " ",
+ verified: false,
+ expectedVideoCount: null,
+ importedVideoCount: 0,
+ }),
+ ).rejects.toThrow("what information you need");
+ await actions.updateLoomMigrationStatus({
+ requestId,
+ nextStatus: "needs_information",
+ message: "Which Loom workspace?",
+ verified: false,
+ expectedVideoCount: null,
+ importedVideoCount: 0,
+ });
+
+ signInAs(ownerId);
+ await actions.answerLoomMigrationQuestion({
+ requestId,
+ answer: "The Acme one",
+ });
+ const answered = await getRequest(requestId);
+ expect(answered.status).toBe("pending");
+ expect(answered.customerReply).toBe("The Acme one");
+ await expect(
+ actions.answerLoomMigrationQuestion({ requestId, answer: "Again" }),
+ ).rejects.toThrow("changed");
+ });
+
+ it("imports CSV rows into the customer workspace under the request", async () => {
+ const { ownerId, organizationId } = await makeOrganization();
+ const requestId = await requestMigration(organizationId, ownerId);
+ const firstRow = {
+ rowNumber: 2,
+ loomUrl: `https://www.loom.com/share/${loomId()}`,
+ userEmail: `${id()}@example.com`,
+ spaceName: "Sales",
+ };
+ const rows = [
+ firstRow,
+ {
+ rowNumber: 3,
+ loomUrl: `https://www.loom.com/share/${loomId()}`,
+ userEmail: `${id()}@example.com`,
+ spaceName: "",
+ },
+ ];
+
+ signInAs(operatorId);
+ await expect(
+ loom.importFromLoomCsvForConcierge({
+ requestId,
+ rows: Array.from({ length: 11 }, () => firstRow),
+ }),
+ ).rejects.toThrow("batches of 10");
+
+ const result = await loom.importFromLoomCsvForConcierge({
+ requestId,
+ rows,
+ });
+ expect(result.importedCount).toBe(2);
+ expect(fixture.start).toHaveBeenCalledTimes(2);
+
+ const mapped = await db()
+ .select({ videoId: loomMigrationImports.videoId, orgId: videos.orgId })
+ .from(loomMigrationImports)
+ .innerJoin(videos, eq(videos.id, loomMigrationImports.videoId))
+ .where(eq(loomMigrationImports.requestId, requestId));
+ expect(mapped).toHaveLength(2);
+ expect(mapped.every((row) => row.orgId === organizationId)).toBe(true);
+
+ const request = await getRequest(requestId);
+ expect(request.queuedVideoCount).toBe(2);
+ expect(request.status).toBe("in_progress");
+ expect(request.activeImportCount).toBe(0);
+ expect(request.activeImportLeaseToken).toBeNull();
+
+ const repeat = await loom.importFromLoomCsvForConcierge({
+ requestId,
+ rows: [firstRow],
+ });
+ expect(repeat.importedCount).toBe(0);
+ expect((await getRequest(requestId)).queuedVideoCount).toBe(2);
+ });
+
+ it("serializes imports and recovers expired reservations", async () => {
+ const { ownerId, organizationId } = await makeOrganization();
+ const requestId = await requestMigration(organizationId, ownerId);
+ const row = {
+ rowNumber: 2,
+ loomUrl: `https://www.loom.com/share/${loomId()}`,
+ userEmail: `${id()}@example.com`,
+ spaceName: "",
+ };
+
+ await db()
+ .update(loomMigrationRequests)
+ .set({
+ activeImportCount: 1,
+ activeImportLeaseToken: randomUUID(),
+ activeImportLeaseUntil: new Date(Date.now() + 60_000),
+ })
+ .where(eq(loomMigrationRequests.id, requestId));
+
+ signInAs(operatorId);
+ await expect(
+ loom.importFromLoomCsvForConcierge({ requestId, rows: [row] }),
+ ).rejects.toThrow("another import is starting");
+ await expect(
+ actions.updateLoomMigrationStatus({
+ requestId,
+ nextStatus: "completed",
+ message: "",
+ verified: true,
+ expectedVideoCount: 0,
+ importedVideoCount: 0,
+ }),
+ ).rejects.toThrow("imports are starting");
+
+ await db()
+ .update(loomMigrationRequests)
+ .set({ activeImportLeaseUntil: new Date(Date.now() - 1_000) })
+ .where(eq(loomMigrationRequests.id, requestId));
+ const result = await loom.importFromLoomCsvForConcierge({
+ requestId,
+ rows: [row],
+ });
+ expect(result.importedCount).toBe(1);
+ expect((await getRequest(requestId)).activeImportLeaseToken).toBeNull();
+ });
+
+ it("only completes reconciled requests with finished imports", async () => {
+ const { ownerId, organizationId } = await makeOrganization();
+ const requestId = await requestMigration(organizationId, ownerId);
+
+ signInAs(operatorId);
+ await loom.importFromLoomCsvForConcierge({
+ requestId,
+ rows: [
+ {
+ rowNumber: 2,
+ loomUrl: `https://www.loom.com/share/${loomId()}`,
+ userEmail: `${id()}@example.com`,
+ spaceName: "",
+ },
+ ],
+ });
+ const [mapped] = await db()
+ .select({ videoId: loomMigrationImports.videoId })
+ .from(loomMigrationImports)
+ .where(eq(loomMigrationImports.requestId, requestId));
+ if (!mapped) throw new Error("Missing mapped import");
+
+ const complete = {
+ requestId,
+ nextStatus: "completed" as const,
+ message: "All done",
+ verified: true,
+ expectedVideoCount: 1,
+ importedVideoCount: 1,
+ };
+ await expect(
+ actions.updateLoomMigrationStatus({ ...complete, verified: false }),
+ ).rejects.toThrow("Verify");
+ await expect(
+ actions.updateLoomMigrationStatus({
+ ...complete,
+ expectedVideoCount: null,
+ }),
+ ).rejects.toThrow("agreed source video count");
+ await expect(
+ actions.updateLoomMigrationStatus({ ...complete, expectedVideoCount: 2 }),
+ ).rejects.toThrow("Reconcile");
+
+ for (const phase of ["uploading", "processing", "error"] as const) {
+ await setUploadPhase(mapped.videoId, phase);
+ await expect(actions.updateLoomMigrationStatus(complete)).rejects.toThrow(
+ "still processing",
+ );
+ }
+
+ const unrelated = await makeOrganization();
+ signInAs(unrelated.ownerId);
+ const unrelatedRequest = await requestMigration(
+ unrelated.organizationId,
+ unrelated.ownerId,
+ );
+ expect(unrelatedRequest).not.toBe(requestId);
+
+ signInAs(operatorId);
+ await setUploadPhase(mapped.videoId, "complete");
+ fixture.sendEmail.mockClear();
+ await actions.updateLoomMigrationStatus(complete);
+
+ const completed = await getRequest(requestId);
+ expect(completed.status).toBe("completed");
+ expect(completed.activeOrganizationId).toBeNull();
+ expect(completed.completedAt).not.toBeNull();
+ expect(fixture.sendEmail).toHaveBeenCalledTimes(1);
+
+ await expect(
+ actions.updateLoomMigrationStatus({
+ ...complete,
+ nextStatus: "in_progress",
+ }),
+ ).rejects.toThrow("cannot be changed");
+ await expect(
+ loom.importFromLoomCsvForConcierge({ requestId, rows: [] }),
+ ).rejects.toThrow("no longer active");
+
+ const queue = await actions.getLoomMigrationOperatorQueue();
+ expect(queue.some((item) => item.id === requestId)).toBe(false);
+
+ signInAs(ownerId);
+ const reopened = await actions.requestLoomMigration({
+ organizationId,
+ workspaceName: "",
+ note: "One more workspace",
+ });
+ expect(reopened.alreadyRequested).toBe(false);
+ expect(reopened.id).not.toBe(requestId);
+ });
+
+ it("uploads restricted MP4 files to storage and starts processing", async () => {
+ const { ownerId, organizationId } = await makeOrganization({
+ privateByDefault: true,
+ });
+ const requestId = await requestMigration(organizationId, ownerId);
+ const ownerEmail = `${id()}@example.com`;
+ const loomUrl = `https://www.loom.com/share/${loomId()}`;
+
+ signInAs(operatorId);
+ await expect(
+ loom.createConciergeLoomFileUpload({
+ requestId,
+ loomUrl: "http://evil.example.com/share/abcdefghijk",
+ userEmail: ownerEmail,
+ spaceName: "",
+ videoTitle: "Kickoff",
+ }),
+ ).rejects.toThrow("valid Loom video URL");
+
+ const prepared = await loom.createConciergeLoomFileUpload({
+ requestId,
+ loomUrl,
+ userEmail: ownerEmail,
+ spaceName: "Imported",
+ videoTitle: "Kickoff",
+ });
+ const [video] = await db()
+ .select()
+ .from(videos)
+ .where(eq(videos.id, prepared.videoId));
+ if (!video) throw new Error("Missing uploaded video");
+ expect(video).toMatchObject({ orgId: organizationId, public: false });
+ const [owner] = await db()
+ .select({ email: users.email })
+ .from(users)
+ .where(eq(users.id, video.ownerId));
+ expect(owner?.email).toBe(ownerEmail);
+ const [imported] = await db()
+ .select()
+ .from(importedVideos)
+ .where(eq(importedVideos.id, prepared.videoId));
+ expect(imported?.source).toBe("loom");
+
+ await expect(
+ loom.finishConciergeLoomFileUpload({
+ requestId,
+ videoId: prepared.videoId,
+ }),
+ ).rejects.toThrow("not found in storage");
+
+ const retry = await loom.createConciergeLoomFileUpload({
+ requestId,
+ loomUrl,
+ userEmail: ownerEmail,
+ spaceName: "Imported",
+ videoTitle: "Kickoff",
+ });
+ expect(retry.videoId).toBe(prepared.videoId);
+ await expect(
+ loom.createConciergeLoomFileUpload({
+ requestId,
+ loomUrl,
+ userEmail: `${id()}@example.com`,
+ spaceName: "",
+ videoTitle: "Kickoff",
+ }),
+ ).rejects.toThrow("already in the Cap import inventory");
+
+ await uploadToTarget(retry.uploadTarget);
+
+ fixture.start.mockClear();
+ const finished = await loom.finishConciergeLoomFileUpload({
+ requestId,
+ videoId: prepared.videoId,
+ });
+ expect(finished.status).toBe("started");
+ expect(fixture.start).toHaveBeenCalledTimes(1);
+ const [upload] = await db()
+ .select({ phase: videoUploads.phase })
+ .from(videoUploads)
+ .where(eq(videoUploads.videoId, prepared.videoId));
+ expect(upload?.phase).toBe("processing");
+ expect((await getRequest(requestId)).queuedVideoCount).toBe(1);
+
+ await expect(
+ loom.createConciergeLoomFileUpload({
+ requestId,
+ loomUrl,
+ userEmail: ownerEmail,
+ spaceName: "",
+ videoTitle: "Kickoff",
+ }),
+ ).rejects.toThrow("already in the Cap import inventory");
+ });
+
+ it("recovers a failed automatic import with a restricted MP4", async () => {
+ const { ownerId, organizationId } = await makeOrganization();
+ const requestId = await requestMigration(organizationId, ownerId);
+ const ownerEmail = `${id()}@example.com`;
+ const loomUrl = `https://www.loom.com/share/${loomId()}`;
+
+ signInAs(operatorId);
+ const imported = await loom.importFromLoomCsvForConcierge({
+ requestId,
+ rows: [{ rowNumber: 2, loomUrl, userEmail: ownerEmail, spaceName: "" }],
+ });
+ const videoId = imported.results[0]?.videoId;
+ if (!videoId) throw new Error("Missing automatic import");
+ await setUploadPhase(videoId, "error");
+ const [failed] = await db()
+ .select({ rawFileKey: videoUploads.rawFileKey })
+ .from(videoUploads)
+ .where(eq(videoUploads.videoId, videoId));
+ expect(failed?.rawFileKey).toBeNull();
+
+ const prepared = await loom.createConciergeLoomFileUpload({
+ requestId,
+ loomUrl,
+ userEmail: ownerEmail,
+ spaceName: "",
+ videoTitle: "Recovered",
+ });
+ expect(prepared.videoId).toBe(videoId);
+ const [video] = await db()
+ .select({ ownerId: videos.ownerId })
+ .from(videos)
+ .where(eq(videos.id, videoId));
+ const [recovering] = await db()
+ .select({ rawFileKey: videoUploads.rawFileKey })
+ .from(videoUploads)
+ .where(eq(videoUploads.videoId, videoId));
+ expect(recovering?.rawFileKey).toBe(
+ `${video?.ownerId}/${videoId}/raw-upload.mp4`,
+ );
+ expect((await getRequest(requestId)).queuedVideoCount).toBe(1);
+
+ await uploadToTarget(prepared.uploadTarget);
+ fixture.start.mockClear();
+ const finished = await loom.finishConciergeLoomFileUpload({
+ requestId,
+ videoId,
+ });
+ expect(finished.status).toBe("started");
+ expect(fixture.start).toHaveBeenCalledTimes(1);
+ });
+
+ it("waits for an in-flight import transaction before completing", async () => {
+ const { ownerId, organizationId } = await makeOrganization();
+ const requestId = await requestMigration(organizationId, ownerId);
+ if (!databaseUrl) throw new Error("Missing test database");
+ const connection = await createConnection(databaseUrl);
+ const videoId = id();
+ try {
+ await connection.beginTransaction();
+ await connection.query(
+ "SELECT id FROM loom_migration_requests WHERE id = ? FOR UPDATE",
+ [requestId],
+ );
+ await connection.query(
+ "INSERT INTO videos (id, ownerId, orgId, name, source, createdAt, updatedAt) VALUES (?, ?, ?, 'In flight', ?, NOW(), NOW())",
+ [videoId, ownerId, organizationId, JSON.stringify({ type: "webMP4" })],
+ );
+ await connection.query(
+ "INSERT INTO video_uploads (video_id, phase, mode) VALUES (?, 'uploading', 'singlepart')",
+ [videoId],
+ );
+ await connection.query(
+ "INSERT INTO loom_migration_imports (videoId, requestId, createdAt) VALUES (?, ?, NOW())",
+ [videoId, requestId],
+ );
+
+ signInAs(operatorId);
+ const completion = actions.updateLoomMigrationStatus({
+ requestId,
+ nextStatus: "completed",
+ message: "",
+ verified: true,
+ expectedVideoCount: 0,
+ importedVideoCount: 0,
+ });
+ const settled = completion.then(
+ () => "completed",
+ (error: Error) => error.message,
+ );
+ await new Promise((resolve) => setTimeout(resolve, 300));
+ await connection.commit();
+
+ expect(await settled).toContain("still processing");
+ expect((await getRequest(requestId)).status).toBe("pending");
+ } finally {
+ await connection.end();
+ }
+ });
+
+ it("refuses to map another request's video", async () => {
+ const first = await makeOrganization();
+ const firstRequest = await requestMigration(
+ first.organizationId,
+ first.ownerId,
+ );
+ const second = await makeOrganization();
+ const secondRequest = await requestMigration(
+ second.organizationId,
+ second.ownerId,
+ );
+
+ signInAs(operatorId);
+ const prepared = await loom.createConciergeLoomFileUpload({
+ requestId: firstRequest,
+ loomUrl: `https://www.loom.com/share/${loomId()}`,
+ userEmail: `${id()}@example.com`,
+ spaceName: "",
+ videoTitle: "First",
+ });
+ await expect(
+ loom.finishConciergeLoomFileUpload({
+ requestId: secondRequest,
+ videoId: prepared.videoId,
+ }),
+ ).rejects.toThrow("no longer available");
+ });
+});
diff --git a/apps/web/__tests__/unit/loom-csv.test.ts b/apps/web/__tests__/unit/loom-csv.test.ts
new file mode 100644
index 0000000000..90d8db8f19
--- /dev/null
+++ b/apps/web/__tests__/unit/loom-csv.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from "vitest";
+import { parseConciergeLoomCsv } from "@/lib/loom-csv";
+
+describe("concierge Loom CSV", () => {
+ it("keeps quoted workspace names and source row numbers", () => {
+ const rows = parseConciergeLoomCsv(
+ "\uFEFFuser_email,space_name,loom_video_url\r\n" +
+ "owner@example.com," +
+ JSON.stringify("Sales, Europe") +
+ ",https://www.loom.com/share/0123456789abcdef\r\n",
+ );
+ expect(rows).toEqual([
+ {
+ rowNumber: 2,
+ loomUrl: "https://www.loom.com/share/0123456789abcdef",
+ userEmail: "owner@example.com",
+ spaceName: "Sales, Europe",
+ },
+ ]);
+ });
+
+ it("rejects a file without the mapping columns", () => {
+ expect(() => parseConciergeLoomCsv("url,email\nlink,person@x.com")).toThrow(
+ "loom_video_url and user_email",
+ );
+ });
+});
diff --git a/apps/web/__tests__/unit/loom-import-ui.test.ts b/apps/web/__tests__/unit/loom-import-ui.test.ts
index d92f8e9091..07fd55309d 100644
--- a/apps/web/__tests__/unit/loom-import-ui.test.ts
+++ b/apps/web/__tests__/unit/loom-import-ui.test.ts
@@ -96,6 +96,9 @@ vi.mock("next/link", () => ({
}: React.PropsWithChildren>) =>
React.createElement("a", props, children),
}));
+vi.mock("@cap/env", () => ({
+ buildEnv: { NEXT_PUBLIC_IS_CAP: "true" },
+}));
vi.mock("@/actions/loom", () => ({
getLoomImportFolders: mocks.folders,
importFromLoom: mocks.import,
@@ -186,6 +189,11 @@ describe("Loom importer component", () => {
it("shows the inherited subfolder and submits it then returns there", async () => {
await render({ folderId: Folder.FolderId.make("child") });
await ready();
+ expect(
+ Array.from(container.querySelectorAll("a")).find(
+ (link) => link.getAttribute("href") === "/dashboard/migrations/loom",
+ )?.textContent,
+ ).toContain("Want Cap to move your whole Loom workspace for you?");
expect(
getByRole(container, "combobox", { name: "Import to" }).textContent,
).toContain("My Caps / Course / Live Calls - Two");
diff --git a/apps/web/__tests__/unit/loom-migration-state.test.ts b/apps/web/__tests__/unit/loom-migration-state.test.ts
new file mode 100644
index 0000000000..87b1f90a54
--- /dev/null
+++ b/apps/web/__tests__/unit/loom-migration-state.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from "vitest";
+import { validateOperatorMigrationUpdate } from "@/lib/loom-migration-state";
+
+const base = {
+ currentStatus: "in_progress" as const,
+ nextStatus: "completed" as const,
+ message: "Migration finished",
+ verified: true,
+ expectedVideoCount: 12,
+ importedVideoCount: 12,
+};
+
+describe("Loom migration completion", () => {
+ it("requires a verified library and reconciled counts", () => {
+ expect(() =>
+ validateOperatorMigrationUpdate({ ...base, verified: false }),
+ ).toThrow("Verify the migrated library");
+ expect(() =>
+ validateOperatorMigrationUpdate({ ...base, importedVideoCount: 11 }),
+ ).toThrow("Reconcile the expected video count");
+ expect(() =>
+ validateOperatorMigrationUpdate({ ...base, expectedVideoCount: null }),
+ ).toThrow("Enter the agreed source video count");
+ });
+
+ it("requires a useful message when Cap asks for information", () => {
+ expect(() =>
+ validateOperatorMigrationUpdate({
+ ...base,
+ nextStatus: "needs_information",
+ message: " ",
+ }),
+ ).toThrow("Tell the customer");
+ });
+
+ it("accepts completion after verification", () => {
+ expect(validateOperatorMigrationUpdate(base)).toBe("Migration finished");
+ });
+});
diff --git a/apps/web/actions/loom-concierge.tsx b/apps/web/actions/loom-concierge.tsx
new file mode 100644
index 0000000000..b332c0c2e8
--- /dev/null
+++ b/apps/web/actions/loom-concierge.tsx
@@ -0,0 +1,390 @@
+"use server";
+
+import { db } from "@cap/database";
+import { sendEmail } from "@cap/database/emails/config";
+import {
+ LoomMigrationRequestEmail,
+ LoomMigrationStatusEmail,
+} from "@cap/database/emails/loom-migration";
+import { nanoId } from "@cap/database/helpers";
+import {
+ loomMigrationImports,
+ loomMigrationRequests,
+ organizations,
+ users,
+ videoUploads,
+} from "@cap/database/schema";
+import { serverEnv } from "@cap/env";
+import type { Organisation } from "@cap/web-domain";
+import { and, eq, inArray, isNotNull } from "drizzle-orm";
+import { revalidatePath } from "next/cache";
+import {
+ getCustomerMigrationRequests,
+ getOperatorMigrationQueue,
+ requireCustomerMigrationAccess,
+ requireMigrationOperator,
+ requireProCustomerMigrationAccess,
+ toLoomMigrationResult,
+} from "@/lib/loom-concierge";
+import {
+ LOOM_MIGRATION_STATUS_LABELS,
+ LoomMigrationError,
+ type LoomMigrationStatus,
+ normalizeMigrationText,
+ validateOperatorMigrationUpdate,
+} from "@/lib/loom-migration-state";
+import { isOrganizationOwnerPro } from "@/lib/org-pro";
+
+const SUPPORT_EMAIL = "hello@cap.so";
+
+function affectedRows(result: unknown) {
+ if (Array.isArray(result)) {
+ return (
+ (result[0] as { affectedRows?: number } | undefined)?.affectedRows ?? 0
+ );
+ }
+ return (result as { affectedRows?: number } | undefined)?.affectedRows ?? 0;
+}
+
+function refreshMigrationPages() {
+ revalidatePath("/dashboard/migrations/loom");
+ revalidatePath("/dashboard/admin/loom-migrations");
+}
+
+async function findMigration(requestId: string) {
+ if (typeof requestId !== "string" || !/^[A-Za-z0-9_-]{15}$/.test(requestId)) {
+ throw new LoomMigrationError("Invalid migration request.");
+ }
+ const [request] = await db()
+ .select()
+ .from(loomMigrationRequests)
+ .where(eq(loomMigrationRequests.id, requestId))
+ .limit(1);
+ if (!request) throw new LoomMigrationError("Migration request not found.");
+ return request;
+}
+
+export async function getLoomMigrationDashboard(
+ organizationId: Organisation.OrganisationId,
+) {
+ await requireCustomerMigrationAccess(organizationId);
+ const [isPro, requests] = await Promise.all([
+ isOrganizationOwnerPro(organizationId),
+ getCustomerMigrationRequests(organizationId),
+ ]);
+ return { isPro, requests };
+}
+
+type LoomMigrationRequestInput = {
+ organizationId: Organisation.OrganisationId;
+ workspaceName: string;
+ note: string;
+};
+
+export async function requestLoomMigration(input: LoomMigrationRequestInput) {
+ return toLoomMigrationResult(() => createMigrationRequest(input));
+}
+
+async function createMigrationRequest({
+ organizationId,
+ workspaceName,
+ note,
+}: LoomMigrationRequestInput) {
+ const user = await requireProCustomerMigrationAccess(organizationId);
+ const normalizedWorkspaceName = normalizeMigrationText(
+ workspaceName,
+ "Workspace name",
+ 255,
+ );
+ const normalizedNote = normalizeMigrationText(note, "Note", 2000);
+ const [existing] = await db()
+ .select({ id: loomMigrationRequests.id })
+ .from(loomMigrationRequests)
+ .where(eq(loomMigrationRequests.activeOrganizationId, organizationId))
+ .limit(1);
+ if (existing) return { id: existing.id, alreadyRequested: true };
+
+ const id = nanoId();
+ try {
+ await db()
+ .insert(loomMigrationRequests)
+ .values({
+ id,
+ organizationId,
+ activeOrganizationId: organizationId,
+ requestedByUserId: user.id,
+ workspaceName: normalizedWorkspaceName || null,
+ customerNote: normalizedNote || null,
+ });
+ } catch (error) {
+ const [racedRequest] = await db()
+ .select({ id: loomMigrationRequests.id })
+ .from(loomMigrationRequests)
+ .where(eq(loomMigrationRequests.activeOrganizationId, organizationId))
+ .limit(1);
+ if (racedRequest) {
+ return { id: racedRequest.id, alreadyRequested: true };
+ }
+ throw error;
+ }
+
+ const [organization] = await db()
+ .select({ name: organizations.name })
+ .from(organizations)
+ .where(eq(organizations.id, organizationId))
+ .limit(1);
+ try {
+ const delivery = await sendEmail({
+ email: SUPPORT_EMAIL,
+ subject: `New Loom migration request: ${organization?.name ?? "Cap workspace"}`,
+ react: (
+
+ ),
+ idempotencyKey: `loom-migration-request-${id}`,
+ });
+ if (delivery?.error) {
+ console.error(
+ "Failed to send Loom migration request notice",
+ delivery.error,
+ );
+ }
+ } catch (error) {
+ console.error("Failed to send Loom migration request notice", error);
+ }
+
+ refreshMigrationPages();
+ return { id, alreadyRequested: false };
+}
+
+export async function confirmLoomMigrationInvite(requestId: string) {
+ return toLoomMigrationResult(() => markMigrationInviteSent(requestId));
+}
+
+async function markMigrationInviteSent(requestId: string) {
+ const request = await findMigration(requestId);
+ await requireCustomerMigrationAccess(request.organizationId);
+ if (request.status === "completed") {
+ throw new LoomMigrationError("This migration is already complete.");
+ }
+ if (request.invitedAt) return;
+ const result = await db()
+ .update(loomMigrationRequests)
+ .set({ invitedAt: new Date(), updatedAt: new Date() })
+ .where(
+ and(
+ eq(loomMigrationRequests.id, requestId),
+ isNotNull(loomMigrationRequests.activeOrganizationId),
+ ),
+ );
+ if (affectedRows(result) === 0) {
+ throw new LoomMigrationError(
+ "The migration request changed. Refresh and try again.",
+ );
+ }
+ refreshMigrationPages();
+}
+
+type LoomMigrationAnswerInput = { requestId: string; answer: string };
+
+export async function answerLoomMigrationQuestion(
+ input: LoomMigrationAnswerInput,
+) {
+ return toLoomMigrationResult(() => saveMigrationAnswer(input));
+}
+
+async function saveMigrationAnswer({
+ requestId,
+ answer,
+}: LoomMigrationAnswerInput) {
+ const request = await findMigration(requestId);
+ await requireCustomerMigrationAccess(request.organizationId);
+ const normalizedAnswer = normalizeMigrationText(answer, "Answer", 2000);
+ if (!normalizedAnswer)
+ throw new LoomMigrationError("Enter the information Cap asked for.");
+ const result = await db()
+ .update(loomMigrationRequests)
+ .set({
+ customerReply: normalizedAnswer,
+ status: "pending",
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(loomMigrationRequests.id, requestId),
+ eq(loomMigrationRequests.status, "needs_information"),
+ ),
+ );
+ if (affectedRows(result) === 0) {
+ throw new LoomMigrationError(
+ "The migration request changed. Refresh and try again.",
+ );
+ }
+ refreshMigrationPages();
+}
+
+export async function getLoomMigrationOperatorQueue() {
+ return getOperatorMigrationQueue();
+}
+
+type LoomMigrationStatusInput = {
+ requestId: string;
+ nextStatus: LoomMigrationStatus;
+ message: string;
+ verified: boolean;
+ expectedVideoCount: number | null;
+ importedVideoCount: number;
+};
+
+export async function updateLoomMigrationStatus(
+ input: LoomMigrationStatusInput,
+) {
+ return toLoomMigrationResult(() => saveMigrationStatus(input));
+}
+
+async function saveMigrationStatus({
+ requestId,
+ nextStatus,
+ message,
+ verified,
+ expectedVideoCount,
+ importedVideoCount,
+}: LoomMigrationStatusInput) {
+ const operator = await requireMigrationOperator();
+ const request = await findMigration(requestId);
+ const normalizedMessage = validateOperatorMigrationUpdate({
+ currentStatus: request.status,
+ nextStatus,
+ message,
+ verified,
+ expectedVideoCount,
+ importedVideoCount,
+ });
+ if (
+ nextStatus === request.status &&
+ (normalizedMessage || null) === request.capMessage &&
+ expectedVideoCount === request.expectedVideoCount &&
+ importedVideoCount === request.importedVideoCount
+ ) {
+ return;
+ }
+ const [organization] = await db()
+ .select({ name: organizations.name })
+ .from(organizations)
+ .where(eq(organizations.id, request.organizationId))
+ .limit(1);
+ await db().transaction(async (tx) => {
+ const now = new Date();
+ const [current] = await tx
+ .select({
+ status: loomMigrationRequests.status,
+ activeOrganizationId: loomMigrationRequests.activeOrganizationId,
+ activeImportCount: loomMigrationRequests.activeImportCount,
+ activeImportLeaseUntil: loomMigrationRequests.activeImportLeaseUntil,
+ })
+ .from(loomMigrationRequests)
+ .where(eq(loomMigrationRequests.id, requestId))
+ .limit(1)
+ .for("update");
+ const importStarting =
+ !!current &&
+ current.activeImportCount > 0 &&
+ (!current.activeImportLeaseUntil || current.activeImportLeaseUntil > now);
+ if (
+ !current?.activeOrganizationId ||
+ current.status !== request.status ||
+ (nextStatus === "completed" && importStarting)
+ ) {
+ throw new LoomMigrationError(
+ "The migration request changed or imports are starting. Refresh and try again.",
+ );
+ }
+ if (nextStatus === "completed") {
+ const [unfinishedImport] = await tx
+ .select({ videoId: videoUploads.videoId })
+ .from(loomMigrationImports)
+ .innerJoin(
+ videoUploads,
+ eq(videoUploads.videoId, loomMigrationImports.videoId),
+ )
+ .where(
+ and(
+ eq(loomMigrationImports.requestId, requestId),
+ inArray(videoUploads.phase, [
+ "uploading",
+ "processing",
+ "generating_thumbnail",
+ "error",
+ ]),
+ ),
+ )
+ .limit(1)
+ .for("share");
+ if (unfinishedImport) {
+ throw new LoomMigrationError(
+ "Loom imports are still processing or have errors. Resolve them before completion.",
+ );
+ }
+ }
+ await tx
+ .update(loomMigrationRequests)
+ .set({
+ status: nextStatus,
+ capMessage: normalizedMessage || null,
+ expectedVideoCount,
+ importedVideoCount,
+ activeImportCount: nextStatus === "completed" ? 0 : undefined,
+ activeImportLeaseToken: nextStatus === "completed" ? null : undefined,
+ activeImportLeaseUntil: nextStatus === "completed" ? null : undefined,
+ activeOrganizationId:
+ nextStatus === "completed" ? null : request.organizationId,
+ completedAt: nextStatus === "completed" ? now : null,
+ lastOperatorUserId: operator.id,
+ lastOperatorAt: now,
+ updatedAt: now,
+ })
+ .where(eq(loomMigrationRequests.id, requestId));
+ });
+
+ const customerVisibleChange =
+ nextStatus !== request.status ||
+ (normalizedMessage || null) !== request.capMessage;
+ const [requester] = customerVisibleChange
+ ? await db()
+ .select({ email: users.email })
+ .from(users)
+ .where(eq(users.id, request.requestedByUserId))
+ .limit(1)
+ : [];
+ if (requester) {
+ try {
+ const delivery = await sendEmail({
+ email: requester.email,
+ subject: `Loom migration: ${LOOM_MIGRATION_STATUS_LABELS[nextStatus]}`,
+ react: (
+
+ ),
+ replyTo: SUPPORT_EMAIL,
+ idempotencyKey: `loom-migration-status-${requestId}-${Date.now()}`,
+ });
+ if (delivery?.error) {
+ console.error(
+ "Failed to send Loom migration status notice",
+ delivery.error,
+ );
+ }
+ } catch (error) {
+ console.error("Failed to send Loom migration status notice", error);
+ }
+ }
+ refreshMigrationPages();
+}
diff --git a/apps/web/actions/loom.ts b/apps/web/actions/loom.ts
index 80386e189c..2f8ce51d17 100644
--- a/apps/web/actions/loom.ts
+++ b/apps/web/actions/loom.ts
@@ -7,7 +7,10 @@ import { nanoId } from "@cap/database/helpers";
import {
folders,
importedVideos,
+ loomMigrationImports,
+ loomMigrationRequests,
organizationMembers,
+ organizations,
sharedVideos,
spaceMembers,
spaces,
@@ -24,11 +27,12 @@ import {
type Organisation,
Space,
SpaceMemberId,
+ type Storage as StorageDomain,
type User,
Video,
} from "@cap/web-domain";
-import { and, asc, eq, isNull } from "drizzle-orm";
-import { Option } from "effect";
+import { and, asc, eq, isNotNull, isNull, sql } from "drizzle-orm";
+import { Effect, Option, Schedule } from "effect";
import { revalidatePath } from "next/cache";
import { start } from "workflow/api";
import {
@@ -37,10 +41,24 @@ import {
requireOrganizationSettingsManager,
} from "@/actions/organization/authorization";
import { requireSpaceManager } from "@/actions/organization/space-authorization";
+import {
+ releaseConciergeImport,
+ renewConciergeImport,
+ requireMigrationOperator,
+ reserveConciergeImport,
+ toLoomMigrationResult,
+} from "@/lib/loom-concierge";
import type { LoomImportDestination } from "@/lib/loom-import-destination";
+import {
+ LoomMigrationError,
+ type LoomMigrationResult,
+} from "@/lib/loom-migration-state";
+import { isOrganizationOwnerPro } from "@/lib/org-pro";
import { provisionOrganizationInvitee } from "@/lib/organization-provisioning";
import { canManageOrganizationSettings } from "@/lib/permissions/roles";
import { runPromise } from "@/lib/server";
+import { startVideoProcessingWorkflow } from "@/lib/video-processing";
+import { decodeStorageVideo } from "@/lib/video-storage";
import { importLoomVideoWorkflow } from "@/workflows/import-loom-video";
interface LoomUrlResponse {
@@ -97,22 +115,26 @@ const MAX_LOOM_SPACE_NAME_LENGTH = 255;
const LOOM_CSV_LIMIT_ERROR = `CSV imports are limited to ${MAX_LOOM_CSV_ROWS} rows at a time. Contact support to raise this limit.`;
const LOOM_CSV_PERMISSION_ERROR =
"Only organization admins and owners can import Loom videos from a CSV.";
+const LOOM_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{10,64}$/;
function extractLoomVideoId(url: string): string | null {
try {
const parsed = new URL(url);
- if (!parsed.hostname.includes("loom.com")) {
+ if (
+ parsed.protocol !== "https:" ||
+ (parsed.hostname !== "loom.com" && !parsed.hostname.endsWith(".loom.com"))
+ ) {
return null;
}
const pathParts = parsed.pathname.split("/").filter(Boolean);
const id = pathParts[pathParts.length - 1] ?? null;
- if (!id || id.length < 10) {
+ if (!id || !LOOM_VIDEO_ID_PATTERN.test(id)) {
return null;
}
- return id.split("?")[0] ?? null;
+ return id;
} catch {
return null;
}
@@ -120,7 +142,7 @@ function extractLoomVideoId(url: string): string | null {
async function fetchLoomEndpoint(
videoId: string,
- endpoint: string,
+ endpoint: "transcoded-url" | "raw-url",
includeBody = true,
): Promise {
try {
@@ -139,7 +161,7 @@ async function fetchLoomEndpoint(
}
const response = await fetch(
- `https://www.loom.com/api/campaigns/sessions/${videoId}/${endpoint}`,
+ `https://www.loom.com/api/campaigns/sessions/${encodeURIComponent(videoId)}/${endpoint}`,
options,
);
@@ -201,7 +223,10 @@ function isDirectMp4Url(url: string): boolean {
}
async function getLoomDownloadUrl(loomVideoId: string): Promise {
- const requestVariants: Array<{ endpoint: string; includeBody: boolean }> = [
+ const requestVariants: Array<{
+ endpoint: "transcoded-url" | "raw-url";
+ includeBody: boolean;
+ }> = [
{ endpoint: "transcoded-url", includeBody: true },
{ endpoint: "raw-url", includeBody: true },
{ endpoint: "transcoded-url", includeBody: false },
@@ -227,7 +252,7 @@ async function fetchLoomOEmbed(
): Promise<{ duration?: number; width?: number; height?: number } | null> {
try {
const response = await fetch(
- `https://www.loom.com/v1/oembed?url=https://www.loom.com/share/${loomVideoId}`,
+ `https://www.loom.com/v1/oembed?url=${encodeURIComponent(`https://www.loom.com/share/${loomVideoId}`)}`,
{ headers: { Accept: "application/json" } },
);
if (!response.ok) return null;
@@ -301,11 +326,15 @@ async function importLoomVideoForOwner({
orgId,
ownerId,
destination = {},
+ migrationRequestId,
+ migrationLeaseToken,
}: {
loomUrl: string;
orgId: Organisation.OrganisationId;
ownerId: User.UserId;
destination?: LoomImportDestination;
+ migrationRequestId?: string;
+ migrationLeaseToken?: string;
}): Promise {
const loomVideoId = extractLoomVideoId(loomUrl.trim());
if (!loomVideoId) {
@@ -396,6 +425,29 @@ async function importLoomVideoForOwner({
`Loom Import - ${new Date().toLocaleDateString("en-US", { day: "numeric", month: "long", year: "numeric" })}`;
await db().transaction(async (tx) => {
+ if (migrationRequestId) {
+ const [reservation] = await tx
+ .select({
+ activeOrganizationId: loomMigrationRequests.activeOrganizationId,
+ leaseToken: loomMigrationRequests.activeImportLeaseToken,
+ leaseUntil: loomMigrationRequests.activeImportLeaseUntil,
+ })
+ .from(loomMigrationRequests)
+ .where(eq(loomMigrationRequests.id, migrationRequestId))
+ .limit(1)
+ .for("update");
+ if (
+ !migrationLeaseToken ||
+ !reservation?.activeOrganizationId ||
+ reservation.leaseToken !== migrationLeaseToken ||
+ !reservation.leaseUntil ||
+ reservation.leaseUntil <= new Date()
+ ) {
+ throw new LoomMigrationError(
+ "The concierge import expired. Retry this video.",
+ );
+ }
+ }
await tx.insert(videos).values({
id: videoId,
name,
@@ -424,6 +476,19 @@ async function importLoomVideoForOwner({
source: "loom",
sourceId: loomVideoId,
});
+ if (migrationRequestId) {
+ await tx.insert(loomMigrationImports).values({
+ videoId,
+ requestId: migrationRequestId,
+ });
+ await tx
+ .update(loomMigrationRequests)
+ .set({
+ queuedVideoCount: sql`${loomMigrationRequests.queuedVideoCount} + 1`,
+ updatedAt: new Date(),
+ })
+ .where(eq(loomMigrationRequests.id, migrationRequestId));
+ }
if (destination.spaceId === orgId) {
await tx.insert(sharedVideos).values({
@@ -771,6 +836,22 @@ export async function importFromLoomCsv({
};
}
+ return processLoomCsvRows({ rows, orgId, actorId: user.id });
+}
+
+async function processLoomCsvRows({
+ rows,
+ orgId,
+ actorId,
+ migrationRequestId,
+ migrationLeaseToken,
+}: {
+ rows: LoomCsvImportRow[];
+ orgId: Organisation.OrganisationId;
+ actorId: User.UserId;
+ migrationRequestId?: string;
+ migrationLeaseToken?: string;
+}): Promise {
const inputRows = Array.isArray(rows) ? rows : [];
const normalizedRows = inputRows
.map((row, index) => ({
@@ -815,6 +896,14 @@ export async function importFromLoomCsv({
const touchedSpaceIds = new Set();
for (const row of normalizedRows) {
+ if (migrationRequestId) {
+ if (!migrationLeaseToken) {
+ throw new LoomMigrationError(
+ "The concierge import is missing its reservation.",
+ );
+ }
+ await renewConciergeImport(migrationRequestId, migrationLeaseToken);
+ }
if (!row.loomUrl) {
results.push({
rowNumber: row.rowNumber,
@@ -855,7 +944,7 @@ export async function importFromLoomCsv({
const provisionedMember = await provisionOrganizationInvitee({
organizationId: orgId,
email: row.userEmail,
- invitedByUserId: user.id,
+ invitedByUserId: actorId,
role: "member",
});
member = {
@@ -879,6 +968,8 @@ export async function importFromLoomCsv({
loomUrl: row.loomUrl,
orgId,
ownerId: member.userId,
+ migrationRequestId,
+ migrationLeaseToken,
});
let spaceName = row.spaceName || undefined;
@@ -887,14 +978,14 @@ export async function importFromLoomCsv({
try {
const space = await getOrCreateImportSpace({
orgId,
- createdById: user.id,
+ createdById: actorId,
name: row.spaceName,
spaceCache,
});
await addImportedVideoToSpace({
videoId: result.videoId,
spaceId: space.id,
- addedById: user.id,
+ addedById: actorId,
});
await addImportOwnerToSpace({
spaceId: space.id,
@@ -945,3 +1036,480 @@ export async function importFromLoomCsv({
error: importedCount > 0 ? undefined : "No Loom videos were imported.",
};
}
+
+export async function importFromLoomCsvForConcierge(input: {
+ requestId: string;
+ rows: LoomCsvImportRow[];
+}): Promise> {
+ return toLoomMigrationResult(() => importConciergeCsvRows(input));
+}
+
+async function importConciergeCsvRows({
+ requestId,
+ rows,
+}: {
+ requestId: string;
+ rows: LoomCsvImportRow[];
+}): Promise {
+ const operator = await requireMigrationOperator();
+ if (typeof requestId !== "string" || !/^[A-Za-z0-9_-]{15}$/.test(requestId)) {
+ throw new LoomMigrationError("Invalid migration request.");
+ }
+ const [request] = await db()
+ .select({
+ organizationId: loomMigrationRequests.organizationId,
+ status: loomMigrationRequests.status,
+ ownerId: organizations.ownerId,
+ })
+ .from(loomMigrationRequests)
+ .innerJoin(
+ organizations,
+ eq(organizations.id, loomMigrationRequests.organizationId),
+ )
+ .where(
+ and(
+ eq(loomMigrationRequests.id, requestId),
+ isNotNull(loomMigrationRequests.activeOrganizationId),
+ isNull(organizations.tombstoneAt),
+ ),
+ )
+ .limit(1);
+ if (!request || request.status === "completed") {
+ throw new LoomMigrationError("The migration request is no longer active.");
+ }
+ if (!(await isOrganizationOwnerPro(request.organizationId))) {
+ throw new LoomMigrationError("The destination workspace needs Cap Pro.");
+ }
+ if (!Array.isArray(rows) || rows.length > 10) {
+ throw new LoomMigrationError(
+ "Concierge CSV imports run in batches of 10 videos.",
+ );
+ }
+ const leaseToken = await reserveConciergeImport(requestId, operator.id);
+ try {
+ const result = await processLoomCsvRows({
+ rows,
+ orgId: request.organizationId,
+ actorId: request.ownerId,
+ migrationRequestId: requestId,
+ migrationLeaseToken: leaseToken,
+ });
+ revalidatePath("/dashboard/migrations/loom");
+ revalidatePath("/dashboard/admin/loom-migrations");
+ return result;
+ } finally {
+ await releaseConciergeImport(requestId, leaseToken);
+ }
+}
+
+type ConciergeLoomFileUploadInput = {
+ requestId: string;
+ loomUrl: string;
+ userEmail: string;
+ spaceName: string;
+ videoTitle: string;
+};
+
+type ConciergeLoomFileUpload = {
+ videoId: Video.VideoId;
+ uploadTarget: StorageDomain.UploadTarget;
+};
+
+export async function createConciergeLoomFileUpload(
+ input: ConciergeLoomFileUploadInput,
+): Promise> {
+ return toLoomMigrationResult(() => prepareConciergeLoomFileUpload(input));
+}
+
+async function prepareConciergeLoomFileUpload({
+ requestId,
+ loomUrl,
+ userEmail,
+ spaceName,
+ videoTitle,
+}: ConciergeLoomFileUploadInput): Promise {
+ const operator = await requireMigrationOperator();
+ if (typeof requestId !== "string" || !/^[A-Za-z0-9_-]{15}$/.test(requestId)) {
+ throw new LoomMigrationError("Invalid migration request.");
+ }
+ const loomVideoId = extractLoomVideoId(loomUrl.trim());
+ if (!loomVideoId)
+ throw new LoomMigrationError("Enter a valid Loom video URL.");
+ const normalizedEmail = normalizeImportEmail(userEmail);
+ if (!isValidImportEmail(normalizedEmail)) {
+ throw new LoomMigrationError("Enter a valid destination owner email.");
+ }
+ const normalizedSpaceName = normalizeImportSpaceName(spaceName);
+ if (!isValidImportSpaceName(normalizedSpaceName)) {
+ throw new LoomMigrationError("Space name is too long.");
+ }
+ const normalizedTitle = videoTitle.trim();
+ if (!normalizedTitle || normalizedTitle.length > 255) {
+ throw new LoomMigrationError(
+ "Enter a video title of 255 characters or fewer.",
+ );
+ }
+ const [request] = await db()
+ .select({
+ organizationId: loomMigrationRequests.organizationId,
+ ownerId: organizations.ownerId,
+ })
+ .from(loomMigrationRequests)
+ .innerJoin(
+ organizations,
+ eq(organizations.id, loomMigrationRequests.organizationId),
+ )
+ .where(
+ and(
+ eq(loomMigrationRequests.id, requestId),
+ isNotNull(loomMigrationRequests.activeOrganizationId),
+ isNull(organizations.tombstoneAt),
+ ),
+ )
+ .limit(1);
+ if (!request)
+ throw new LoomMigrationError("The migration request is no longer active.");
+ if (!(await isOrganizationOwnerPro(request.organizationId))) {
+ throw new LoomMigrationError("The destination workspace needs Cap Pro.");
+ }
+ const leaseToken = await reserveConciergeImport(requestId, operator.id);
+ try {
+ const [existing] = await db()
+ .select({
+ id: importedVideos.id,
+ ownerId: videos.ownerId,
+ ownerEmail: users.email,
+ bucket: videos.bucket,
+ storageIntegrationId: videos.storageIntegrationId,
+ rawFileKey: videoUploads.rawFileKey,
+ phase: videoUploads.phase,
+ processingMessage: videoUploads.processingMessage,
+ })
+ .from(importedVideos)
+ .leftJoin(
+ videos,
+ and(
+ eq(videos.id, importedVideos.id),
+ eq(videos.orgId, importedVideos.orgId),
+ ),
+ )
+ .leftJoin(users, eq(users.id, videos.ownerId))
+ .leftJoin(videoUploads, eq(videoUploads.videoId, importedVideos.id))
+ .where(
+ and(
+ eq(importedVideos.orgId, request.organizationId),
+ eq(importedVideos.source, "loom"),
+ eq(importedVideos.sourceId, loomVideoId),
+ ),
+ )
+ .limit(1);
+ if (existing) {
+ const existingRawFileKey =
+ existing.rawFileKey ??
+ (existing.phase === "error" && existing.ownerId
+ ? `${existing.ownerId}/${existing.id}/raw-upload.mp4`
+ : null);
+ if (
+ !existing.ownerId ||
+ !existing.ownerEmail ||
+ normalizeImportEmail(existing.ownerEmail) !== normalizedEmail ||
+ !existingRawFileKey ||
+ (existing.phase !== "error" &&
+ (existing.phase !== "uploading" ||
+ existing.processingMessage !== "Uploading Loom video..."))
+ ) {
+ throw new LoomMigrationError(
+ "This Loom video is already in the Cap import inventory.",
+ );
+ }
+ const retryUpload = await Storage.createUploadTargetForUser(
+ existing.ownerId,
+ existingRawFileKey,
+ {
+ contentType: "video/mp4",
+ videoTitle: normalizedTitle,
+ method: "put",
+ fields: { "x-amz-meta-userid": existing.ownerId },
+ },
+ request.organizationId,
+ ).pipe(runPromise);
+ if (
+ Option.getOrNull(retryUpload.bucketId) !== existing.bucket ||
+ Option.getOrNull(retryUpload.storageIntegrationId) !==
+ existing.storageIntegrationId
+ ) {
+ throw new LoomMigrationError(
+ "The Cap storage destination changed. Review this upload before retrying.",
+ );
+ }
+ const [mapped] = await db()
+ .select({ requestId: loomMigrationImports.requestId })
+ .from(loomMigrationImports)
+ .where(
+ eq(loomMigrationImports.videoId, Video.VideoId.make(existing.id)),
+ )
+ .limit(1);
+ if (mapped && mapped.requestId !== requestId) {
+ throw new LoomMigrationError(
+ "This Loom import belongs to another migration request.",
+ );
+ }
+ if (!mapped || !existing.rawFileKey) {
+ await db().transaction(async (tx) => {
+ const [reservation] = await tx
+ .select({
+ activeOrganizationId: loomMigrationRequests.activeOrganizationId,
+ leaseToken: loomMigrationRequests.activeImportLeaseToken,
+ leaseUntil: loomMigrationRequests.activeImportLeaseUntil,
+ })
+ .from(loomMigrationRequests)
+ .where(eq(loomMigrationRequests.id, requestId))
+ .limit(1)
+ .for("update");
+ if (
+ !reservation?.activeOrganizationId ||
+ reservation.leaseToken !== leaseToken ||
+ !reservation.leaseUntil ||
+ reservation.leaseUntil <= new Date()
+ ) {
+ throw new LoomMigrationError(
+ "The concierge import expired. Retry this video.",
+ );
+ }
+ if (!existing.rawFileKey) {
+ await tx
+ .update(videoUploads)
+ .set({ rawFileKey: existingRawFileKey, updatedAt: new Date() })
+ .where(
+ and(
+ eq(videoUploads.videoId, Video.VideoId.make(existing.id)),
+ eq(videoUploads.phase, "error"),
+ isNull(videoUploads.rawFileKey),
+ ),
+ );
+ }
+ if (mapped) return;
+ await tx.insert(loomMigrationImports).values({
+ videoId: Video.VideoId.make(existing.id),
+ requestId,
+ });
+ await tx
+ .update(loomMigrationRequests)
+ .set({
+ queuedVideoCount: sql`${loomMigrationRequests.queuedVideoCount} + 1`,
+ updatedAt: new Date(),
+ })
+ .where(eq(loomMigrationRequests.id, requestId));
+ });
+ }
+ return {
+ videoId: Video.VideoId.make(existing.id),
+ uploadTarget: retryUpload.upload,
+ };
+ }
+ let member = await getOrganizationMemberByEmail(
+ request.organizationId,
+ normalizedEmail,
+ );
+ if (!member) {
+ const provisioned = await provisionOrganizationInvitee({
+ organizationId: request.organizationId,
+ email: normalizedEmail,
+ invitedByUserId: request.ownerId,
+ role: "member",
+ });
+ member = { userId: provisioned.userId, email: normalizedEmail };
+ }
+ const space = normalizedSpaceName
+ ? await getOrCreateImportSpace({
+ orgId: request.organizationId,
+ createdById: request.ownerId,
+ name: normalizedSpaceName,
+ spaceCache: new Map(),
+ })
+ : null;
+ if (space) {
+ await addImportOwnerToSpace({ spaceId: space.id, userId: member.userId });
+ }
+ const videoId = Video.VideoId.make(nanoId());
+ const rawFileKey = `${member.userId}/${videoId}/raw-upload.mp4`;
+ const upload = await Storage.createUploadTargetForUser(
+ member.userId,
+ rawFileKey,
+ {
+ contentType: "video/mp4",
+ videoTitle: normalizedTitle,
+ method: "put",
+ fields: { "x-amz-meta-userid": member.userId },
+ },
+ request.organizationId,
+ ).pipe(runPromise);
+ const isPublic = await getNewVideoPublic(request.organizationId);
+ await db().transaction(async (tx) => {
+ const [reservation] = await tx
+ .select({
+ activeOrganizationId: loomMigrationRequests.activeOrganizationId,
+ leaseToken: loomMigrationRequests.activeImportLeaseToken,
+ leaseUntil: loomMigrationRequests.activeImportLeaseUntil,
+ })
+ .from(loomMigrationRequests)
+ .where(eq(loomMigrationRequests.id, requestId))
+ .limit(1)
+ .for("update");
+ if (
+ !reservation?.activeOrganizationId ||
+ reservation.leaseToken !== leaseToken ||
+ !reservation.leaseUntil ||
+ reservation.leaseUntil <= new Date()
+ ) {
+ throw new LoomMigrationError(
+ "The concierge import expired. Retry this video.",
+ );
+ }
+ await tx.insert(videos).values({
+ id: videoId,
+ name: normalizedTitle,
+ ownerId: member.userId,
+ orgId: request.organizationId,
+ source: { type: "webMP4" as const },
+ bucket: Option.getOrNull(upload.bucketId),
+ storageIntegrationId: Option.getOrNull(upload.storageIntegrationId),
+ public: isPublic,
+ });
+ await tx.insert(videoUploads).values({
+ videoId,
+ mode: "singlepart",
+ phase: "uploading",
+ processingProgress: 0,
+ processingMessage: "Uploading Loom video...",
+ rawFileKey,
+ });
+ await tx.insert(importedVideos).values({
+ id: videoId,
+ orgId: request.organizationId,
+ source: "loom",
+ sourceId: loomVideoId,
+ });
+ await tx.insert(loomMigrationImports).values({ videoId, requestId });
+ await tx
+ .update(loomMigrationRequests)
+ .set({
+ queuedVideoCount: sql`${loomMigrationRequests.queuedVideoCount} + 1`,
+ updatedAt: new Date(),
+ })
+ .where(eq(loomMigrationRequests.id, requestId));
+ if (space) {
+ await tx.insert(spaceVideos).values({
+ id: nanoId(),
+ videoId,
+ spaceId: space.id,
+ addedById: request.ownerId,
+ });
+ }
+ });
+ revalidatePath("/dashboard/admin/loom-migrations");
+ return { videoId, uploadTarget: upload.upload };
+ } finally {
+ await releaseConciergeImport(requestId, leaseToken);
+ }
+}
+
+export async function finishConciergeLoomFileUpload(input: {
+ requestId: string;
+ videoId: Video.VideoId;
+}) {
+ return toLoomMigrationResult(() => startConciergeLoomFileProcessing(input));
+}
+
+async function startConciergeLoomFileProcessing({
+ requestId,
+ videoId,
+}: {
+ requestId: string;
+ videoId: Video.VideoId;
+}) {
+ const operator = await requireMigrationOperator();
+ if (typeof requestId !== "string" || !/^[A-Za-z0-9_-]{15}$/.test(requestId)) {
+ throw new LoomMigrationError("Invalid migration request.");
+ }
+ const leaseToken = await reserveConciergeImport(requestId, operator.id);
+ try {
+ const [record] = await db()
+ .select({
+ video: videos,
+ rawFileKey: videoUploads.rawFileKey,
+ phase: videoUploads.phase,
+ processingMessage: videoUploads.processingMessage,
+ })
+ .from(videos)
+ .innerJoin(
+ importedVideos,
+ and(
+ eq(importedVideos.id, videos.id),
+ eq(importedVideos.orgId, videos.orgId),
+ ),
+ )
+ .innerJoin(videoUploads, eq(videoUploads.videoId, videos.id))
+ .innerJoin(
+ loomMigrationImports,
+ eq(loomMigrationImports.videoId, videos.id),
+ )
+ .innerJoin(
+ loomMigrationRequests,
+ and(
+ eq(loomMigrationRequests.id, loomMigrationImports.requestId),
+ eq(loomMigrationRequests.organizationId, importedVideos.orgId),
+ ),
+ )
+ .where(
+ and(
+ eq(videos.id, videoId),
+ eq(loomMigrationRequests.id, requestId),
+ eq(importedVideos.source, "loom"),
+ isNotNull(loomMigrationRequests.activeOrganizationId),
+ ),
+ )
+ .limit(1);
+ if (
+ !record ||
+ !record.rawFileKey ||
+ (record.phase !== "error" &&
+ (record.phase !== "uploading" ||
+ record.processingMessage !== "Uploading Loom video..."))
+ ) {
+ throw new LoomMigrationError("This Loom upload is no longer available.");
+ }
+ const [bucket] = await Storage.getAccessForVideo(
+ decodeStorageVideo(record.video),
+ ).pipe(runPromise);
+ const head = await bucket
+ .headObject(record.rawFileKey)
+ .pipe(
+ Effect.retry({
+ times: 3,
+ schedule: Schedule.exponential("100 millis"),
+ }),
+ runPromise,
+ )
+ .catch(() => {
+ throw new LoomMigrationError(
+ "The uploaded file was not found in storage. Upload it again.",
+ );
+ });
+ if ((head.ContentLength ?? 0) <= 0)
+ throw new LoomMigrationError("The uploaded file is empty.");
+ const status = await startVideoProcessingWorkflow({
+ videoId,
+ userId: record.video.ownerId,
+ rawFileKey: record.rawFileKey,
+ bucketId: record.video.bucket,
+ processingMessage: "Processing Loom video...",
+ startFailureMessage: "Loom file processing could not start.",
+ });
+ revalidatePath("/dashboard/migrations/loom");
+ revalidatePath("/dashboard/admin/loom-migrations");
+ return { status };
+ } finally {
+ await releaseConciergeImport(requestId, leaseToken);
+ }
+}
diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx
index e91138d70e..d5d0ebfe63 100644
--- a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx
+++ b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx
@@ -46,6 +46,7 @@ import {
loomImportDestinationFromPathname,
loomImportPageHref,
} from "@/lib/loom-import-destination";
+import { MESSENGER_ADMIN_EMAIL } from "@/lib/messenger/constants";
import {
canViewOrganizationSettings,
getEffectiveOrganizationRole,
@@ -78,6 +79,9 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => {
const showDeveloperDashboard =
buildEnv.NEXT_PUBLIC_IS_CAP &&
DEVELOPER_DASHBOARD_ALLOWED_EMAILS.includes(user.email);
+ const showMigrationQueue =
+ buildEnv.NEXT_PUBLIC_IS_CAP &&
+ user.email.toLowerCase() === MESSENGER_ADMIN_EMAIL;
const manageNavigation = [
{
@@ -107,6 +111,17 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => {
icon: ,
subNav: [],
},
+ ...(buildEnv.NEXT_PUBLIC_IS_CAP
+ ? [
+ {
+ name: "Loom Migration",
+ href: "/dashboard/migrations/loom",
+ adminOnly: true,
+ icon: ,
+ subNav: [],
+ },
+ ]
+ : []),
{
name: "Organization Settings",
href: `/dashboard/settings/organization`,
@@ -127,6 +142,16 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => {
},
]
: []),
+ ...(showMigrationQueue
+ ? [
+ {
+ name: "Migration Queue",
+ href: "/dashboard/admin/loom-migrations",
+ icon: ,
+ subNav: [],
+ },
+ ]
+ : []),
];
const [dialogOpen, setDialogOpen] = useState(false);
diff --git a/apps/web/app/(org)/dashboard/admin/loom-migrations/LoomMigrationQueue.tsx b/apps/web/app/(org)/dashboard/admin/loom-migrations/LoomMigrationQueue.tsx
new file mode 100644
index 0000000000..f9d860b04d
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/admin/loom-migrations/LoomMigrationQueue.tsx
@@ -0,0 +1,550 @@
+"use client";
+
+import { Button } from "@cap/ui";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { LoaderCircle } from "lucide-react";
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import {
+ createConciergeLoomFileUpload,
+ finishConciergeLoomFileUpload,
+ importFromLoomCsvForConcierge,
+ type LoomCsvImportRow,
+ type LoomCsvImportRowResult,
+} from "@/actions/loom";
+import {
+ getLoomMigrationOperatorQueue,
+ updateLoomMigrationStatus,
+} from "@/actions/loom-concierge";
+import type { OperatorLoomMigrationView } from "@/lib/loom-concierge";
+import { parseConciergeLoomCsv } from "@/lib/loom-csv";
+import {
+ LOOM_MIGRATION_STATUS_LABELS,
+ type LoomMigrationStatus,
+ unwrapLoomMigrationResult,
+} from "@/lib/loom-migration-state";
+import { uploadWithTarget } from "@/utils/upload-target";
+
+const BATCH_SIZE = 10;
+const BATCH_DELAY_MS = 1500;
+
+function delay(ms: number) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function QueueItem({ request }: { request: OperatorLoomMigrationView }) {
+ const queryClient = useQueryClient();
+ const [nextStatus, setNextStatus] = useState(
+ request.status,
+ );
+ const [message, setMessage] = useState(request.capMessage ?? "");
+ const [expectedVideoCount, setExpectedVideoCount] = useState(
+ request.expectedVideoCount?.toString() ?? "",
+ );
+ const [importedVideoCount, setImportedVideoCount] = useState(
+ request.importedVideoCount.toString(),
+ );
+ const [verified, setVerified] = useState(false);
+ const [csvRows, setCsvRows] = useState([]);
+ const [processedRows, setProcessedRows] = useState(0);
+ const [startedRows, setStartedRows] = useState(0);
+ const [failedRows, setFailedRows] = useState([]);
+ const [fileLoomUrl, setFileLoomUrl] = useState("");
+ const [fileOwnerEmail, setFileOwnerEmail] = useState("");
+ const [fileSpaceName, setFileSpaceName] = useState("");
+ const [fileTitle, setFileTitle] = useState("");
+ const [file, setFile] = useState(null);
+ const [uploadPercent, setUploadPercent] = useState(0);
+
+ useEffect(() => {
+ setNextStatus(request.status);
+ setMessage(request.capMessage ?? "");
+ setExpectedVideoCount(request.expectedVideoCount?.toString() ?? "");
+ setImportedVideoCount(request.importedVideoCount.toString());
+ setVerified(false);
+ }, [
+ request.status,
+ request.capMessage,
+ request.expectedVideoCount,
+ request.importedVideoCount,
+ ]);
+
+ const refresh = () =>
+ queryClient.invalidateQueries({ queryKey: ["loom-migration-queue"] });
+ const statusMutation = useMutation({
+ mutationFn: async () =>
+ unwrapLoomMigrationResult(
+ await updateLoomMigrationStatus({
+ requestId: request.id,
+ nextStatus,
+ message,
+ verified,
+ expectedVideoCount:
+ expectedVideoCount.trim() === ""
+ ? null
+ : Number(expectedVideoCount),
+ importedVideoCount: Number(importedVideoCount),
+ }),
+ ),
+ onSuccess: async () => {
+ await refresh();
+ toast.success("Migration status updated.");
+ },
+ onError: (error) => toast.error(error.message),
+ });
+ const importMutation = useMutation({
+ mutationFn: async () => {
+ if (csvRows.length === 0) throw new Error("Choose a Loom CSV first.");
+ setProcessedRows(0);
+ setStartedRows(0);
+ setFailedRows([]);
+ let started = 0;
+ const failures: LoomCsvImportRowResult[] = [];
+ for (let index = 0; index < csvRows.length; index += BATCH_SIZE) {
+ const batch = csvRows.slice(index, index + BATCH_SIZE);
+ const result = unwrapLoomMigrationResult(
+ await importFromLoomCsvForConcierge({
+ requestId: request.id,
+ rows: batch,
+ }),
+ );
+ started += result.importedCount;
+ failures.push(
+ ...result.results.filter((row) => !row.success || row.error),
+ );
+ setProcessedRows(index + batch.length);
+ setStartedRows(started);
+ setFailedRows([...failures]);
+ if (index + BATCH_SIZE < csvRows.length) await delay(BATCH_DELAY_MS);
+ }
+ return { started, failures };
+ },
+ onSuccess: async (result) => {
+ await refresh();
+ toast.success(
+ `${result.started} imports started. ${result.failures.length} rows need review. Wait for processing and reconcile the library before completion.`,
+ );
+ },
+ onError: async (error) => {
+ await refresh();
+ toast.error(
+ `${error.message} Check the rows already started before retrying.`,
+ );
+ },
+ });
+ const fileMutation = useMutation({
+ mutationFn: async () => {
+ if (!file || !fileLoomUrl.trim() || !fileOwnerEmail.trim()) {
+ throw new Error("Choose an MP4 file, Loom URL, and destination owner.");
+ }
+ if (!file.name.toLowerCase().endsWith(".mp4")) {
+ throw new Error("Choose an MP4 downloaded from Loom.");
+ }
+ if (file.size === 0) throw new Error("The MP4 file is empty.");
+ const prepared = unwrapLoomMigrationResult(
+ await createConciergeLoomFileUpload({
+ requestId: request.id,
+ loomUrl: fileLoomUrl,
+ userEmail: fileOwnerEmail,
+ spaceName: fileSpaceName,
+ videoTitle: fileTitle.trim() || file.name.replace(/\.mp4$/i, ""),
+ }),
+ );
+ setUploadPercent(0);
+ await uploadWithTarget({
+ target: prepared.uploadTarget,
+ body: file,
+ fileName: file.name,
+ onProgress: ({ loaded, total }) => {
+ if (total > 0) setUploadPercent(Math.round((loaded / total) * 100));
+ },
+ });
+ return unwrapLoomMigrationResult(
+ await finishConciergeLoomFileUpload({
+ requestId: request.id,
+ videoId: prepared.videoId,
+ }),
+ );
+ },
+ onSuccess: async (result) => {
+ await refresh();
+ setFile(null);
+ setUploadPercent(0);
+ toast.success(
+ result.status === "started"
+ ? "Loom file uploaded. Processing has started."
+ : "Loom file uploaded. Processing was already underway.",
+ );
+ },
+ onError: async (error) => {
+ await refresh();
+ toast.error(
+ `${error.message} The same Loom URL can be retried after checking its upload.`,
+ );
+ },
+ });
+
+ return (
+
+
+
+
+ {request.organizationName}
+
+
+ {request.requestedByEmail} · {request.organizationId} · Requested{" "}
+ {new Date(request.createdAt).toLocaleDateString()}
+
+
+
+ {LOOM_MIGRATION_STATUS_LABELS[request.status]}
+
+
+
+
Loom workspace: {request.workspaceName || "Not provided"}
+
+ Loom invite:{" "}
+ {request.invitedAt ? "Marked as sent" : "Waiting for customer"}
+
+
Videos queued: {request.queuedVideoCount}
+
Verified videos: {request.importedVideoCount}
+
Imports starting: {request.activeImportCount}
+
+ {request.customerNote && (
+
+ Customer note: {request.customerNote}
+
+ )}
+ {request.customerReply && (
+
+ Latest customer answer: {request.customerReply}
+
+ )}
+
+
+
Import a mapped Loom CSV
+
+ Use loom_video_url,user_email,space_name. The destination is this Cap
+ organization; imports run in batches of 10. Started jobs still need
+ terminal processing checks.
+
+
+ Loom CSV
+
+
{
+ const file = event.target.files?.[0];
+ if (!file) return;
+ try {
+ setCsvRows(parseConciergeLoomCsv(await file.text()));
+ setProcessedRows(0);
+ setStartedRows(0);
+ setFailedRows([]);
+ } catch (error) {
+ setCsvRows([]);
+ toast.error(
+ error instanceof Error
+ ? error.message
+ : "Could not read the CSV.",
+ );
+ }
+ }}
+ type="file"
+ />
+ {csvRows.length > 0 && (
+
+ {csvRows.length} rows ready. Review the source inventory and owner
+ mapping before import.
+
+ )}
+
importMutation.mutate()}
+ size="sm"
+ variant="dark"
+ >
+ {importMutation.isPending
+ ? `Importing ${processedRows}/${csvRows.length}`
+ : "Start import"}
+
+ {processedRows > 0 && (
+
+ {startedRows} jobs started;{" "}
+ {failedRows.filter((row) => !row.success).length} rows failed to
+ start; {failedRows.filter((row) => row.success).length} started rows
+ need space mapping.
+
+ )}
+ {failedRows.length > 0 && (
+
+ {failedRows.map((row) => (
+
+ Row {row.rowNumber}
+ {row.success && row.spaceName ? ` (${row.spaceName})` : ""}:{" "}
+ {row.error || "Could not start import."}
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+export function LoomMigrationQueue() {
+ const queueQuery = useQuery({
+ queryKey: ["loom-migration-queue"],
+ queryFn: getLoomMigrationOperatorQueue,
+ refetchInterval: 20_000,
+ });
+ return (
+
+
+
+ Loom migration queue
+
+
+ Process Cap Pro concierge requests, reconcile every import, and update
+ customers here.
+
+
+ {queueQuery.isPending && (
+
+ Loading requests…
+
+ )}
+ {queueQuery.isError && (
+
+ Could not load the queue. {queueQuery.error.message}
+ queueQuery.refetch()}
+ type="button"
+ >
+ Try again
+
+
+ )}
+ {queueQuery.data?.length === 0 && (
+
+ No active Loom migration requests.
+
+ )}
+ {queueQuery.data?.map((request) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/web/app/(org)/dashboard/admin/loom-migrations/page.tsx b/apps/web/app/(org)/dashboard/admin/loom-migrations/page.tsx
new file mode 100644
index 0000000000..2dc9ff503a
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/admin/loom-migrations/page.tsx
@@ -0,0 +1,17 @@
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import { requireMigrationOperator } from "@/lib/loom-concierge";
+import { LoomMigrationQueue } from "./LoomMigrationQueue";
+
+export const metadata: Metadata = {
+ title: "Loom Migration Queue — Cap",
+};
+
+export default async function Page() {
+ try {
+ await requireMigrationOperator();
+ } catch {
+ notFound();
+ }
+ return ;
+}
diff --git a/apps/web/app/(org)/dashboard/import/ImportPage.tsx b/apps/web/app/(org)/dashboard/import/ImportPage.tsx
index 9712a3af2e..1cf0fe8479 100644
--- a/apps/web/app/(org)/dashboard/import/ImportPage.tsx
+++ b/apps/web/app/(org)/dashboard/import/ImportPage.tsx
@@ -1,18 +1,34 @@
"use client";
-import { faUpload } from "@fortawesome/free-solid-svg-icons";
+import { buildEnv } from "@cap/env";
+import { faArrowsRotate, faUpload } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import Link from "next/link";
+import { useDashboardContext } from "@/app/(org)/dashboard/Contexts";
import {
type LoomImportDestination,
loomImportPageHref,
} from "@/lib/loom-import-destination";
+import {
+ canManageOrganizationSettings,
+ getEffectiveOrganizationRole,
+} from "@/lib/permissions/roles";
export const ImportPage = ({
initialDestination = {},
}: {
initialDestination?: LoomImportDestination;
}) => {
+ const { user, activeOrganization } = useDashboardContext();
+ const currentMember = activeOrganization?.members.find(
+ (member) => member.userId === user.id,
+ );
+ const currentRole = getEffectiveOrganizationRole({
+ userId: user.id,
+ ownerId: activeOrganization?.organization.ownerId,
+ memberRole: currentMember?.role,
+ });
+ const canRequestMigration = canManageOrganizationSettings(currentRole);
return (
@@ -71,6 +87,26 @@ export const ImportPage = ({
+ {buildEnv.NEXT_PUBLIC_IS_CAP && canRequestMigration && (
+
+
+
+
+ Concierge Loom Migration
+
+
+ Let Cap move your Loom workspace. Free with Cap Pro.
+
+
+
+ )}
);
diff --git a/apps/web/app/(org)/dashboard/import/loom/ImportLoomPage.tsx b/apps/web/app/(org)/dashboard/import/loom/ImportLoomPage.tsx
index ebca807982..8e5db52a25 100644
--- a/apps/web/app/(org)/dashboard/import/loom/ImportLoomPage.tsx
+++ b/apps/web/app/(org)/dashboard/import/loom/ImportLoomPage.tsx
@@ -1,5 +1,6 @@
"use client";
+import { buildEnv } from "@cap/env";
import {
Button,
Dialog,
@@ -55,6 +56,7 @@ import {
} from "@/actions/loom";
import { useDashboardContext } from "@/app/(org)/dashboard/Contexts";
import { UpgradeModal } from "@/components/UpgradeModal";
+import { parseLoomCsvRecords } from "@/lib/loom-csv";
import {
type LoomImportDestination,
loomImportDestinationHref,
@@ -132,57 +134,8 @@ function buildCsvImportResult(
};
}
-function parseCsvRecords(text: string) {
- const records: string[][] = [];
- let field = "";
- let row: string[] = [];
- let inQuotes = false;
- const input = text.replace(/^\uFEFF/, "");
-
- for (let index = 0; index < input.length; index += 1) {
- const char = input.charAt(index);
- const next = input.charAt(index + 1);
-
- if (char === '"') {
- if (inQuotes && next === '"') {
- field += '"';
- index += 1;
- } else {
- inQuotes = !inQuotes;
- }
- continue;
- }
-
- if (char === "," && !inQuotes) {
- row.push(field.trim());
- field = "";
- continue;
- }
-
- if ((char === "\n" || char === "\r") && !inQuotes) {
- if (char === "\r" && next === "\n") index += 1;
- row.push(field.trim());
- if (row.some((cell) => cell.length > 0)) records.push(row);
- row = [];
- field = "";
- continue;
- }
-
- field += char;
- }
-
- if (inQuotes) throw new Error("CSV has an unclosed quoted field.");
-
- if (field.length > 0 || row.length > 0) {
- row.push(field.trim());
- if (row.some((cell) => cell.length > 0)) records.push(row);
- }
-
- return records;
-}
-
function parseCsv(text: string, fileName: string): CsvData {
- const records = parseCsvRecords(text);
+ const records = parseLoomCsvRecords(text);
const headers = records[0]?.map((header) => header.trim()) ?? [];
const rows = records
.slice(1)
@@ -601,6 +554,14 @@ export const ImportLoomPage = ({
? "Bring a single Loom video into Cap, or bulk import recordings for organization members and new users from a CSV."
: "Paste a Loom share link to bring it into Cap."}
+ {buildEnv.NEXT_PUBLIC_IS_CAP && canUseCsvImport && (
+
+ Want Cap to move your whole Loom workspace for you?
+
+ )}
diff --git a/apps/web/app/(org)/dashboard/migrations/loom/LoomMigrationPage.tsx b/apps/web/app/(org)/dashboard/migrations/loom/LoomMigrationPage.tsx
new file mode 100644
index 0000000000..6c3cd67422
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/migrations/loom/LoomMigrationPage.tsx
@@ -0,0 +1,403 @@
+"use client";
+
+import { Button } from "@cap/ui";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { CheckCircle2, CircleHelp, Clock3, LoaderCircle } from "lucide-react";
+import Link from "next/link";
+import { useId, useState } from "react";
+import { toast } from "sonner";
+import {
+ answerLoomMigrationQuestion,
+ confirmLoomMigrationInvite,
+ getLoomMigrationDashboard,
+ requestLoomMigration,
+} from "@/actions/loom-concierge";
+import { useDashboardContext } from "@/app/(org)/dashboard/Contexts";
+import type { LoomMigrationView } from "@/lib/loom-concierge";
+import {
+ LOOM_MIGRATION_STATUS_LABELS,
+ type LoomMigrationStatus,
+ unwrapLoomMigrationResult,
+} from "@/lib/loom-migration-state";
+
+const STATUS_STYLE: Record = {
+ pending: "bg-amber-500/10 text-amber-600",
+ in_progress: "bg-blue-3 text-blue-11",
+ needs_information: "bg-orange-500/10 text-orange-600",
+ completed: "bg-green-500/10 text-green-600",
+};
+
+function MigrationStatus({ request }: { request: LoomMigrationView }) {
+ return (
+
+
+
+
Your migration
+
+ Requested on {new Date(request.createdAt).toLocaleDateString()}
+ {request.workspaceName ? ` for ${request.workspaceName}` : ""}
+
+
+
+ {LOOM_MIGRATION_STATUS_LABELS[request.status]}
+
+
+ {request.status === "pending" && (
+
+ Your request is in Cap’s queue. We’ll review your Loom workspace and
+ confirm the migration plan.
+
+ )}
+ {request.status === "in_progress" && (
+
+ Cap is moving your videos. We’ll mark this complete after checking the
+ imported library.
+
+ )}
+ {request.status === "completed" && (
+
+
+
+
Your migration is complete.
+ {request.expectedVideoCount !== null && (
+
+ {request.importedVideoCount} of {request.expectedVideoCount}{" "}
+ videos verified in Cap.
+
+ )}
+
+ View your Caps
+
+
+
+ )}
+ {request.capMessage && (
+
+
Update from Cap
+
{request.capMessage}
+
+ )}
+ {request.customerReply && (
+
+ Your latest reply: {request.customerReply}
+
+ )}
+
+ );
+}
+
+export function LoomMigrationPage() {
+ const { activeOrganization, user } = useDashboardContext();
+ const organizationId = activeOrganization?.organization.id;
+ const queryClient = useQueryClient();
+ const [requestFormOpen, setRequestFormOpen] = useState(false);
+ const [workspaceName, setWorkspaceName] = useState("");
+ const [note, setNote] = useState("");
+ const [answer, setAnswer] = useState("");
+ const answerId = useId();
+ const workspaceNameId = useId();
+ const noteId = useId();
+
+ const migrationQuery = useQuery({
+ queryKey: ["loom-concierge", organizationId],
+ queryFn: () => {
+ if (!organizationId) throw new Error("Select a Cap workspace.");
+ return getLoomMigrationDashboard(organizationId);
+ },
+ enabled: Boolean(organizationId),
+ refetchInterval: 30_000,
+ });
+
+ const refresh = () =>
+ queryClient.invalidateQueries({
+ queryKey: ["loom-concierge", organizationId],
+ });
+ const requestMutation = useMutation({
+ mutationFn: async () => {
+ if (!organizationId) throw new Error("Select a Cap workspace.");
+ return unwrapLoomMigrationResult(
+ await requestLoomMigration({ organizationId, workspaceName, note }),
+ );
+ },
+ onSuccess: async (result) => {
+ await refresh();
+ setRequestFormOpen(false);
+ toast.success(
+ result.alreadyRequested
+ ? "Your migration request is already in the queue."
+ : "Migration requested. Invite hello@cap.so in Loom next.",
+ );
+ },
+ onError: (error) => toast.error(error.message),
+ });
+ const inviteMutation = useMutation({
+ mutationFn: async (requestId: string) =>
+ unwrapLoomMigrationResult(await confirmLoomMigrationInvite(requestId)),
+ onSuccess: async () => {
+ await refresh();
+ toast.success("Thanks. Cap can now review your Loom invite.");
+ },
+ onError: (error) => toast.error(error.message),
+ });
+ const answerMutation = useMutation({
+ mutationFn: async (input: { requestId: string; answer: string }) =>
+ unwrapLoomMigrationResult(await answerLoomMigrationQuestion(input)),
+ onSuccess: async () => {
+ setAnswer("");
+ await refresh();
+ toast.success("Your answer has been sent to Cap.");
+ },
+ onError: (error) => toast.error(error.message),
+ });
+
+ const requests = migrationQuery.data?.requests ?? [];
+ const activeRequest = requests.find(
+ (request) => request.status !== "completed",
+ );
+ const completedRequest = requests.find(
+ (request) => request.status === "completed",
+ );
+ const isPro = migrationQuery.data?.isPro ?? false;
+
+ return (
+
+
+
+ Switch from Loom
+
+
+ Move your Loom library to Cap
+
+
+ Import it yourself, or let Cap handle the move. Concierge migration is
+ included with Cap Pro at no extra cost.
+
+
+
+ {!organizationId && (
+
+ Select a Cap workspace to request a Loom migration.
+
+ )}
+ {organizationId && migrationQuery.isPending && (
+
+ Loading your
+ migration…
+
+ )}
+ {migrationQuery.isError && (
+
+ Could not load your migration. {migrationQuery.error.message}
+ migrationQuery.refetch()}
+ type="button"
+ >
+ Try again
+
+
+ )}
+
+ {activeRequest &&
}
+ {!activeRequest && completedRequest && (
+
+ )}
+
+ {activeRequest && !activeRequest.invitedAt && (
+
+
+
+
+
+ One step left: invite Cap to Loom
+
+
+ In your Loom workspace settings, invite{" "}
+ hello@cap.so with video access and download
+ permission. Some private Library videos need access shared
+ separately. If we find any, we’ll ask you here. You can keep
+ using Loom until we confirm the migration is complete.
+
+
+ Loom’s workspace invite steps
+
+
inviteMutation.mutate(activeRequest.id)}
+ size="sm"
+ variant="dark"
+ >
+ I sent the Loom invite
+
+
+
+
+ )}
+ {activeRequest?.invitedAt && activeRequest.status !== "completed" && (
+
+ Loom invite marked as sent. Cap will take it from here.
+
+ )}
+ {activeRequest?.status === "needs_information" && (
+
{
+ event.preventDefault();
+ answerMutation.mutate({ requestId: activeRequest.id, answer });
+ }}
+ >
+
+ Cap needs a little
+ more information
+
+
+ Your answer
+
+ setAnswer(event.target.value)}
+ value={answer}
+ />
+
+ Send answer
+
+
+ )}
+
+
+
+
Do it yourself
+
+ Best when you already have a list of Loom links and video owners.
+
+
+
+ Prepare a CSV with loom_video_url and user_email. space_name is
+ optional.
+
+
+ Upload up to 500 videos per CSV and review the owner mapping.
+
+
+ Check the import results and your Cap library before leaving Loom.
+
+
+ {user.isPro ? (
+
+ Open CSV importer
+
+ ) : (
+
+ The self-service importer requires your own Cap Pro account.
+
+ )}
+
+
+
+
+ Cap handles it for you
+
+
+ Free with Pro
+
+
+
+ Request the migration and invite hello@cap.so to Loom. We’ll
+ inventory, move, and verify your videos. No CSV preparation needed.
+ If Loom restricts a video, we’ll ask for the specific access we
+ need.
+
+ {!migrationQuery.isPending && !migrationQuery.isError && !isPro && (
+
+ Your organization owner needs Cap Pro to request this service.
+
+ View billing
+
+
+ )}
+ {isPro && !activeRequest && !requestFormOpen && (
+
setRequestFormOpen(true)}
+ size="sm"
+ variant="dark"
+ >
+ Enable concierge migration
+
+ )}
+ {isPro && !activeRequest && requestFormOpen && (
+
{
+ event.preventDefault();
+ requestMutation.mutate();
+ }}
+ >
+
+
+ Loom workspace name{" "}
+ (optional)
+
+ setWorkspaceName(event.target.value)}
+ value={workspaceName}
+ />
+
+
+
+ Anything we should know?{" "}
+ (optional)
+
+ setNote(event.target.value)}
+ value={note}
+ />
+
+
+ Request migration
+
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/app/(org)/dashboard/migrations/loom/page.tsx b/apps/web/app/(org)/dashboard/migrations/loom/page.tsx
new file mode 100644
index 0000000000..bb06782ed7
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/migrations/loom/page.tsx
@@ -0,0 +1,10 @@
+import type { Metadata } from "next";
+import { LoomMigrationPage } from "./LoomMigrationPage";
+
+export const metadata: Metadata = {
+ title: "Loom Migration — Cap",
+};
+
+export default function Page() {
+ return ;
+}
diff --git a/apps/web/content/docs/migrating-to-cap.mdx b/apps/web/content/docs/migrating-to-cap.mdx
index 50a99dedfe..dcc74ca840 100644
--- a/apps/web/content/docs/migrating-to-cap.mdx
+++ b/apps/web/content/docs/migrating-to-cap.mdx
@@ -14,9 +14,21 @@ A successful migration is not just a count of accepted URLs. It preserves owners
|---------|------------------|
| One Loom video | Paste the Loom URL in **Dashboard > Import > Loom** |
| Up to 500 mapped videos | Use the organization CSV importer |
-| More than 500 videos or a complex hierarchy | Run controlled batches with an agent or contact [hello@cap.so](mailto:hello@cap.so) for a managed migration |
+| A library you want Cap to move for you | Request the free Cap Pro concierge migration in **Dashboard > Loom Migration** |
+| More than 500 videos or a complex hierarchy | Request concierge migration or run controlled batches with an agent |
-Loom import is a Cap Pro feature. Bulk CSV import is available to organization owners and admins.
+Loom import is a Cap Pro feature. Bulk CSV import is available to organization owners and admins. Concierge migration is included at no extra cost when the Cap organization owner has Pro.
+
+## Request concierge migration
+
+1. Open **Dashboard > Loom Migration** and select your Cap organization.
+2. Choose **Enable concierge migration**, then **Request migration**. The request enters Cap's queue as **Pending**.
+3. Invite [hello@cap.so](mailto:hello@cap.so) to your Loom workspace with access and download permission for the videos you want moved, then mark the invite as sent in Cap.
+4. Cap inventories the source, maps owners and spaces, imports in batches, and checks the destination. The dashboard shows **In progress** while this is underway.
+5. If a video needs separate access or another detail, the dashboard shows **Information needed** with Cap's exact request. Reply there so the migration can continue.
+6. Cap marks **Migration complete** only after the agreed source count and imported library are reconciled and checked. Keep Loom available until that confirmation.
+
+Loom workspace membership may not expose private Library videos automatically. An account without Loom download rights also cannot export every video. Cap will ask for specific access when the workspace invite does not cover a source video.
## Prepare the source inventory
diff --git a/apps/web/lib/loom-concierge.ts b/apps/web/lib/loom-concierge.ts
new file mode 100644
index 0000000000..27538802fe
--- /dev/null
+++ b/apps/web/lib/loom-concierge.ts
@@ -0,0 +1,258 @@
+import "server-only";
+
+import { randomUUID } from "node:crypto";
+import { db } from "@cap/database";
+import { getCurrentUser } from "@cap/database/auth/session";
+import {
+ loomMigrationRequests,
+ organizations,
+ users,
+} from "@cap/database/schema";
+import { buildEnv } from "@cap/env";
+import type { Organisation, User } from "@cap/web-domain";
+import {
+ and,
+ asc,
+ desc,
+ eq,
+ gt,
+ isNotNull,
+ isNull,
+ lte,
+ ne,
+ or,
+ sql,
+} from "drizzle-orm";
+import { requireOrganizationSettingsManager } from "@/actions/organization/authorization";
+import { MESSENGER_ADMIN_EMAIL } from "@/lib/messenger/constants";
+import { isOrganizationOwnerPro } from "@/lib/org-pro";
+import {
+ LoomMigrationError,
+ type LoomMigrationResult,
+ type LoomMigrationStatus,
+} from "./loom-migration-state";
+
+type MigrationRecord = typeof loomMigrationRequests.$inferSelect;
+const IMPORT_LEASE_MS = 60 * 60 * 1000;
+
+export type LoomMigrationView = {
+ id: string;
+ organizationId: Organisation.OrganisationId;
+ status: LoomMigrationStatus;
+ workspaceName: string | null;
+ customerNote: string | null;
+ customerReply: string | null;
+ invitedAt: string | null;
+ capMessage: string | null;
+ expectedVideoCount: number | null;
+ importedVideoCount: number;
+ queuedVideoCount: number;
+ activeImportCount: number;
+ completedAt: string | null;
+ createdAt: string;
+ updatedAt: string;
+};
+
+export type OperatorLoomMigrationView = LoomMigrationView & {
+ organizationName: string;
+ requestedByEmail: string;
+};
+
+export async function toLoomMigrationResult(
+ run: () => Promise,
+): Promise> {
+ try {
+ return { ok: true, value: await run() };
+ } catch (error) {
+ if (error instanceof LoomMigrationError) {
+ return { ok: false, error: error.message };
+ }
+ throw error;
+ }
+}
+
+export function migrationToView(record: MigrationRecord): LoomMigrationView {
+ return {
+ id: record.id,
+ organizationId: record.organizationId,
+ status: record.status,
+ workspaceName: record.workspaceName,
+ customerNote: record.customerNote,
+ customerReply: record.customerReply,
+ invitedAt: record.invitedAt?.toISOString() ?? null,
+ capMessage: record.capMessage,
+ expectedVideoCount: record.expectedVideoCount,
+ importedVideoCount: record.importedVideoCount,
+ queuedVideoCount: record.queuedVideoCount,
+ activeImportCount:
+ record.activeImportLeaseUntil &&
+ record.activeImportLeaseUntil.getTime() <= Date.now()
+ ? 0
+ : record.activeImportCount,
+ completedAt: record.completedAt?.toISOString() ?? null,
+ createdAt: record.createdAt.toISOString(),
+ updatedAt: record.updatedAt.toISOString(),
+ };
+}
+
+export async function requireCustomerMigrationAccess(
+ organizationId: Organisation.OrganisationId,
+) {
+ const user = await getCurrentUser();
+ if (!user) throw new LoomMigrationError("Unauthorized");
+ await requireOrganizationSettingsManager(user.id, organizationId);
+ return user;
+}
+
+export async function requireProCustomerMigrationAccess(
+ organizationId: Organisation.OrganisationId,
+) {
+ const user = await requireCustomerMigrationAccess(organizationId);
+ if (!buildEnv.NEXT_PUBLIC_IS_CAP) {
+ throw new LoomMigrationError(
+ "Concierge migration is available on Cap Cloud.",
+ );
+ }
+ if (!(await isOrganizationOwnerPro(organizationId))) {
+ throw new LoomMigrationError("Concierge Loom migration requires Cap Pro.");
+ }
+ return user;
+}
+
+export async function requireMigrationOperator() {
+ const user = await getCurrentUser();
+ if (
+ !buildEnv.NEXT_PUBLIC_IS_CAP ||
+ !user ||
+ user.email.toLowerCase() !== MESSENGER_ADMIN_EMAIL
+ ) {
+ throw new LoomMigrationError("Unauthorized");
+ }
+ return user;
+}
+
+function affectedRows(result: unknown) {
+ if (Array.isArray(result)) {
+ return (
+ (result[0] as { affectedRows?: number } | undefined)?.affectedRows ?? 0
+ );
+ }
+ return (result as { affectedRows?: number } | undefined)?.affectedRows ?? 0;
+}
+
+export async function reserveConciergeImport(
+ requestId: string,
+ operatorId: User.UserId,
+) {
+ const token = randomUUID();
+ const now = new Date();
+ const result = await db()
+ .update(loomMigrationRequests)
+ .set({
+ activeImportCount: 1,
+ activeImportLeaseToken: token,
+ activeImportLeaseUntil: new Date(now.getTime() + IMPORT_LEASE_MS),
+ status: sql`CASE WHEN ${loomMigrationRequests.status} = 'pending' THEN 'in_progress' ELSE ${loomMigrationRequests.status} END`,
+ lastOperatorUserId: operatorId,
+ lastOperatorAt: now,
+ updatedAt: now,
+ })
+ .where(
+ and(
+ eq(loomMigrationRequests.id, requestId),
+ isNotNull(loomMigrationRequests.activeOrganizationId),
+ or(
+ eq(loomMigrationRequests.activeImportCount, 0),
+ lte(loomMigrationRequests.activeImportLeaseUntil, now),
+ ),
+ ),
+ );
+ if (affectedRows(result) === 0) {
+ throw new LoomMigrationError(
+ "The migration request changed or another import is starting. Refresh and try again.",
+ );
+ }
+ return token;
+}
+
+export async function releaseConciergeImport(requestId: string, token: string) {
+ await db()
+ .update(loomMigrationRequests)
+ .set({
+ activeImportCount: 0,
+ activeImportLeaseToken: null,
+ activeImportLeaseUntil: null,
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(loomMigrationRequests.id, requestId),
+ eq(loomMigrationRequests.activeImportLeaseToken, token),
+ ),
+ );
+}
+
+export async function renewConciergeImport(requestId: string, token: string) {
+ const now = new Date();
+ const deadline = new Date(now.getTime() + IMPORT_LEASE_MS);
+ const result = await db()
+ .update(loomMigrationRequests)
+ .set({
+ activeImportLeaseUntil: sql`DATE_ADD(GREATEST(${loomMigrationRequests.activeImportLeaseUntil}, ${deadline}), INTERVAL 1 SECOND)`,
+ updatedAt: now,
+ })
+ .where(
+ and(
+ eq(loomMigrationRequests.id, requestId),
+ eq(loomMigrationRequests.activeImportLeaseToken, token),
+ gt(loomMigrationRequests.activeImportLeaseUntil, now),
+ isNotNull(loomMigrationRequests.activeOrganizationId),
+ ),
+ );
+ if (affectedRows(result) === 0) {
+ throw new LoomMigrationError(
+ "The concierge import expired. Refresh and retry.",
+ );
+ }
+}
+
+export async function getCustomerMigrationRequests(
+ organizationId: Organisation.OrganisationId,
+) {
+ await requireCustomerMigrationAccess(organizationId);
+ const requests = await db()
+ .select()
+ .from(loomMigrationRequests)
+ .where(eq(loomMigrationRequests.organizationId, organizationId))
+ .orderBy(desc(loomMigrationRequests.createdAt))
+ .limit(20);
+ return requests.map(migrationToView);
+}
+
+export async function getOperatorMigrationQueue() {
+ await requireMigrationOperator();
+ const queue = await db()
+ .select({
+ request: loomMigrationRequests,
+ organizationName: organizations.name,
+ requestedByEmail: users.email,
+ })
+ .from(loomMigrationRequests)
+ .innerJoin(
+ organizations,
+ eq(organizations.id, loomMigrationRequests.organizationId),
+ )
+ .innerJoin(users, eq(users.id, loomMigrationRequests.requestedByUserId))
+ .where(
+ and(
+ ne(loomMigrationRequests.status, "completed"),
+ isNull(organizations.tombstoneAt),
+ ),
+ )
+ .orderBy(asc(loomMigrationRequests.createdAt));
+ return queue.map((item) => ({
+ ...migrationToView(item.request),
+ organizationName: item.organizationName,
+ requestedByEmail: item.requestedByEmail,
+ }));
+}
diff --git a/apps/web/lib/loom-csv.ts b/apps/web/lib/loom-csv.ts
new file mode 100644
index 0000000000..3aaea1f196
--- /dev/null
+++ b/apps/web/lib/loom-csv.ts
@@ -0,0 +1,73 @@
+import type { LoomCsvImportRow } from "@/actions/loom";
+
+export function parseLoomCsvRecords(text: string) {
+ const records: string[][] = [];
+ let field = "";
+ let row: string[] = [];
+ let inQuotes = false;
+ const input = text.replace(/^\uFEFF/, "");
+ const quote = "\u0022";
+
+ for (let index = 0; index < input.length; index += 1) {
+ const char = input.charAt(index);
+ const next = input.charAt(index + 1);
+
+ if (char === quote) {
+ if (inQuotes && next === quote) {
+ field += quote;
+ index += 1;
+ } else {
+ inQuotes = !inQuotes;
+ }
+ continue;
+ }
+
+ if (char === "," && !inQuotes) {
+ row.push(field.trim());
+ field = "";
+ continue;
+ }
+
+ if ((char === "\n" || char === "\r") && !inQuotes) {
+ if (char === "\r" && next === "\n") index += 1;
+ row.push(field.trim());
+ if (row.some((cell) => cell.length > 0)) records.push(row);
+ row = [];
+ field = "";
+ continue;
+ }
+
+ field += char;
+ }
+
+ if (inQuotes) throw new Error("CSV has an unclosed quoted field.");
+
+ if (field.length > 0 || row.length > 0) {
+ row.push(field.trim());
+ if (row.some((cell) => cell.length > 0)) records.push(row);
+ }
+
+ return records;
+}
+
+export function parseConciergeLoomCsv(text: string): LoomCsvImportRow[] {
+ const records = parseLoomCsvRecords(text);
+ const headers = records[0]?.map((value) => value.toLowerCase()) ?? [];
+ const loomUrlIndex = headers.indexOf("loom_video_url");
+ const userEmailIndex = headers.indexOf("user_email");
+ const spaceNameIndex = headers.indexOf("space_name");
+ if (loomUrlIndex < 0 || userEmailIndex < 0) {
+ throw new Error("CSV needs loom_video_url and user_email columns.");
+ }
+ const rows = records.slice(1).map((values, index) => ({
+ rowNumber: index + 2,
+ loomUrl: values[loomUrlIndex] ?? "",
+ userEmail: values[userEmailIndex] ?? "",
+ spaceName: spaceNameIndex < 0 ? undefined : values[spaceNameIndex],
+ }));
+ if (rows.length === 0) throw new Error("CSV has no video rows.");
+ if (rows.length > 500) {
+ throw new Error("Split the library into CSV files of up to 500 videos.");
+ }
+ return rows;
+}
diff --git a/apps/web/lib/loom-migration-state.ts b/apps/web/lib/loom-migration-state.ts
new file mode 100644
index 0000000000..79ab2a6c47
--- /dev/null
+++ b/apps/web/lib/loom-migration-state.ts
@@ -0,0 +1,104 @@
+export const LOOM_MIGRATION_STATUSES = [
+ "pending",
+ "in_progress",
+ "needs_information",
+ "completed",
+] as const;
+
+export type LoomMigrationStatus = (typeof LOOM_MIGRATION_STATUSES)[number];
+
+export class LoomMigrationError extends Error {
+ override name = "LoomMigrationError";
+}
+
+export type LoomMigrationResult =
+ | { ok: true; value: T }
+ | { ok: false; error: string };
+
+export function unwrapLoomMigrationResult(result: LoomMigrationResult) {
+ if (!result.ok) throw new Error(result.error);
+ return result.value;
+}
+
+export const LOOM_MIGRATION_STATUS_LABELS: Record =
+ {
+ pending: "Pending",
+ in_progress: "In progress",
+ needs_information: "Information needed",
+ completed: "Migration complete",
+ };
+
+export function normalizeMigrationText(
+ value: string,
+ label: string,
+ maxLength: number,
+) {
+ if (typeof value !== "string")
+ throw new LoomMigrationError(`${label} is invalid.`);
+ const normalized = value.trim().replace(/\s+/g, " ");
+ if (normalized.length > maxLength) {
+ throw new LoomMigrationError(
+ `${label} must be ${maxLength} characters or fewer.`,
+ );
+ }
+ return normalized;
+}
+
+export function validateOperatorMigrationUpdate({
+ currentStatus,
+ nextStatus,
+ message,
+ verified,
+ expectedVideoCount,
+ importedVideoCount,
+}: {
+ currentStatus: LoomMigrationStatus;
+ nextStatus: LoomMigrationStatus;
+ message: string;
+ verified: boolean;
+ expectedVideoCount: number | null;
+ importedVideoCount: number;
+}) {
+ if (currentStatus === "completed") {
+ throw new LoomMigrationError("A completed migration cannot be changed.");
+ }
+ if (!LOOM_MIGRATION_STATUSES.includes(nextStatus)) {
+ throw new LoomMigrationError("Invalid migration status.");
+ }
+ const normalizedMessage = normalizeMigrationText(message, "Message", 2000);
+ if (nextStatus === "needs_information" && !normalizedMessage) {
+ throw new LoomMigrationError(
+ "Tell the customer what information you need.",
+ );
+ }
+ if (nextStatus === "completed" && !verified) {
+ throw new LoomMigrationError(
+ "Verify the migrated library before marking it complete.",
+ );
+ }
+ if (nextStatus === "completed" && expectedVideoCount === null) {
+ throw new LoomMigrationError(
+ "Enter the agreed source video count before completion.",
+ );
+ }
+ if (
+ !Number.isInteger(importedVideoCount) ||
+ importedVideoCount < 0 ||
+ (expectedVideoCount !== null &&
+ (!Number.isInteger(expectedVideoCount) ||
+ expectedVideoCount < 0 ||
+ importedVideoCount > expectedVideoCount))
+ ) {
+ throw new LoomMigrationError("Enter valid video counts.");
+ }
+ if (
+ nextStatus === "completed" &&
+ expectedVideoCount !== null &&
+ importedVideoCount !== expectedVideoCount
+ ) {
+ throw new LoomMigrationError(
+ "Reconcile the expected video count before completion.",
+ );
+ }
+ return normalizedMessage;
+}
diff --git a/packages/database/emails/loom-migration.tsx b/packages/database/emails/loom-migration.tsx
new file mode 100644
index 0000000000..c21ad295b1
--- /dev/null
+++ b/packages/database/emails/loom-migration.tsx
@@ -0,0 +1,70 @@
+import {
+ Body,
+ Container,
+ Head,
+ Heading,
+ Html,
+ Link,
+ Preview,
+ Text,
+} from "@react-email/components";
+
+export function LoomMigrationRequestEmail({
+ organizationName,
+ requesterEmail,
+ workspaceName,
+ queueUrl,
+}: {
+ organizationName: string;
+ requesterEmail: string;
+ workspaceName: string | null;
+ queueUrl: string;
+}) {
+ return (
+
+
+ New Loom migration request from {organizationName}
+
+
+ New Loom migration request
+
+ {organizationName} requested a Cap Pro concierge migration.
+
+ Requested by: {requesterEmail}
+ {workspaceName && Loom workspace: {workspaceName} }
+ Open the migration queue
+
+
+
+ );
+}
+
+export function LoomMigrationStatusEmail({
+ organizationName,
+ statusLabel,
+ message,
+ dashboardUrl,
+}: {
+ organizationName: string;
+ statusLabel: string;
+ message: string | null;
+ dashboardUrl: string;
+}) {
+ return (
+
+
+ {`Loom migration: ${statusLabel}`}
+
+
+ Loom migration update
+
+ There is an update on the Loom migration for {organizationName}.
+
+ {`Status: ${statusLabel}`}
+ {message && {message} }
+ View your migration
+
+
+
+ );
+}
diff --git a/packages/database/migrations/0050_loom_concierge.sql b/packages/database/migrations/0050_loom_concierge.sql
new file mode 100644
index 0000000000..301b8c23b9
--- /dev/null
+++ b/packages/database/migrations/0050_loom_concierge.sql
@@ -0,0 +1,36 @@
+CREATE TABLE `loom_migration_imports` (
+ `videoId` varchar(15) NOT NULL,
+ `requestId` varchar(15) NOT NULL,
+ `createdAt` datetime NOT NULL,
+ CONSTRAINT `loom_migration_imports_videoId` PRIMARY KEY(`videoId`)
+);
+--> statement-breakpoint
+CREATE TABLE `loom_migration_requests` (
+ `id` varchar(15) NOT NULL,
+ `organizationId` varchar(15) NOT NULL,
+ `activeOrganizationId` varchar(15),
+ `requestedByUserId` varchar(15) NOT NULL,
+ `workspaceName` varchar(255),
+ `customerNote` text,
+ `customerReply` text,
+ `invitedAt` datetime,
+ `status` varchar(32) NOT NULL DEFAULT 'pending',
+ `capMessage` text,
+ `expectedVideoCount` int,
+ `importedVideoCount` int NOT NULL DEFAULT 0,
+ `queuedVideoCount` int NOT NULL DEFAULT 0,
+ `activeImportCount` int NOT NULL DEFAULT 0,
+ `activeImportLeaseToken` varchar(36),
+ `activeImportLeaseUntil` datetime,
+ `lastOperatorUserId` varchar(15),
+ `lastOperatorAt` datetime,
+ `completedAt` datetime,
+ `createdAt` datetime NOT NULL,
+ `updatedAt` datetime NOT NULL,
+ CONSTRAINT `loom_migration_requests_id` PRIMARY KEY(`id`),
+ CONSTRAINT `loom_migration_active_org_idx` UNIQUE(`activeOrganizationId`)
+);
+--> statement-breakpoint
+CREATE INDEX `loom_migration_imports_request_idx` ON `loom_migration_imports` (`requestId`);--> statement-breakpoint
+CREATE INDEX `loom_migration_org_created_idx` ON `loom_migration_requests` (`organizationId`,`createdAt`);--> statement-breakpoint
+CREATE INDEX `loom_migration_status_created_idx` ON `loom_migration_requests` (`status`,`createdAt`);
\ No newline at end of file
diff --git a/packages/database/migrations/meta/0050_snapshot.json b/packages/database/migrations/meta/0050_snapshot.json
new file mode 100644
index 0000000000..0530480929
--- /dev/null
+++ b/packages/database/migrations/meta/0050_snapshot.json
@@ -0,0 +1,5213 @@
+{
+ "version": "5",
+ "dialect": "mysql",
+ "id": "d513a71b-36f6-4513-8ebf-071a00a4e709",
+ "prevId": "7db9e8b2-ba9e-416d-a243-4b79b5d4ec5d",
+ "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": {}
+ },
+ "agent_api_authorization_codes": {
+ "name": "agent_api_authorization_codes",
+ "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
+ },
+ "codeHash": {
+ "name": "codeHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "codeChallenge": {
+ "name": "codeChallenge",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "redirectUri": {
+ "name": "redirectUri",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "consumedAt": {
+ "name": "consumedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "code_hash_idx": {
+ "name": "code_hash_idx",
+ "columns": ["codeHash"],
+ "isUnique": true
+ },
+ "expires_at_idx": {
+ "name": "expires_at_idx",
+ "columns": ["expiresAt"],
+ "isUnique": false
+ },
+ "user_created_at_idx": {
+ "name": "user_created_at_idx",
+ "columns": ["userId", "createdAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "agent_api_authorization_codes_id": {
+ "name": "agent_api_authorization_codes_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "agent_api_idempotency": {
+ "name": "agent_api_idempotency",
+ "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
+ },
+ "operation": {
+ "name": "operation",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyHash": {
+ "name": "keyHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "requestHash": {
+ "name": "requestHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "state": {
+ "name": "state",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "statusCode": {
+ "name": "statusCode",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "response": {
+ "name": "response",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "user_operation_key_idx": {
+ "name": "user_operation_key_idx",
+ "columns": ["userId", "operation", "keyHash"],
+ "isUnique": true
+ },
+ "expires_at_idx": {
+ "name": "expires_at_idx",
+ "columns": ["expiresAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "agent_api_idempotency_id": {
+ "name": "agent_api_idempotency_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "agent_api_keys": {
+ "name": "agent_api_keys",
+ "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
+ },
+ "tokenHash": {
+ "name": "tokenHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'Cap CLI'"
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "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())"
+ },
+ "lastUsedAt": {
+ "name": "lastUsedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "token_hash_idx": {
+ "name": "token_hash_idx",
+ "columns": ["tokenHash"],
+ "isUnique": true
+ },
+ "user_created_at_idx": {
+ "name": "user_created_at_idx",
+ "columns": ["userId", "createdAt"],
+ "isUnique": false
+ },
+ "expires_at_idx": {
+ "name": "expires_at_idx",
+ "columns": ["expiresAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "agent_api_keys_id": {
+ "name": "agent_api_keys_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "agent_api_operations": {
+ "name": "agent_api_operations",
+ "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
+ },
+ "kind": {
+ "name": "kind",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "resourceId": {
+ "name": "resourceId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "resultResourceId": {
+ "name": "resultResourceId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "state": {
+ "name": "state",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'queued'"
+ },
+ "payload": {
+ "name": "payload",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "result": {
+ "name": "result",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "errorCode": {
+ "name": "errorCode",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "errorMessage": {
+ "name": "errorMessage",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "completedAt": {
+ "name": "completedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "user_created_at_idx": {
+ "name": "user_created_at_idx",
+ "columns": ["userId", "createdAt"],
+ "isUnique": false
+ },
+ "state_updated_at_idx": {
+ "name": "state_updated_at_idx",
+ "columns": ["state", "updatedAt"],
+ "isUnique": false
+ },
+ "resource_id_idx": {
+ "name": "resource_id_idx",
+ "columns": ["resourceId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "agent_api_operations_id": {
+ "name": "agent_api_operations_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
+ },
+ "source": {
+ "name": "source",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unknown'"
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "user_id_created_at_idx": {
+ "name": "user_id_created_at_idx",
+ "columns": ["userId", "createdAt"],
+ "isUnique": false
+ }
+ },
+ "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
+ },
+ "mediaKey": {
+ "name": "mediaKey",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mediaDuration": {
+ "name": "mediaDuration",
+ "type": "float",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mediaMeta": {
+ "name": "mediaMeta",
+ "type": "json",
+ "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'"
+ },
+ "public": {
+ "name": "public",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "settings": {
+ "name": "settings",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "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
+ },
+ "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
+ },
+ "public_parent_id_idx": {
+ "name": "public_parent_id_idx",
+ "columns": ["public", "parentId"],
+ "isUnique": false
+ },
+ "public_space_parent_id_idx": {
+ "name": "public_space_parent_id_idx",
+ "columns": ["public", "spaceId", "parentId"],
+ "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": {
+ "id_idx": {
+ "name": "id_idx",
+ "columns": ["id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "imported_videos_orgId_source_source_id_pk": {
+ "name": "imported_videos_orgId_source_source_id_pk",
+ "columns": ["orgId", "source", "source_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "integration_installations": {
+ "name": "integration_installations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "externalId": {
+ "name": "externalId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "displayName": {
+ "name": "displayName",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "installedByUserId": {
+ "name": "installedByUserId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "encryptedCredentials": {
+ "name": "encryptedCredentials",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "provider_external_id_idx": {
+ "name": "provider_external_id_idx",
+ "columns": ["provider", "externalId"],
+ "isUnique": true
+ },
+ "organization_provider_display_name_idx": {
+ "name": "organization_provider_display_name_idx",
+ "columns": ["organizationId", "provider", "displayName"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "integration_installations_id": {
+ "name": "integration_installations_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "loom_migration_imports": {
+ "name": "loom_migration_imports",
+ "columns": {
+ "videoId": {
+ "name": "videoId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "requestId": {
+ "name": "requestId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "loom_migration_imports_request_idx": {
+ "name": "loom_migration_imports_request_idx",
+ "columns": ["requestId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "loom_migration_imports_videoId": {
+ "name": "loom_migration_imports_videoId",
+ "columns": ["videoId"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "loom_migration_requests": {
+ "name": "loom_migration_requests",
+ "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
+ },
+ "activeOrganizationId": {
+ "name": "activeOrganizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "requestedByUserId": {
+ "name": "requestedByUserId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "workspaceName": {
+ "name": "workspaceName",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "customerNote": {
+ "name": "customerNote",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "customerReply": {
+ "name": "customerReply",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "invitedAt": {
+ "name": "invitedAt",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "capMessage": {
+ "name": "capMessage",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expectedVideoCount": {
+ "name": "expectedVideoCount",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "importedVideoCount": {
+ "name": "importedVideoCount",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "queuedVideoCount": {
+ "name": "queuedVideoCount",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "activeImportCount": {
+ "name": "activeImportCount",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "activeImportLeaseToken": {
+ "name": "activeImportLeaseToken",
+ "type": "varchar(36)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "activeImportLeaseUntil": {
+ "name": "activeImportLeaseUntil",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lastOperatorUserId": {
+ "name": "lastOperatorUserId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lastOperatorAt": {
+ "name": "lastOperatorAt",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "completedAt": {
+ "name": "completedAt",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "loom_migration_active_org_idx": {
+ "name": "loom_migration_active_org_idx",
+ "columns": ["activeOrganizationId"],
+ "isUnique": true
+ },
+ "loom_migration_org_created_idx": {
+ "name": "loom_migration_org_created_idx",
+ "columns": ["organizationId", "createdAt"],
+ "isUnique": false
+ },
+ "loom_migration_status_created_idx": {
+ "name": "loom_migration_status_created_idx",
+ "columns": ["status", "createdAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "loom_migration_requests_id": {
+ "name": "loom_migration_requests_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "loops_sync_jobs": {
+ "name": "loops_sync_jobs",
+ "columns": {
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "revision": {
+ "name": "revision",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "nextAttemptAt": {
+ "name": "nextAttemptAt",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "leaseToken": {
+ "name": "leaseToken",
+ "type": "varchar(36)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "leaseUntil": {
+ "name": "leaseUntil",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "failures": {
+ "name": "failures",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "lastError": {
+ "name": "lastError",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "profileHash": {
+ "name": "profileHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "teammateJoinedAt": {
+ "name": "teammateJoinedAt",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "syncedEmail": {
+ "name": "syncedEmail",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lastSyncedAt": {
+ "name": "lastSyncedAt",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "loops_sync_due_idx": {
+ "name": "loops_sync_due_idx",
+ "columns": ["nextAttemptAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "loops_sync_jobs_userId": {
+ "name": "loops_sync_jobs_userId",
+ "columns": ["userId"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "mcp_oauth_clients": {
+ "name": "mcp_oauth_clients",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "clientId": {
+ "name": "clientId",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "clientName": {
+ "name": "clientName",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "redirectUris": {
+ "name": "redirectUris",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "activatedAt": {
+ "name": "activatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "client_id_idx": {
+ "name": "client_id_idx",
+ "columns": ["clientId"],
+ "isUnique": true
+ },
+ "inactive_created_at_idx": {
+ "name": "inactive_created_at_idx",
+ "columns": ["activatedAt", "createdAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "mcp_oauth_clients_id": {
+ "name": "mcp_oauth_clients_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "mcp_oauth_codes": {
+ "name": "mcp_oauth_codes",
+ "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
+ },
+ "clientId": {
+ "name": "clientId",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "codeHash": {
+ "name": "codeHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "codeChallenge": {
+ "name": "codeChallenge",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "redirectUri": {
+ "name": "redirectUri",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "resource": {
+ "name": "resource",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "consumedAt": {
+ "name": "consumedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "code_hash_idx": {
+ "name": "code_hash_idx",
+ "columns": ["codeHash"],
+ "isUnique": true
+ },
+ "expires_at_idx": {
+ "name": "expires_at_idx",
+ "columns": ["expiresAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "mcp_oauth_codes_id": {
+ "name": "mcp_oauth_codes_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "mcp_oauth_registration_quotas": {
+ "name": "mcp_oauth_registration_quotas",
+ "columns": {
+ "windowId": {
+ "name": "windowId",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "registrations": {
+ "name": "registrations",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "expires_at_idx": {
+ "name": "expires_at_idx",
+ "columns": ["expiresAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "mcp_oauth_registration_quotas_windowId": {
+ "name": "mcp_oauth_registration_quotas_windowId",
+ "columns": ["windowId"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "mcp_oauth_tokens": {
+ "name": "mcp_oauth_tokens",
+ "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
+ },
+ "clientId": {
+ "name": "clientId",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "familyId": {
+ "name": "familyId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "resource": {
+ "name": "resource",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "accessHash": {
+ "name": "accessHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refreshHash": {
+ "name": "refreshHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "accessExpiresAt": {
+ "name": "accessExpiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refreshExpiresAt": {
+ "name": "refreshExpiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "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": {
+ "access_hash_idx": {
+ "name": "access_hash_idx",
+ "columns": ["accessHash"],
+ "isUnique": true
+ },
+ "refresh_hash_idx": {
+ "name": "refresh_hash_idx",
+ "columns": ["refreshHash"],
+ "isUnique": true
+ },
+ "family_id_idx": {
+ "name": "family_id_idx",
+ "columns": ["familyId"],
+ "isUnique": false
+ },
+ "client_active_idx": {
+ "name": "client_active_idx",
+ "columns": ["clientId", "revokedAt", "refreshExpiresAt"],
+ "isUnique": false
+ },
+ "refresh_expires_at_idx": {
+ "name": "refresh_expires_at_idx",
+ "columns": ["refreshExpiresAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "mcp_oauth_tokens_id": {
+ "name": "mcp_oauth_tokens_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "media_processing_budgets": {
+ "name": "media_processing_budgets",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reserved_bytes": {
+ "name": "reserved_bytes",
+ "type": "bigint unsigned",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "limit_bytes": {
+ "name": "limit_bytes",
+ "type": "bigint unsigned",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "media_budget_expiry_idx": {
+ "name": "media_budget_expiry_idx",
+ "columns": ["expires_at"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "media_processing_budgets_id": {
+ "name": "media_processing_budgets_id",
+ "columns": ["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": {}
+ },
+ "messenger_support_emails": {
+ "name": "messenger_support_emails",
+ "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
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userEmail": {
+ "name": "userEmail",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject": {
+ "name": "subject",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "support_email_user_created_at_idx": {
+ "name": "support_email_user_created_at_idx",
+ "columns": ["userId", "createdAt"],
+ "isUnique": false
+ },
+ "support_email_conversation_created_at_idx": {
+ "name": "support_email_conversation_created_at_idx",
+ "columns": ["conversationId", "createdAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "support_email_conversation_fk": {
+ "name": "support_email_conversation_fk",
+ "tableFrom": "messenger_support_emails",
+ "tableTo": "messenger_conversations",
+ "columnsFrom": ["conversationId"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "messenger_support_emails_id": {
+ "name": "messenger_support_emails_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "notifications": {
+ "name": "notifications",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "orgId": {
+ "name": "orgId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipientId": {
+ "name": "recipientId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "data": {
+ "name": "data",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "videoId": {
+ "name": "videoId",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dedupKey": {
+ "name": "dedupKey",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "readAt": {
+ "name": "readAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "org_id_idx": {
+ "name": "org_id_idx",
+ "columns": ["orgId"],
+ "isUnique": false
+ },
+ "type_idx": {
+ "name": "type_idx",
+ "columns": ["type"],
+ "isUnique": false
+ },
+ "read_at_idx": {
+ "name": "read_at_idx",
+ "columns": ["readAt"],
+ "isUnique": false
+ },
+ "created_at_idx": {
+ "name": "created_at_idx",
+ "columns": ["createdAt"],
+ "isUnique": false
+ },
+ "recipient_read_idx": {
+ "name": "recipient_read_idx",
+ "columns": ["recipientId", "readAt"],
+ "isUnique": false
+ },
+ "recipient_created_idx": {
+ "name": "recipient_created_idx",
+ "columns": ["recipientId", "createdAt"],
+ "isUnique": false
+ },
+ "dedup_key_idx": {
+ "name": "dedup_key_idx",
+ "columns": ["dedupKey"],
+ "isUnique": true
+ },
+ "type_recipient_created_idx": {
+ "name": "type_recipient_created_idx",
+ "columns": ["type", "recipientId", "createdAt"],
+ "isUnique": false
+ },
+ "type_recipient_video_created_idx": {
+ "name": "type_recipient_video_created_idx",
+ "columns": ["type", "recipientId", "videoId", "createdAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "notifications_id": {
+ "name": "notifications_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "organization_invites": {
+ "name": "organization_invites",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "invitedEmail": {
+ "name": "invitedEmail",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "invitedByUserId": {
+ "name": "invitedByUserId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "organization_id_idx": {
+ "name": "organization_id_idx",
+ "columns": ["organizationId"],
+ "isUnique": false
+ },
+ "invited_email_idx": {
+ "name": "invited_email_idx",
+ "columns": ["invitedEmail"],
+ "isUnique": false
+ },
+ "invited_by_user_id_idx": {
+ "name": "invited_by_user_id_idx",
+ "columns": ["invitedByUserId"],
+ "isUnique": false
+ },
+ "status_idx": {
+ "name": "status_idx",
+ "columns": ["status"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "organization_invites_id": {
+ "name": "organization_invites_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "organization_members": {
+ "name": "organization_members",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "hasProSeat": {
+ "name": "hasProSeat",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "organization_id_idx": {
+ "name": "organization_id_idx",
+ "columns": ["organizationId"],
+ "isUnique": false
+ },
+ "user_id_organization_id_idx": {
+ "name": "user_id_organization_id_idx",
+ "columns": ["userId", "organizationId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "organization_members_id": {
+ "name": "organization_members_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "organization_sso": {
+ "name": "organization_sso",
+ "columns": {
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "purchasedByUserId": {
+ "name": "purchasedByUserId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "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
+ },
+ "stripePriceId": {
+ "name": "stripePriceId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unpaid'"
+ },
+ "paidThrough": {
+ "name": "paidThrough",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "currentPeriodEnd": {
+ "name": "currentPeriodEnd",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cancelAtPeriodEnd": {
+ "name": "cancelAtPeriodEnd",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "checkoutAttemptId": {
+ "name": "checkoutAttemptId",
+ "type": "varchar(36)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "checkoutCurrency": {
+ "name": "checkoutCurrency",
+ "type": "varchar(3)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "checkoutPriceId": {
+ "name": "checkoutPriceId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "checkoutSessionId": {
+ "name": "checkoutSessionId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "checkoutStartedAt": {
+ "name": "checkoutStartedAt",
+ "type": "datetime",
+ "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": {
+ "sso_stripe_subscription_id_idx": {
+ "name": "sso_stripe_subscription_id_idx",
+ "columns": ["stripeSubscriptionId"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "organization_sso_organizationId": {
+ "name": "organization_sso_organizationId",
+ "columns": ["organizationId"]
+ }
+ },
+ "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
+ },
+ "defaultVideoVisibility": {
+ "name": "defaultVideoVisibility",
+ "type": "varchar(7)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "customDomain": {
+ "name": "customDomain",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "domainVerified": {
+ "name": "domainVerified",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "settings": {
+ "name": "settings",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "iconUrl": {
+ "name": "iconUrl",
+ "type": "varchar(1024)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "shareableLinkIconUrl": {
+ "name": "shareableLinkIconUrl",
+ "type": "varchar(1024)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "workosOrganizationId": {
+ "name": "workosOrganizationId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "workosConnectionId": {
+ "name": "workosConnectionId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "owner_id_tombstone_idx": {
+ "name": "owner_id_tombstone_idx",
+ "columns": ["ownerId", "tombstoneAt"],
+ "isUnique": false
+ },
+ "custom_domain_idx": {
+ "name": "custom_domain_idx",
+ "columns": ["customDomain"],
+ "isUnique": false
+ },
+ "workos_organization_id_idx": {
+ "name": "workos_organization_id_idx",
+ "columns": ["workosOrganizationId"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "organizations_id": {
+ "name": "organizations_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "s3_buckets": {
+ "name": "s3_buckets",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ownerId": {
+ "name": "ownerId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "region": {
+ "name": "region",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "endpoint": {
+ "name": "endpoint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "bucketName": {
+ "name": "bucketName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "accessKeyId": {
+ "name": "accessKeyId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "secretAccessKey": {
+ "name": "secretAccessKey",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('aws')"
+ },
+ "active": {
+ "name": "active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "owner_organization_idx": {
+ "name": "owner_organization_idx",
+ "columns": ["ownerId", "organizationId"],
+ "isUnique": false
+ },
+ "organization_id_idx": {
+ "name": "organization_id_idx",
+ "columns": ["organizationId"],
+ "isUnique": false
+ },
+ "organization_active_idx": {
+ "name": "organization_active_idx",
+ "columns": ["organizationId", "active", "updatedAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "s3_buckets_id": {
+ "name": "s3_buckets_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "sessions": {
+ "name": "sessions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sessionToken": {
+ "name": "sessionToken",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "session_token_idx": {
+ "name": "session_token_idx",
+ "columns": ["sessionToken"],
+ "isUnique": true
+ },
+ "user_id_idx": {
+ "name": "user_id_idx",
+ "columns": ["userId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "sessions_id": {
+ "name": "sessions_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "shared_videos": {
+ "name": "shared_videos",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "videoId": {
+ "name": "videoId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "folderId": {
+ "name": "folderId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sharedByUserId": {
+ "name": "sharedByUserId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sharedAt": {
+ "name": "sharedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "folder_id_idx": {
+ "name": "folder_id_idx",
+ "columns": ["folderId"],
+ "isUnique": false
+ },
+ "organization_id_idx": {
+ "name": "organization_id_idx",
+ "columns": ["organizationId"],
+ "isUnique": false
+ },
+ "shared_by_user_id_idx": {
+ "name": "shared_by_user_id_idx",
+ "columns": ["sharedByUserId"],
+ "isUnique": false
+ },
+ "video_id_organization_id_idx": {
+ "name": "video_id_organization_id_idx",
+ "columns": ["videoId", "organizationId"],
+ "isUnique": false
+ },
+ "video_id_folder_id_idx": {
+ "name": "video_id_folder_id_idx",
+ "columns": ["videoId", "folderId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "shared_videos_id": {
+ "name": "shared_videos_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "signed_baas": {
+ "name": "signed_baas",
+ "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
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "stripeSubscriptionId": {
+ "name": "stripeSubscriptionId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "entityName": {
+ "name": "entityName",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entityType": {
+ "name": "entityType",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entityAddress": {
+ "name": "entityAddress",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "signerName": {
+ "name": "signerName",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "signerTitle": {
+ "name": "signerTitle",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "noticesEmail": {
+ "name": "noticesEmail",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "signatureData": {
+ "name": "signatureData",
+ "type": "longtext",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "signedAt": {
+ "name": "signedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "emailSentAt": {
+ "name": "emailSentAt",
+ "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": {
+ "signed_baa_organization_id_idx": {
+ "name": "signed_baa_organization_id_idx",
+ "columns": ["organizationId"],
+ "isUnique": true
+ },
+ "signed_baa_stripe_subscription_idx": {
+ "name": "signed_baa_stripe_subscription_idx",
+ "columns": ["stripeSubscriptionId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "signed_baas_id": {
+ "name": "signed_baas_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "space_members": {
+ "name": "space_members",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "spaceId": {
+ "name": "spaceId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'member'"
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "user_id_idx": {
+ "name": "user_id_idx",
+ "columns": ["userId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "space_members_id": {
+ "name": "space_members_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {
+ "space_id_user_id_unique": {
+ "name": "space_id_user_id_unique",
+ "columns": ["spaceId", "userId"]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "space_videos": {
+ "name": "space_videos",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "spaceId": {
+ "name": "spaceId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "folderId": {
+ "name": "folderId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "videoId": {
+ "name": "videoId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "addedById": {
+ "name": "addedById",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "addedAt": {
+ "name": "addedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "folder_id_idx": {
+ "name": "folder_id_idx",
+ "columns": ["folderId"],
+ "isUnique": false
+ },
+ "video_id_idx": {
+ "name": "video_id_idx",
+ "columns": ["videoId"],
+ "isUnique": false
+ },
+ "added_by_id_idx": {
+ "name": "added_by_id_idx",
+ "columns": ["addedById"],
+ "isUnique": false
+ },
+ "space_id_video_id_idx": {
+ "name": "space_id_video_id_idx",
+ "columns": ["spaceId", "videoId"],
+ "isUnique": false
+ },
+ "space_id_folder_id_idx": {
+ "name": "space_id_folder_id_idx",
+ "columns": ["spaceId", "folderId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "space_videos_id": {
+ "name": "space_videos_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "spaces": {
+ "name": "spaces",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "primary": {
+ "name": "primary",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdById": {
+ "name": "createdById",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "iconUrl": {
+ "name": "iconUrl",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "varchar(1000)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "settings": {
+ "name": "settings",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "privacy": {
+ "name": "privacy",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'Private'"
+ },
+ "public": {
+ "name": "public",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "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
+ },
+ "public_organization_id_idx": {
+ "name": "public_organization_id_idx",
+ "columns": ["public", "organizationId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "spaces_id": {
+ "name": "spaces_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "storage_integrations": {
+ "name": "storage_integrations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ownerId": {
+ "name": "ownerId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "displayName": {
+ "name": "displayName",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'active'"
+ },
+ "active": {
+ "name": "active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "encryptedConfig": {
+ "name": "encryptedConfig",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "googleDriveAccessToken": {
+ "name": "googleDriveAccessToken",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "googleDriveAccessTokenExpiresAt": {
+ "name": "googleDriveAccessTokenExpiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "googleDriveTokenRefreshLeaseId": {
+ "name": "googleDriveTokenRefreshLeaseId",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "googleDriveTokenRefreshLeaseExpiresAt": {
+ "name": "googleDriveTokenRefreshLeaseExpiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "googleDriveStorageQuotaCache": {
+ "name": "googleDriveStorageQuotaCache",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "owner_provider_idx": {
+ "name": "owner_provider_idx",
+ "columns": ["ownerId", "provider"],
+ "isUnique": false
+ },
+ "owner_active_idx": {
+ "name": "owner_active_idx",
+ "columns": ["ownerId", "active"],
+ "isUnique": false
+ },
+ "organization_provider_idx": {
+ "name": "organization_provider_idx",
+ "columns": ["organizationId", "provider"],
+ "isUnique": false
+ },
+ "organization_active_idx": {
+ "name": "organization_active_idx",
+ "columns": ["organizationId", "active", "status"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "storage_integrations_id": {
+ "name": "storage_integrations_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "storage_objects": {
+ "name": "storage_objects",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "integrationId": {
+ "name": "integrationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ownerId": {
+ "name": "ownerId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "videoId": {
+ "name": "videoId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "objectKey": {
+ "name": "objectKey",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "objectKeyHash": {
+ "name": "objectKeyHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "providerObjectId": {
+ "name": "providerObjectId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploadSessionUrl": {
+ "name": "uploadSessionUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "uploadStatus": {
+ "name": "uploadStatus",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "contentType": {
+ "name": "contentType",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "contentLength": {
+ "name": "contentLength",
+ "type": "bigint unsigned",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "integration_key_hash_idx": {
+ "name": "integration_key_hash_idx",
+ "columns": ["integrationId", "objectKeyHash"],
+ "isUnique": true
+ },
+ "integration_status_idx": {
+ "name": "integration_status_idx",
+ "columns": ["integrationId", "uploadStatus"],
+ "isUnique": false
+ },
+ "integration_object_key_prefix_idx": {
+ "name": "integration_object_key_prefix_idx",
+ "columns": ["integrationId", "`objectKey`(191)"],
+ "isUnique": false
+ },
+ "video_id_idx": {
+ "name": "video_id_idx",
+ "columns": ["videoId"],
+ "isUnique": false
+ },
+ "owner_id_idx": {
+ "name": "owner_id_idx",
+ "columns": ["ownerId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "storage_objects_integrationId_storage_integrations_id_fk": {
+ "name": "storage_objects_integrationId_storage_integrations_id_fk",
+ "tableFrom": "storage_objects",
+ "tableTo": "storage_integrations",
+ "columnsFrom": ["integrationId"],
+ "columnsTo": ["id"],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "storage_objects_id": {
+ "name": "storage_objects_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lastName": {
+ "name": "lastName",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "emailVerified": {
+ "name": "emailVerified",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image": {
+ "name": "image",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "stripeCustomerId": {
+ "name": "stripeCustomerId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "stripeSubscriptionId": {
+ "name": "stripeSubscriptionId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "thirdPartyStripeSubscriptionId": {
+ "name": "thirdPartyStripeSubscriptionId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "stripeSubscriptionStatus": {
+ "name": "stripeSubscriptionStatus",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "stripeSubscriptionPriceId": {
+ "name": "stripeSubscriptionPriceId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "preferences": {
+ "name": "preferences",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "('null')"
+ },
+ "activeOrganizationId": {
+ "name": "activeOrganizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "onboardingSteps": {
+ "name": "onboardingSteps",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "onboarding_completed_at": {
+ "name": "onboarding_completed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "customBucket": {
+ "name": "customBucket",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inviteQuota": {
+ "name": "inviteQuota",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "defaultOrgId": {
+ "name": "defaultOrgId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "authSessionVersion": {
+ "name": "authSessionVersion",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "marketingOrigin": {
+ "name": "marketingOrigin",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unknown'"
+ }
+ },
+ "indexes": {
+ "email_idx": {
+ "name": "email_idx",
+ "columns": ["email"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "users_id": {
+ "name": "users_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "verification_tokens": {
+ "name": "verification_tokens",
+ "columns": {
+ "identifier": {
+ "name": "identifier",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "verification_tokens_identifier": {
+ "name": "verification_tokens_identifier",
+ "columns": ["identifier"]
+ }
+ },
+ "uniqueConstraints": {
+ "verification_tokens_token_unique": {
+ "name": "verification_tokens_token_unique",
+ "columns": ["token"]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "video_edits": {
+ "name": "video_edits",
+ "columns": {
+ "videoId": {
+ "name": "videoId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sourceKey": {
+ "name": "sourceKey",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "editSpec": {
+ "name": "editSpec",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "video_edits_videoId_videos_id_fk": {
+ "name": "video_edits_videoId_videos_id_fk",
+ "tableFrom": "video_edits",
+ "tableTo": "videos",
+ "columnsFrom": ["videoId"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "video_edits_videoId": {
+ "name": "video_edits_videoId",
+ "columns": ["videoId"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "video_processing_jobs": {
+ "name": "video_processing_jobs",
+ "columns": {
+ "video_id": {
+ "name": "video_id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "generation": {
+ "name": "generation",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "manifest_sha256": {
+ "name": "manifest_sha256",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "state": {
+ "name": "state",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'committing'"
+ },
+ "attempt_id": {
+ "name": "attempt_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "attempt_count": {
+ "name": "attempt_count",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "lease_expires_at": {
+ "name": "lease_expires_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "next_retry_at": {
+ "name": "next_retry_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "workflow_run_id": {
+ "name": "workflow_run_id",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "remote_job_id": {
+ "name": "remote_job_id",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "verification": {
+ "name": "verification",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "output": {
+ "name": "output",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error_code": {
+ "name": "error_code",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "processing_state_retry_video_idx": {
+ "name": "processing_state_retry_video_idx",
+ "columns": ["state", "next_retry_at", "video_id"],
+ "isUnique": false
+ },
+ "processing_state_lease_video_idx": {
+ "name": "processing_state_lease_video_idx",
+ "columns": ["state", "lease_expires_at", "video_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "video_processing_jobs_video_id": {
+ "name": "video_processing_jobs_video_id",
+ "columns": ["video_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "video_uploads": {
+ "name": "video_uploads",
+ "columns": {
+ "video_id": {
+ "name": "video_id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded": {
+ "name": "uploaded",
+ "type": "bigint unsigned",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "total": {
+ "name": "total",
+ "type": "bigint unsigned",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "mode": {
+ "name": "mode",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "phase": {
+ "name": "phase",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'uploading'"
+ },
+ "processing_progress": {
+ "name": "processing_progress",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "processing_message": {
+ "name": "processing_message",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "processing_error": {
+ "name": "processing_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "raw_file_key": {
+ "name": "raw_file_key",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "phase_updated_at_video_id_idx": {
+ "name": "phase_updated_at_video_id_idx",
+ "columns": ["phase", "updated_at", "video_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "video_uploads_video_id": {
+ "name": "video_uploads_video_id",
+ "columns": ["video_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "video_viewer_grants": {
+ "name": "video_viewer_grants",
+ "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
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "invitedByUserId": {
+ "name": "invitedByUserId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "revokedAt": {
+ "name": "revokedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "video_email_idx": {
+ "name": "video_email_idx",
+ "columns": ["videoId", "email"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "video_viewer_grants_videoId_videos_id_fk": {
+ "name": "video_viewer_grants_videoId_videos_id_fk",
+ "tableFrom": "video_viewer_grants",
+ "tableTo": "videos",
+ "columnsFrom": ["videoId"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "video_viewer_grants_id": {
+ "name": "video_viewer_grants_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "videos": {
+ "name": "videos",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ownerId": {
+ "name": "ownerId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "orgId": {
+ "name": "orgId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'My Video'"
+ },
+ "bucket": {
+ "name": "bucket",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "storageIntegrationId": {
+ "name": "storageIntegrationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "duration": {
+ "name": "duration",
+ "type": "float",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "width": {
+ "name": "width",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "height": {
+ "name": "height",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "fps": {
+ "name": "fps",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "public": {
+ "name": "public",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "settings": {
+ "name": "settings",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "transcriptionStatus": {
+ "name": "transcriptionStatus",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('{\"type\":\"MediaConvert\"}')"
+ },
+ "folderId": {
+ "name": "folderId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "effectiveCreatedAt": {
+ "name": "effectiveCreatedAt",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "generated": {
+ "as": "COALESCE(\n STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(`metadata`, '$.customCreatedAt')), '%Y-%m-%dT%H:%i:%s.%fZ'),\n STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(`metadata`, '$.customCreatedAt')), '%Y-%m-%dT%H:%i:%sZ'),\n `createdAt`\n )",
+ "type": "stored"
+ }
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "xStreamInfo": {
+ "name": "xStreamInfo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "firstViewEmailSentAt": {
+ "name": "firstViewEmailSentAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "isScreenshot": {
+ "name": "isScreenshot",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "awsRegion": {
+ "name": "awsRegion",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "awsBucket": {
+ "name": "awsBucket",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "videoStartTime": {
+ "name": "videoStartTime",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "audioStartTime": {
+ "name": "audioStartTime",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "jobId": {
+ "name": "jobId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "jobStatus": {
+ "name": "jobStatus",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "skipProcessing": {
+ "name": "skipProcessing",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "owner_id_idx": {
+ "name": "owner_id_idx",
+ "columns": ["ownerId"],
+ "isUnique": false
+ },
+ "owner_updated_id_idx": {
+ "name": "owner_updated_id_idx",
+ "columns": ["ownerId", "updatedAt", "id"],
+ "isUnique": false
+ },
+ "is_public_idx": {
+ "name": "is_public_idx",
+ "columns": ["public"],
+ "isUnique": false
+ },
+ "folder_id_idx": {
+ "name": "folder_id_idx",
+ "columns": ["folderId"],
+ "isUnique": false
+ },
+ "storage_integration_id_idx": {
+ "name": "storage_integration_id_idx",
+ "columns": ["storageIntegrationId"],
+ "isUnique": false
+ },
+ "org_owner_folder_idx": {
+ "name": "org_owner_folder_idx",
+ "columns": ["orgId", "ownerId", "folderId"],
+ "isUnique": false
+ },
+ "org_effective_created_idx": {
+ "name": "org_effective_created_idx",
+ "columns": ["orgId", "effectiveCreatedAt"],
+ "isUnique": false
+ },
+ "screenshot_transcription_created_idx": {
+ "name": "screenshot_transcription_created_idx",
+ "columns": ["isScreenshot", "transcriptionStatus", "createdAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "videos_storageIntegrationId_storage_integrations_id_fk": {
+ "name": "videos_storageIntegrationId_storage_integrations_id_fk",
+ "tableFrom": "videos",
+ "tableTo": "storage_integrations",
+ "columnsFrom": ["storageIntegrationId"],
+ "columnsTo": ["id"],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "videos_id": {
+ "name": "videos_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ }
+ },
+ "views": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "tables": {},
+ "indexes": {
+ "integration_object_key_prefix_idx": {
+ "columns": {
+ "`objectKey`(191)": {
+ "isExpression": true
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json
index d09298a78f..ded36cce8c 100644
--- a/packages/database/migrations/meta/_journal.json
+++ b/packages/database/migrations/meta/_journal.json
@@ -351,6 +351,13 @@
"when": 1790203121737,
"tag": "0049_jittery_professor_monster",
"breakpoints": true
+ },
+ {
+ "idx": 50,
+ "version": "5",
+ "when": 1792510929738,
+ "tag": "0050_loom_concierge",
+ "breakpoints": true
}
]
}
diff --git a/packages/database/schema.ts b/packages/database/schema.ts
index 8c19b1fd20..2235f558e9 100644
--- a/packages/database/schema.ts
+++ b/packages/database/schema.ts
@@ -1652,6 +1652,74 @@ export const importedVideos = mysqlTable(
],
);
+export const loomMigrationRequests = mysqlTable(
+ "loom_migration_requests",
+ {
+ id: nanoId("id").notNull().primaryKey(),
+ organizationId: nanoId("organizationId")
+ .notNull()
+ .$type(),
+ activeOrganizationId: nanoIdNullable(
+ "activeOrganizationId",
+ ).$type(),
+ requestedByUserId: nanoId("requestedByUserId")
+ .notNull()
+ .$type(),
+ workspaceName: varchar("workspaceName", { length: 255 }),
+ customerNote: text("customerNote"),
+ customerReply: text("customerReply"),
+ invitedAt: datetime("invitedAt", { mode: "date" }),
+ status: varchar("status", {
+ length: 32,
+ enum: ["pending", "in_progress", "needs_information", "completed"],
+ })
+ .notNull()
+ .default("pending"),
+ capMessage: text("capMessage"),
+ expectedVideoCount: int("expectedVideoCount"),
+ importedVideoCount: int("importedVideoCount").notNull().default(0),
+ queuedVideoCount: int("queuedVideoCount").notNull().default(0),
+ activeImportCount: int("activeImportCount").notNull().default(0),
+ activeImportLeaseToken: varchar("activeImportLeaseToken", { length: 36 }),
+ activeImportLeaseUntil: datetime("activeImportLeaseUntil", {
+ mode: "date",
+ }),
+ lastOperatorUserId:
+ nanoIdNullable("lastOperatorUserId").$type(),
+ lastOperatorAt: datetime("lastOperatorAt", { mode: "date" }),
+ completedAt: datetime("completedAt", { mode: "date" }),
+ createdAt: datetime("createdAt", { mode: "date" })
+ .notNull()
+ .$defaultFn(() => new Date()),
+ updatedAt: datetime("updatedAt", { mode: "date" })
+ .notNull()
+ .$defaultFn(() => new Date()),
+ },
+ (table) => [
+ uniqueIndex("loom_migration_active_org_idx").on(table.activeOrganizationId),
+ index("loom_migration_org_created_idx").on(
+ table.organizationId,
+ table.createdAt,
+ ),
+ index("loom_migration_status_created_idx").on(
+ table.status,
+ table.createdAt,
+ ),
+ ],
+);
+
+export const loomMigrationImports = mysqlTable(
+ "loom_migration_imports",
+ {
+ videoId: nanoId("videoId").notNull().primaryKey().$type(),
+ requestId: nanoId("requestId").notNull(),
+ createdAt: datetime("createdAt", { mode: "date" })
+ .notNull()
+ .$defaultFn(() => new Date()),
+ },
+ (table) => [index("loom_migration_imports_request_idx").on(table.requestId)],
+);
+
export const developerApps = mysqlTable(
"developer_apps",
{