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. +

+ + { + 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. +

+ )} + + {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."} +
  • + ))} +
+ )} +
+ +
{ + event.preventDefault(); + fileMutation.mutate(); + }} + > +

+ Upload a restricted Loom video +

+

+ Use this when the CSV import cannot download a video. Download an MP4 + from Loom with an account that has permission, then map it to its Loom + link and Cap owner. Restricted Library videos may need explicit + access. +

+
+ + + + +
+ + +
+ +
{ + event.preventDefault(); + statusMutation.mutate(); + }} + > +

Update customer status

+
+
+ + +
+
+ + setExpectedVideoCount(event.target.value)} + type="number" + value={expectedVideoCount} + /> +
+
+ + setImportedVideoCount(event.target.value)} + type="number" + value={importedVideoCount} + /> +
+
+
+ +