From d9a9d57db814e41e07e68a90bdc637e6d30002a3 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:43:16 +0100 Subject: [PATCH 1/8] refactor: share Loom import processing --- apps/web/__tests__/unit/loom-import.test.ts | 58 +- apps/web/actions/loom.ts | 797 +------------------- apps/web/lib/loom-import.ts | 777 +++++++++++++++++++ 3 files changed, 861 insertions(+), 771 deletions(-) create mode 100644 apps/web/lib/loom-import.ts diff --git a/apps/web/__tests__/unit/loom-import.test.ts b/apps/web/__tests__/unit/loom-import.test.ts index 765347c06a..aa846d1a88 100644 --- a/apps/web/__tests__/unit/loom-import.test.ts +++ b/apps/web/__tests__/unit/loom-import.test.ts @@ -468,6 +468,10 @@ describe("importFromLoom", () => { videoId: "video-123", }); expect(mockDb.delete).toHaveBeenCalledTimes(1); + expect(whereMock).toHaveBeenNthCalledWith( + 2, + expect.arrayContaining([{ field: "id", value: "stale-row" }]), + ); expect(valuesMock).toHaveBeenCalledTimes(3); expect(valuesMock).toHaveBeenNthCalledWith( 3, @@ -482,6 +486,58 @@ describe("importFromLoom", () => { expect(revalidatePathMock).toHaveBeenCalledWith("/dashboard/caps"); }); + it("keeps the source claim transaction atomic on a duplicate collision", async () => { + whereMock.mockResolvedValueOnce([]); + valuesMock + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("duplicate source claim")); + + const fetchMock = vi.mocked(fetch); + fetchMock.mockImplementation(async (input) => { + const url = typeof input === "string" ? input : input.toString(); + + if (url.includes("/transcoded-url")) { + return { + ok: true, + status: 200, + text: async () => + JSON.stringify({ url: "https://cdn.loom.com/video.mp4" }), + } as Response; + } + + if (url === "https://www.loom.com/graphql") { + return { + ok: true, + json: async () => ({ + data: { getVideo: { name: "Imported video" } }, + }), + } as Response; + } + + if (url.includes("/v1/oembed")) { + return { + ok: true, + json: async () => ({ duration: 42, width: 1920, height: 1080 }), + } as Response; + } + + throw new Error(`Unexpected fetch: ${url}`); + }); + + const { importFromLoom } = await import("@/actions/loom"); + + await expect( + importFromLoom({ + loomUrl: "https://www.loom.com/share/loom-abc1234567", + orgId: "org-1" as never, + }), + ).rejects.toThrow("duplicate source claim"); + expect(mockDb.transaction).toHaveBeenCalledTimes(1); + expect(valuesMock).toHaveBeenCalledTimes(3); + expect(startMock).not.toHaveBeenCalled(); + }); + it("rejects a CSV import when the current user is not an organization admin or owner", async () => { getOrganizationAccessMock.mockResolvedValueOnce({ id: "org-1", @@ -904,7 +960,7 @@ describe("importFromLoom", () => { ], error: undefined, }); - expect(mockDb.transaction).toHaveBeenCalledTimes(1); + expect(mockDb.transaction).toHaveBeenCalledTimes(2); expect(valuesMock).toHaveBeenCalledWith( expect.objectContaining({ name: "Sales Team", diff --git a/apps/web/actions/loom.ts b/apps/web/actions/loom.ts index 75f3672a2a..c63aa50545 100644 --- a/apps/web/actions/loom.ts +++ b/apps/web/actions/loom.ts @@ -1,449 +1,38 @@ "use server"; -import { randomUUID } from "node:crypto"; -import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; -import { nanoId } from "@cap/database/helpers"; -import { - importedVideos, - organizationMembers, - spaceMembers, - spaces, - spaceVideos, - users, - videos, - videoUploads, -} from "@cap/database/schema"; -import { buildEnv, NODE_ENV, serverEnv } from "@cap/env"; -import { dub, userIsPro } from "@cap/utils"; -import { Storage } from "@cap/web-backend"; -import { - type Organisation, - Space, - SpaceMemberId, - type User, - Video, -} from "@cap/web-domain"; -import { and, eq } from "drizzle-orm"; -import { Option } from "effect"; -import { revalidatePath } from "next/cache"; -import { start } from "workflow/api"; +import { userIsPro } from "@cap/utils"; +import type { Organisation } from "@cap/web-domain"; import { getOrganizationAccess, requireOrganizationAccess, } from "@/actions/organization/authorization"; -import { provisionOrganizationInvitee } from "@/lib/organization-provisioning"; +import { + downloadLoomVideo as downloadLoomVideoInternal, + importLoomCsvForUser, + importLoomVideoForOwner, + type LoomCsvImportResult, + type LoomCsvImportRow, + type LoomImportResult, +} from "@/lib/loom-import"; import { canManageOrganizationSettings } from "@/lib/permissions/roles"; -import { runPromise } from "@/lib/server"; -import { importLoomVideoWorkflow } from "@/workflows/import-loom-video"; - -interface LoomUrlResponse { - url?: string; -} - -type LoomDownloadMode = "direct-download" | "browser-conversion"; - -interface LoomDownloadResult { - success: boolean; - videoId?: string; - videoName?: string; - downloadUrl?: string; - downloadMode?: LoomDownloadMode; - durationSeconds?: number; - width?: number; - height?: number; - requiresProxy?: boolean; - error?: string; -} - -export interface LoomImportResult { - success: boolean; - videoId?: Video.VideoId; - error?: string; -} - -export interface LoomCsvImportRow { - rowNumber: number; - loomUrl: string; - userEmail: string; - spaceName?: string; -} -export interface LoomCsvImportRowResult { - rowNumber: number; - userEmail: string; - spaceName?: string; - success: boolean; - videoId?: Video.VideoId; - error?: string; -} +export type { + LoomCsvImportResult, + LoomCsvImportRow, + LoomCsvImportRowResult, + LoomImportResult, +} from "@/lib/loom-import"; -export interface LoomCsvImportResult { - success: boolean; - importedCount: number; - failedCount: number; - results: LoomCsvImportRowResult[]; - error?: string; +export async function downloadLoomVideo( + ...args: Parameters +) { + return downloadLoomVideoInternal(...args); } -const MAX_LOOM_CSV_ROWS = 500; -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."; -function extractLoomVideoId(url: string): string | null { - try { - const parsed = new URL(url); - if (!parsed.hostname.includes("loom.com")) { - return null; - } - - const pathParts = parsed.pathname.split("/").filter(Boolean); - const id = pathParts[pathParts.length - 1] ?? null; - - if (!id || id.length < 10) { - return null; - } - - return id.split("?")[0] ?? null; - } catch { - return null; - } -} - -async function fetchLoomEndpoint( - videoId: string, - endpoint: string, - includeBody = true, -): Promise { - try { - const options: RequestInit = { method: "POST" }; - if (includeBody) { - options.headers = { - "Content-Type": "application/json", - Accept: "application/json", - }; - options.body = JSON.stringify({ - anonID: randomUUID(), - deviceID: null, - force_original: false, - password: null, - }); - } - - const response = await fetch( - `https://www.loom.com/api/campaigns/sessions/${videoId}/${endpoint}`, - options, - ); - - if (!response.ok || response.status === 204) { - return null; - } - - const text = await response.text(); - if (!text.trim()) { - return null; - } - - const data: LoomUrlResponse = JSON.parse(text); - return data.url ?? null; - } catch { - return null; - } -} - -async function fetchVideoName(videoId: string): Promise { - try { - const response = await fetch("https://www.loom.com/graphql", { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - "x-loom-request-source": "loom_web", - }, - body: JSON.stringify({ - operationName: "GetVideoName", - variables: { videoId, password: null }, - query: `query GetVideoName($videoId: ID!, $password: String) { - getVideo(id: $videoId, password: $password) { - ... on RegularUserVideo { name } - ... on PrivateVideo { id } - ... on VideoPasswordMissingOrIncorrect { id } - } - }`, - }), - }); - - if (!response.ok) return null; - - const data = await response.json(); - return data?.data?.getVideo?.name ?? null; - } catch { - return null; - } -} - -function isStreamingUrl(url: string): boolean { - const path = (url.split("?")[0] ?? "").toLowerCase(); - return path.endsWith(".m3u8") || path.endsWith(".mpd"); -} - -function isDirectMp4Url(url: string): boolean { - const path = (url.split("?")[0] ?? "").toLowerCase(); - return path.endsWith(".mp4"); -} - -async function getLoomDownloadUrl(loomVideoId: string): Promise { - const requestVariants: Array<{ endpoint: string; includeBody: boolean }> = [ - { endpoint: "transcoded-url", includeBody: true }, - { endpoint: "raw-url", includeBody: true }, - { endpoint: "transcoded-url", includeBody: false }, - { endpoint: "raw-url", includeBody: false }, - ]; - - let fallbackStreamingUrl: string | null = null; - - for (const { endpoint, includeBody } of requestVariants) { - const url = await fetchLoomEndpoint(loomVideoId, endpoint, includeBody); - if (!url) continue; - - if (!isStreamingUrl(url)) return url; - - if (!fallbackStreamingUrl) fallbackStreamingUrl = url; - } - - return fallbackStreamingUrl; -} - -async function fetchLoomOEmbed( - loomVideoId: string, -): 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}`, - { headers: { Accept: "application/json" } }, - ); - if (!response.ok) return null; - const data = await response.json(); - return { - duration: data.duration ? Math.round(data.duration) : undefined, - width: data.width ?? undefined, - height: data.height ?? undefined, - }; - } catch { - return null; - } -} - -export async function downloadLoomVideo( - url: string, -): Promise { - if (!url || typeof url !== "string") { - return { success: false, error: "Please provide a valid URL." }; - } - - const videoId = extractLoomVideoId(url.trim()); - - if (!videoId) { - return { - success: false, - error: - "Invalid Loom URL. Please paste a valid Loom video link (e.g. https://www.loom.com/share/abc123).", - }; - } - - try { - const downloadUrl = await getLoomDownloadUrl(videoId); - - if (!downloadUrl) { - return { - success: false, - error: - "Could not retrieve a download URL. The video may be private, password-protected, or the link may have expired.", - }; - } - - const [videoName, oembedMeta] = await Promise.all([ - fetchVideoName(videoId), - fetchLoomOEmbed(videoId), - ]); - return { - success: true, - videoId, - videoName: videoName ?? undefined, - downloadUrl, - downloadMode: isDirectMp4Url(downloadUrl) - ? "direct-download" - : "browser-conversion", - durationSeconds: oembedMeta?.duration, - width: oembedMeta?.width, - height: oembedMeta?.height, - requiresProxy: false, - }; - } catch { - return { - success: false, - error: - "An unexpected error occurred. Please try again or check your internet connection.", - }; - } -} - -async function importLoomVideoForOwner({ - loomUrl, - orgId, - ownerId, -}: { - loomUrl: string; - orgId: Organisation.OrganisationId; - ownerId: User.UserId; -}): Promise { - const loomVideoId = extractLoomVideoId(loomUrl.trim()); - if (!loomVideoId) { - return { - success: false, - error: - "Invalid Loom URL. Please paste a valid Loom video link (e.g. https://www.loom.com/share/abc123).", - }; - } - - const existing = await db() - .select({ - videoId: videos.id, - }) - .from(importedVideos) - .leftJoin( - videos, - and( - eq(videos.id, importedVideos.id), - eq(videos.orgId, importedVideos.orgId), - ), - ) - .where( - and( - eq(importedVideos.orgId, orgId), - eq(importedVideos.source, "loom"), - eq(importedVideos.sourceId, loomVideoId), - ), - ); - - if (existing.some((row) => row.videoId !== null)) { - return { - success: false, - error: "This Loom video has already been imported.", - }; - } - - if (existing.length > 0) { - await db() - .delete(importedVideos) - .where( - and( - eq(importedVideos.orgId, orgId), - eq(importedVideos.source, "loom"), - eq(importedVideos.sourceId, loomVideoId), - ), - ); - } - - const downloadUrl = await getLoomDownloadUrl(loomVideoId); - if (!downloadUrl) { - return { - success: false, - error: - "Could not retrieve a download URL. The video may be private, password-protected, or the link may have expired.", - }; - } - - const [videoName, oembedMeta] = await Promise.all([ - fetchVideoName(loomVideoId), - fetchLoomOEmbed(loomVideoId), - ]); - - const writableResult = await Storage.getWritableAccessForUser(ownerId, orgId) - .pipe(runPromise) - .then( - (value) => ({ ok: true as const, value }), - (error) => ({ ok: false as const, error }), - ); - - if (!writableResult.ok) { - console.error( - `Loom import: failed to resolve storage access for user ${ownerId} in org ${orgId}:`, - writableResult.error, - ); - return { - success: false, - error: - "Could not prepare storage for this import. Please try again or contact support.", - }; - } - - const writable = writableResult.value; - - const videoId = Video.VideoId.make(nanoId()); - const name = - videoName || - `Loom Import - ${new Date().toLocaleDateString("en-US", { day: "numeric", month: "long", year: "numeric" })}`; - - await db() - .insert(videos) - .values({ - id: videoId, - name, - ownerId, - orgId, - source: { type: "webMP4" as const }, - bucket: Option.getOrNull(writable.bucketId), - storageIntegrationId: Option.getOrNull(writable.storageIntegrationId), - public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, - ...(oembedMeta?.duration ? { duration: oembedMeta.duration } : {}), - ...(oembedMeta?.width ? { width: oembedMeta.width } : {}), - ...(oembedMeta?.height ? { height: oembedMeta.height } : {}), - }); - - await db().insert(videoUploads).values({ - videoId, - phase: "uploading", - processingProgress: 0, - processingMessage: "Importing from Loom...", - }); - - await db().insert(importedVideos).values({ - id: videoId, - orgId, - source: "loom", - sourceId: loomVideoId, - }); - - const rawFileKey = `${ownerId}/${videoId}/raw-upload.mp4`; - - if (buildEnv.NEXT_PUBLIC_IS_CAP && NODE_ENV === "production") { - await dub() - .links.create({ - url: `${serverEnv().WEB_URL}/s/${videoId}`, - domain: "cap.link", - key: videoId, - }) - .catch(() => {}); - } - - await start(importLoomVideoWorkflow, [ - { - videoId, - userId: ownerId, - rawFileKey, - bucketId: Option.getOrNull(writable.bucketId), - loomVideoId, - }, - ]); - - revalidatePath("/dashboard/caps"); - - return { success: true, videoId }; -} - export async function importFromLoom({ loomUrl, orgId, @@ -452,7 +41,10 @@ export async function importFromLoom({ orgId: Organisation.OrganisationId; }): Promise { const user = await getCurrentUser(); - if (!user) return { success: false, error: "Unauthorized" }; + + if (!user) { + return { success: false, error: "Unauthorized" }; + } if (!userIsPro(user)) { return { @@ -470,170 +62,6 @@ export async function importFromLoom({ }); } -function normalizeImportEmail(email: string) { - return email.trim().toLowerCase(); -} - -function normalizeImportSpaceName(spaceName: string) { - return spaceName.trim().replace(/\s+/g, " "); -} - -function getSpaceNameCacheKey(spaceName: string) { - return normalizeImportSpaceName(spaceName).toLowerCase(); -} - -function isValidImportEmail(email: string) { - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); -} - -function isValidImportSpaceName(spaceName: string) { - return spaceName.length <= MAX_LOOM_SPACE_NAME_LENGTH; -} - -async function getOrganizationMemberByEmail( - orgId: Organisation.OrganisationId, - email: string, -) { - const [member] = await db() - .select({ - userId: organizationMembers.userId, - email: users.email, - }) - .from(organizationMembers) - .innerJoin(users, eq(organizationMembers.userId, users.id)) - .where( - and( - eq(organizationMembers.organizationId, orgId), - eq(users.email, email), - ), - ) - .limit(1); - - return member ?? null; -} - -type ImportSpaceCacheValue = { - id: Space.SpaceIdOrOrganisationId; - name: string; -}; - -async function getOrCreateImportSpace({ - orgId, - createdById, - name, - spaceCache, -}: { - orgId: Organisation.OrganisationId; - createdById: User.UserId; - name: string; - spaceCache: Map; -}) { - const normalizedName = normalizeImportSpaceName(name); - const cacheKey = getSpaceNameCacheKey(normalizedName); - const cached = spaceCache.get(cacheKey); - if (cached) return cached; - - const [existingSpace] = await db() - .select({ - id: spaces.id, - name: spaces.name, - }) - .from(spaces) - .where( - and(eq(spaces.organizationId, orgId), eq(spaces.name, normalizedName)), - ) - .limit(1); - - if (existingSpace) { - const value = { - id: existingSpace.id, - name: existingSpace.name, - }; - spaceCache.set(cacheKey, value); - return value; - } - - const spaceId = Space.SpaceId.make(nanoId()); - - await db().transaction(async (tx) => { - await tx.insert(spaces).values({ - id: spaceId, - name: normalizedName, - organizationId: orgId, - createdById, - iconUrl: null, - }); - - await tx.insert(spaceMembers).values({ - id: SpaceMemberId.make(nanoId()), - spaceId, - userId: createdById, - role: "admin", - }); - }); - - const value = { - id: spaceId, - name: normalizedName, - }; - spaceCache.set(cacheKey, value); - return value; -} - -async function addImportedVideoToSpace({ - videoId, - spaceId, - addedById, -}: { - videoId: Video.VideoId; - spaceId: Space.SpaceIdOrOrganisationId; - addedById: User.UserId; -}) { - const [existingSpaceVideo] = await db() - .select({ id: spaceVideos.id }) - .from(spaceVideos) - .where( - and(eq(spaceVideos.spaceId, spaceId), eq(spaceVideos.videoId, videoId)), - ) - .limit(1); - - if (existingSpaceVideo) return; - - await db().insert(spaceVideos).values({ - id: nanoId(), - spaceId, - videoId, - addedById, - }); -} - -async function addImportOwnerToSpace({ - spaceId, - userId, -}: { - spaceId: Space.SpaceIdOrOrganisationId; - userId: User.UserId; -}) { - const [existingSpaceMember] = await db() - .select({ id: spaceMembers.id }) - .from(spaceMembers) - .where( - and(eq(spaceMembers.spaceId, spaceId), eq(spaceMembers.userId, userId)), - ) - .limit(1); - - if (existingSpaceMember) return; - - await db() - .insert(spaceMembers) - .values({ - id: SpaceMemberId.make(nanoId()), - spaceId, - userId, - role: "member", - }); -} - export async function importFromLoomCsv({ rows, orgId, @@ -642,6 +70,7 @@ export async function importFromLoomCsv({ orgId: Organisation.OrganisationId; }): Promise { const user = await getCurrentUser(); + if (!user) { return { success: false, @@ -663,7 +92,7 @@ export async function importFromLoomCsv({ } const access = await getOrganizationAccess(user.id, orgId); - if (!canManageOrganizationSettings(access?.role)) { + if (!access || !canManageOrganizationSettings(access.role)) { return { success: false, importedCount: 0, @@ -673,177 +102,5 @@ export async function importFromLoomCsv({ }; } - const inputRows = Array.isArray(rows) ? rows : []; - const normalizedRows = inputRows - .map((row, index) => ({ - rowNumber: - Number.isInteger(row.rowNumber) && row.rowNumber > 0 - ? row.rowNumber - : index + 2, - loomUrl: typeof row.loomUrl === "string" ? row.loomUrl.trim() : "", - userEmail: - typeof row.userEmail === "string" - ? normalizeImportEmail(row.userEmail) - : "", - spaceName: - typeof row.spaceName === "string" - ? normalizeImportSpaceName(row.spaceName) - : "", - })) - .filter((row) => row.loomUrl || row.userEmail || row.spaceName); - - if (normalizedRows.length === 0) { - return { - success: false, - importedCount: 0, - failedCount: 0, - results: [], - error: "No rows found to import.", - }; - } - - if (normalizedRows.length > MAX_LOOM_CSV_ROWS) { - return { - success: false, - importedCount: 0, - failedCount: normalizedRows.length, - results: [], - error: LOOM_CSV_LIMIT_ERROR, - }; - } - - const results: LoomCsvImportRowResult[] = []; - const spaceCache = new Map(); - const touchedSpaceIds = new Set(); - - for (const row of normalizedRows) { - if (!row.loomUrl) { - results.push({ - rowNumber: row.rowNumber, - userEmail: row.userEmail, - spaceName: row.spaceName || undefined, - success: false, - error: "Missing Loom video URL.", - }); - continue; - } - - if (!isValidImportEmail(row.userEmail)) { - results.push({ - rowNumber: row.rowNumber, - userEmail: row.userEmail, - spaceName: row.spaceName || undefined, - success: false, - error: "Missing or invalid user email.", - }); - continue; - } - - if (!isValidImportSpaceName(row.spaceName)) { - results.push({ - rowNumber: row.rowNumber, - userEmail: row.userEmail, - spaceName: row.spaceName, - success: false, - error: `Space name must be ${MAX_LOOM_SPACE_NAME_LENGTH} characters or fewer.`, - }); - continue; - } - - let member = await getOrganizationMemberByEmail(orgId, row.userEmail); - - if (!member) { - try { - const provisionedMember = await provisionOrganizationInvitee({ - organizationId: orgId, - email: row.userEmail, - invitedByUserId: user.id, - role: "member", - }); - member = { - userId: provisionedMember.userId, - email: row.userEmail, - }; - } catch { - results.push({ - rowNumber: row.rowNumber, - userEmail: row.userEmail, - spaceName: row.spaceName || undefined, - success: false, - error: "Could not add this email to the organization.", - }); - continue; - } - } - - try { - const result = await importLoomVideoForOwner({ - loomUrl: row.loomUrl, - orgId, - ownerId: member.userId, - }); - - let spaceName = row.spaceName || undefined; - let spaceError: string | undefined; - if (result.success && result.videoId && row.spaceName) { - try { - const space = await getOrCreateImportSpace({ - orgId, - createdById: user.id, - name: row.spaceName, - spaceCache, - }); - await addImportedVideoToSpace({ - videoId: result.videoId, - spaceId: space.id, - addedById: user.id, - }); - await addImportOwnerToSpace({ - spaceId: space.id, - userId: member.userId, - }); - touchedSpaceIds.add(space.id); - spaceName = space.name; - } catch { - spaceError = "Import started, but it could not be added to a space."; - } - } - - results.push({ - rowNumber: row.rowNumber, - userEmail: row.userEmail, - spaceName, - success: result.success, - videoId: result.videoId, - error: result.error ?? spaceError, - }); - } catch { - results.push({ - rowNumber: row.rowNumber, - userEmail: row.userEmail, - spaceName: row.spaceName || undefined, - success: false, - error: "Failed to start this import.", - }); - } - } - - const importedCount = results.filter((result) => result.success).length; - const failedCount = results.length - importedCount; - - for (const spaceId of touchedSpaceIds) { - revalidatePath(`/dashboard/spaces/${spaceId}`); - } - - if (touchedSpaceIds.size > 0) { - revalidatePath("/dashboard"); - } - - return { - success: importedCount > 0, - importedCount, - failedCount, - results, - error: importedCount > 0 ? undefined : "No Loom videos were imported.", - }; + return importLoomCsvForUser({ rows, orgId, user }); } diff --git a/apps/web/lib/loom-import.ts b/apps/web/lib/loom-import.ts new file mode 100644 index 0000000000..95093e3845 --- /dev/null +++ b/apps/web/lib/loom-import.ts @@ -0,0 +1,777 @@ +import "server-only"; + +import { randomUUID } from "node:crypto"; +import { db } from "@cap/database"; +import { nanoId } from "@cap/database/helpers"; +import { + importedVideos, + organizationMembers, + spaceMembers, + spaces, + spaceVideos, + users, + videos, + videoUploads, +} from "@cap/database/schema"; +import { buildEnv, NODE_ENV, serverEnv } from "@cap/env"; +import { dub } from "@cap/utils"; +import { Storage } from "@cap/web-backend"; +import { + type Organisation, + Space, + SpaceMemberId, + type User, + Video, +} from "@cap/web-domain"; +import { and, eq } from "drizzle-orm"; +import { Option } from "effect"; +import { revalidatePath } from "next/cache"; +import { start } from "workflow/api"; +import { provisionOrganizationInvitee } from "@/lib/organization-provisioning"; +import { runPromise } from "@/lib/server"; +import { importLoomVideoWorkflow } from "@/workflows/import-loom-video"; + +interface LoomUrlResponse { + url?: string; +} + +type LoomDownloadMode = "direct-download" | "browser-conversion"; + +interface LoomDownloadResult { + success: boolean; + videoId?: string; + videoName?: string; + downloadUrl?: string; + downloadMode?: LoomDownloadMode; + durationSeconds?: number; + width?: number; + height?: number; + requiresProxy?: boolean; + error?: string; +} + +export interface LoomImportResult { + success: boolean; + videoId?: Video.VideoId; + error?: string; +} + +export interface LoomCsvImportRow { + rowNumber: number; + loomUrl: string; + userEmail: string; + spaceName?: string; +} + +export interface LoomCsvImportRowResult { + rowNumber: number; + userEmail: string; + spaceName?: string; + success: boolean; + videoId?: Video.VideoId; + error?: string; +} + +export interface LoomCsvImportResult { + success: boolean; + importedCount: number; + failedCount: number; + results: LoomCsvImportRowResult[]; + error?: string; +} + +const MAX_LOOM_CSV_ROWS = 500; +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.`; + +function extractLoomVideoId(url: string): string | null { + try { + const parsed = new URL(url); + if (!parsed.hostname.includes("loom.com")) { + return null; + } + + const pathParts = parsed.pathname.split("/").filter(Boolean); + const id = pathParts[pathParts.length - 1] ?? null; + + if (!id || id.length < 10) { + return null; + } + + return id.split("?")[0] ?? null; + } catch { + return null; + } +} + +async function fetchLoomEndpoint( + videoId: string, + endpoint: string, + includeBody = true, +): Promise { + try { + const options: RequestInit = { method: "POST" }; + if (includeBody) { + options.headers = { + "Content-Type": "application/json", + Accept: "application/json", + }; + options.body = JSON.stringify({ + anonID: randomUUID(), + deviceID: null, + force_original: false, + password: null, + }); + } + + const response = await fetch( + `https://www.loom.com/api/campaigns/sessions/${videoId}/${endpoint}`, + options, + ); + + if (!response.ok || response.status === 204) { + return null; + } + + const text = await response.text(); + if (!text.trim()) { + return null; + } + + const data: LoomUrlResponse = JSON.parse(text); + return data.url ?? null; + } catch { + return null; + } +} + +async function fetchVideoName(videoId: string): Promise { + try { + const response = await fetch("https://www.loom.com/graphql", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "x-loom-request-source": "loom_web", + }, + body: JSON.stringify({ + operationName: "GetVideoName", + variables: { videoId, password: null }, + query: `query GetVideoName($videoId: ID!, $password: String) { + getVideo(id: $videoId, password: $password) { + ... on RegularUserVideo { name } + ... on PrivateVideo { id } + ... on VideoPasswordMissingOrIncorrect { id } + } + }`, + }), + }); + + if (!response.ok) return null; + + const data = await response.json(); + return data?.data?.getVideo?.name ?? null; + } catch { + return null; + } +} + +function isStreamingUrl(url: string): boolean { + const path = (url.split("?")[0] ?? "").toLowerCase(); + return path.endsWith(".m3u8") || path.endsWith(".mpd"); +} + +function isDirectMp4Url(url: string): boolean { + const path = (url.split("?")[0] ?? "").toLowerCase(); + return path.endsWith(".mp4"); +} + +async function getLoomDownloadUrl(loomVideoId: string): Promise { + const requestVariants: Array<{ endpoint: string; includeBody: boolean }> = [ + { endpoint: "transcoded-url", includeBody: true }, + { endpoint: "raw-url", includeBody: true }, + { endpoint: "transcoded-url", includeBody: false }, + { endpoint: "raw-url", includeBody: false }, + ]; + + let fallbackStreamingUrl: string | null = null; + + for (const { endpoint, includeBody } of requestVariants) { + const url = await fetchLoomEndpoint(loomVideoId, endpoint, includeBody); + if (!url) continue; + + if (!isStreamingUrl(url)) return url; + + if (!fallbackStreamingUrl) fallbackStreamingUrl = url; + } + + return fallbackStreamingUrl; +} + +async function fetchLoomOEmbed( + loomVideoId: string, +): 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}`, + { headers: { Accept: "application/json" } }, + ); + if (!response.ok) return null; + const data = await response.json(); + return { + duration: data.duration ? Math.round(data.duration) : undefined, + width: data.width ?? undefined, + height: data.height ?? undefined, + }; + } catch { + return null; + } +} + +export async function downloadLoomVideo( + url: string, +): Promise { + if (!url || typeof url !== "string") { + return { success: false, error: "Please provide a valid URL." }; + } + + const videoId = extractLoomVideoId(url.trim()); + + if (!videoId) { + return { + success: false, + error: + "Invalid Loom URL. Please paste a valid Loom video link (e.g. https://www.loom.com/share/abc123).", + }; + } + + try { + const downloadUrl = await getLoomDownloadUrl(videoId); + + if (!downloadUrl) { + return { + success: false, + error: + "Could not retrieve a download URL. The video may be private, password-protected, or the link may have expired.", + }; + } + + const [videoName, oembedMeta] = await Promise.all([ + fetchVideoName(videoId), + fetchLoomOEmbed(videoId), + ]); + return { + success: true, + videoId, + videoName: videoName ?? undefined, + downloadUrl, + downloadMode: isDirectMp4Url(downloadUrl) + ? "direct-download" + : "browser-conversion", + durationSeconds: oembedMeta?.duration, + width: oembedMeta?.width, + height: oembedMeta?.height, + requiresProxy: false, + }; + } catch { + return { + success: false, + error: + "An unexpected error occurred. Please try again or check your internet connection.", + }; + } +} + +export async function importLoomVideoForOwner({ + loomUrl, + orgId, + ownerId, +}: { + loomUrl: string; + orgId: Organisation.OrganisationId; + ownerId: User.UserId; +}): Promise { + const loomVideoId = extractLoomVideoId(loomUrl.trim()); + if (!loomVideoId) { + return { + success: false, + error: + "Invalid Loom URL. Please paste a valid Loom video link (e.g. https://www.loom.com/share/abc123).", + }; + } + + const existing = await db() + .select({ + importedVideoId: importedVideos.id, + videoId: videos.id, + }) + .from(importedVideos) + .leftJoin( + videos, + and( + eq(videos.id, importedVideos.id), + eq(videos.orgId, importedVideos.orgId), + ), + ) + .where( + and( + eq(importedVideos.orgId, orgId), + eq(importedVideos.source, "loom"), + eq(importedVideos.sourceId, loomVideoId), + ), + ); + + if (existing.some((row) => row.videoId !== null)) { + return { + success: false, + error: "This Loom video has already been imported.", + }; + } + + for (const staleImport of existing) { + await db() + .delete(importedVideos) + .where( + and( + eq(importedVideos.orgId, orgId), + eq(importedVideos.source, "loom"), + eq(importedVideos.sourceId, loomVideoId), + eq(importedVideos.id, staleImport.importedVideoId), + ), + ); + } + + const downloadUrl = await getLoomDownloadUrl(loomVideoId); + if (!downloadUrl) { + return { + success: false, + error: + "Could not retrieve a download URL. The video may be private, password-protected, or the link may have expired.", + }; + } + + const [videoName, oembedMeta] = await Promise.all([ + fetchVideoName(loomVideoId), + fetchLoomOEmbed(loomVideoId), + ]); + + const writableResult = await Storage.getWritableAccessForUser(ownerId, orgId) + .pipe(runPromise) + .then( + (value) => ({ ok: true as const, value }), + (error) => ({ ok: false as const, error }), + ); + + if (!writableResult.ok) { + console.error( + `Loom import: failed to resolve storage access for user ${ownerId} in org ${orgId}:`, + writableResult.error, + ); + return { + success: false, + error: + "Could not prepare storage for this import. Please try again or contact support.", + }; + } + + const writable = writableResult.value; + + const videoId = Video.VideoId.make(nanoId()); + const name = + videoName || + `Loom Import - ${new Date().toLocaleDateString("en-US", { day: "numeric", month: "long", year: "numeric" })}`; + + await db().transaction(async (tx) => { + await tx.insert(videos).values({ + id: videoId, + name, + ownerId, + orgId, + source: { type: "webMP4" as const }, + bucket: Option.getOrNull(writable.bucketId), + storageIntegrationId: Option.getOrNull(writable.storageIntegrationId), + public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, + ...(oembedMeta?.duration ? { duration: oembedMeta.duration } : {}), + ...(oembedMeta?.width ? { width: oembedMeta.width } : {}), + ...(oembedMeta?.height ? { height: oembedMeta.height } : {}), + }); + + await tx.insert(videoUploads).values({ + videoId, + phase: "uploading", + processingProgress: 0, + processingMessage: "Importing from Loom...", + }); + + await tx.insert(importedVideos).values({ + id: videoId, + orgId, + source: "loom", + sourceId: loomVideoId, + }); + }); + + const rawFileKey = `${ownerId}/${videoId}/raw-upload.mp4`; + + if (buildEnv.NEXT_PUBLIC_IS_CAP && NODE_ENV === "production") { + await dub() + .links.create({ + url: `${serverEnv().WEB_URL}/s/${videoId}`, + domain: "cap.link", + key: videoId, + }) + .catch(() => {}); + } + + await start(importLoomVideoWorkflow, [ + { + videoId, + userId: ownerId, + rawFileKey, + bucketId: Option.getOrNull(writable.bucketId), + loomVideoId, + }, + ]); + + revalidatePath("/dashboard/caps"); + + return { success: true, videoId }; +} + +function normalizeImportEmail(email: string) { + return email.trim().toLowerCase(); +} + +function normalizeImportSpaceName(spaceName: string) { + return spaceName.trim().replace(/\s+/g, " "); +} + +function getSpaceNameCacheKey(spaceName: string) { + return normalizeImportSpaceName(spaceName).toLowerCase(); +} + +function isValidImportEmail(email: string) { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} + +function isValidImportSpaceName(spaceName: string) { + return spaceName.length <= MAX_LOOM_SPACE_NAME_LENGTH; +} + +async function getOrganizationMemberByEmail( + orgId: Organisation.OrganisationId, + email: string, +) { + const [member] = await db() + .select({ + userId: organizationMembers.userId, + email: users.email, + }) + .from(organizationMembers) + .innerJoin(users, eq(organizationMembers.userId, users.id)) + .where( + and( + eq(organizationMembers.organizationId, orgId), + eq(users.email, email), + ), + ) + .limit(1); + + return member ?? null; +} + +type ImportSpaceCacheValue = { + id: Space.SpaceIdOrOrganisationId; + name: string; +}; + +async function getOrCreateImportSpace({ + orgId, + createdById, + name, + spaceCache, +}: { + orgId: Organisation.OrganisationId; + createdById: User.UserId; + name: string; + spaceCache: Map; +}) { + const normalizedName = normalizeImportSpaceName(name); + const cacheKey = getSpaceNameCacheKey(normalizedName); + const cached = spaceCache.get(cacheKey); + if (cached) return cached; + + const [existingSpace] = await db() + .select({ + id: spaces.id, + name: spaces.name, + }) + .from(spaces) + .where( + and(eq(spaces.organizationId, orgId), eq(spaces.name, normalizedName)), + ) + .limit(1); + + if (existingSpace) { + const value = { id: existingSpace.id, name: existingSpace.name }; + spaceCache.set(cacheKey, value); + return value; + } + + const spaceId = Space.SpaceId.make(nanoId()); + await db().transaction(async (tx) => { + await tx.insert(spaces).values({ + id: spaceId, + name: normalizedName, + organizationId: orgId, + createdById, + iconUrl: null, + }); + await tx.insert(spaceMembers).values({ + id: SpaceMemberId.make(nanoId()), + spaceId, + userId: createdById, + role: "admin", + }); + }); + + const value = { id: spaceId, name: normalizedName }; + spaceCache.set(cacheKey, value); + return value; +} + +async function addImportedVideoToSpace({ + videoId, + spaceId, + addedById, +}: { + videoId: Video.VideoId; + spaceId: Space.SpaceIdOrOrganisationId; + addedById: User.UserId; +}) { + const [existingSpaceVideo] = await db() + .select({ id: spaceVideos.id }) + .from(spaceVideos) + .where( + and(eq(spaceVideos.spaceId, spaceId), eq(spaceVideos.videoId, videoId)), + ) + .limit(1); + if (existingSpaceVideo) return; + + await db().insert(spaceVideos).values({ + id: nanoId(), + spaceId, + videoId, + addedById, + }); +} + +async function addImportOwnerToSpace({ + spaceId, + userId, +}: { + spaceId: Space.SpaceIdOrOrganisationId; + userId: User.UserId; +}) { + const [existingSpaceMember] = await db() + .select({ id: spaceMembers.id }) + .from(spaceMembers) + .where( + and(eq(spaceMembers.spaceId, spaceId), eq(spaceMembers.userId, userId)), + ) + .limit(1); + if (existingSpaceMember) return; + + await db() + .insert(spaceMembers) + .values({ + id: SpaceMemberId.make(nanoId()), + spaceId, + userId, + role: "member", + }); +} + +export async function importLoomCsvForUser({ + rows, + orgId, + user, +}: { + rows: LoomCsvImportRow[]; + orgId: Organisation.OrganisationId; + user: typeof users.$inferSelect; +}): Promise { + const inputRows = Array.isArray(rows) ? rows : []; + const normalizedRows = inputRows + .map((row, index) => ({ + rowNumber: + Number.isInteger(row.rowNumber) && row.rowNumber > 0 + ? row.rowNumber + : index + 2, + loomUrl: typeof row.loomUrl === "string" ? row.loomUrl.trim() : "", + userEmail: + typeof row.userEmail === "string" + ? normalizeImportEmail(row.userEmail) + : "", + spaceName: + typeof row.spaceName === "string" + ? normalizeImportSpaceName(row.spaceName) + : "", + })) + .filter((row) => row.loomUrl || row.userEmail || row.spaceName); + + if (normalizedRows.length === 0) { + return { + success: false, + importedCount: 0, + failedCount: 0, + results: [], + error: "No rows found to import.", + }; + } + + if (normalizedRows.length > MAX_LOOM_CSV_ROWS) { + return { + success: false, + importedCount: 0, + failedCount: normalizedRows.length, + results: [], + error: LOOM_CSV_LIMIT_ERROR, + }; + } + + const results: LoomCsvImportRowResult[] = []; + const spaceCache = new Map(); + const touchedSpaceIds = new Set(); + + for (const row of normalizedRows) { + if (!row.loomUrl) { + results.push({ + rowNumber: row.rowNumber, + userEmail: row.userEmail, + spaceName: row.spaceName || undefined, + success: false, + error: "Missing Loom video URL.", + }); + continue; + } + + if (!isValidImportEmail(row.userEmail)) { + results.push({ + rowNumber: row.rowNumber, + userEmail: row.userEmail, + spaceName: row.spaceName || undefined, + success: false, + error: "Missing or invalid user email.", + }); + continue; + } + + if (!isValidImportSpaceName(row.spaceName)) { + results.push({ + rowNumber: row.rowNumber, + userEmail: row.userEmail, + spaceName: row.spaceName, + success: false, + error: `Space name must be ${MAX_LOOM_SPACE_NAME_LENGTH} characters or fewer.`, + }); + continue; + } + + let member = await getOrganizationMemberByEmail(orgId, row.userEmail); + + if (!member) { + try { + const provisionedMember = await provisionOrganizationInvitee({ + organizationId: orgId, + email: row.userEmail, + invitedByUserId: user.id, + role: "member", + }); + member = { + userId: provisionedMember.userId, + email: row.userEmail, + }; + } catch { + results.push({ + rowNumber: row.rowNumber, + userEmail: row.userEmail, + spaceName: row.spaceName || undefined, + success: false, + error: "Could not add this email to the organization.", + }); + continue; + } + } + + try { + const result = await importLoomVideoForOwner({ + loomUrl: row.loomUrl, + orgId, + ownerId: member.userId, + }); + + let spaceName = row.spaceName || undefined; + let spaceError: string | undefined; + if (result.success && result.videoId && row.spaceName) { + try { + const space = await getOrCreateImportSpace({ + orgId, + createdById: user.id, + name: row.spaceName, + spaceCache, + }); + await addImportedVideoToSpace({ + videoId: result.videoId, + spaceId: space.id, + addedById: user.id, + }); + await addImportOwnerToSpace({ + spaceId: space.id, + userId: member.userId, + }); + touchedSpaceIds.add(space.id); + spaceName = space.name; + } catch { + spaceError = "Import started, but it could not be added to a space."; + } + } + + results.push({ + rowNumber: row.rowNumber, + userEmail: row.userEmail, + spaceName, + success: result.success, + videoId: result.videoId, + error: result.error ?? spaceError, + }); + } catch { + results.push({ + rowNumber: row.rowNumber, + userEmail: row.userEmail, + spaceName: row.spaceName || undefined, + success: false, + error: "Failed to start this import.", + }); + } + } + + const importedCount = results.filter((result) => result.success).length; + const failedCount = results.length - importedCount; + + for (const spaceId of touchedSpaceIds) { + revalidatePath(`/dashboard/spaces/${spaceId}`); + } + + if (touchedSpaceIds.size > 0) { + revalidatePath("/dashboard"); + } + + return { + success: importedCount > 0, + importedCount, + failedCount, + results, + error: importedCount > 0 ? undefined : "No Loom videos were imported.", + }; +} From 693d4d3ef3a123ae0e08aad6e34b96221bb0f767 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:43:17 +0100 Subject: [PATCH 2/8] feat: add extension Loom import requests --- .../unit/extension-loom-import.test.ts | 347 +++++++++++++++ .../app/api/extension/import-loom/route.ts | 177 ++++++++ apps/web/lib/extension-loom-import.ts | 410 ++++++++++++++++++ 3 files changed, 934 insertions(+) create mode 100644 apps/web/__tests__/unit/extension-loom-import.test.ts create mode 100644 apps/web/app/api/extension/import-loom/route.ts create mode 100644 apps/web/lib/extension-loom-import.ts diff --git a/apps/web/__tests__/unit/extension-loom-import.test.ts b/apps/web/__tests__/unit/extension-loom-import.test.ts new file mode 100644 index 0000000000..57379bf073 --- /dev/null +++ b/apps/web/__tests__/unit/extension-loom-import.test.ts @@ -0,0 +1,347 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const dbMock = vi.hoisted(() => vi.fn()); +const whereMock = vi.hoisted(() => vi.fn()); +const limitMock = vi.hoisted(() => vi.fn()); +const importLoomCsvForUserMock = vi.hoisted(() => vi.fn()); +const userIsProMock = vi.hoisted(() => vi.fn(() => true)); +const getEffectiveOrganizationRoleMock = vi.hoisted(() => vi.fn()); +const canManageOrganizationSettingsMock = vi.hoisted(() => vi.fn()); +const mockDb = { + select: vi.fn(), + from: vi.fn(), + leftJoin: vi.fn(), + innerJoin: vi.fn(), + where: whereMock, +}; + +mockDb.select.mockReturnValue(mockDb); +mockDb.from.mockReturnValue(mockDb); +mockDb.leftJoin.mockReturnValue(mockDb); +mockDb.innerJoin.mockReturnValue(mockDb); +whereMock.mockReturnValue({ limit: limitMock }); +dbMock.mockReturnValue(mockDb); + +vi.mock("server-only", () => ({})); +vi.mock("@cap/database", () => ({ db: dbMock })); +vi.mock("@cap/database/schema", () => ({ + importedVideos: {}, + organizationMembers: {}, + organizations: {}, + users: {}, + videos: {}, +})); +vi.mock("@cap/env", () => ({ + serverEnv: vi.fn(() => ({ CAP_VIDEOS_DEFAULT_PUBLIC: true })), +})); +vi.mock("@cap/utils", () => ({ userIsPro: userIsProMock })); +vi.mock("@/lib/loom-import", () => ({ + importLoomCsvForUser: importLoomCsvForUserMock, +})); +vi.mock("@/lib/permissions/roles", () => ({ + canManageOrganizationSettings: canManageOrganizationSettingsMock, + getEffectiveOrganizationRole: getEffectiveOrganizationRoleMock, +})); + +import { + authorizeExtensionLoomImport, + canonicalizeExtensionLoomUrl, + ExtensionLoomAuthorizationError, + importExtensionLoomRow, + MAX_EXTENSION_LOOM_ROW_NUMBER, + validateExtensionLoomRow, +} from "@/lib/extension-loom-import"; + +const validRow = { + rowNumber: 1, + loomUrl: `https://www.loom.com/share/${"a".repeat(32)}`, + userEmail: "owner@example.com", +}; + +describe("extension Loom import validation", () => { + beforeEach(() => { + vi.clearAllMocks(); + limitMock.mockReset(); + importLoomCsvForUserMock.mockReset(); + getEffectiveOrganizationRoleMock.mockReset(); + canManageOrganizationSettingsMock.mockReset(); + dbMock.mockReturnValue(mockDb); + mockDb.select.mockReturnValue(mockDb); + mockDb.from.mockReturnValue(mockDb); + mockDb.leftJoin.mockReturnValue(mockDb); + mockDb.innerJoin.mockReturnValue(mockDb); + whereMock.mockReturnValue({ limit: limitMock }); + userIsProMock.mockReturnValue(true); + }); + + it("accepts share links and 32-hex embed links", () => { + expect(validateExtensionLoomRow(validRow)).toBeUndefined(); + expect( + validateExtensionLoomRow({ + ...validRow, + loomUrl: `https://loom.com/embed/${"a".repeat(32)}`, + }), + ).toBeUndefined(); + expect( + canonicalizeExtensionLoomUrl( + `HTTPS://WWW.LOOM.COM/EMBED/${"B".repeat(32)}/`, + ), + ).toBe(`https://www.loom.com/share/${"b".repeat(32)}`); + }); + + it("rejects non-canonical Loom hosts and paths", () => { + for (const loomUrl of [ + "http://www.loom.com/share/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "https://cdn.loom.com/share/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "https://www.loom.com/watch/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "https://www.loom.com/embed/not-32-hex", + "https://www.loom.com/share//aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ]) { + expect(validateExtensionLoomRow({ ...validRow, loomUrl })).toBeDefined(); + } + }); + + it("bounds row numbers and CSV fields", () => { + expect( + validateExtensionLoomRow({ + ...validRow, + rowNumber: MAX_EXTENSION_LOOM_ROW_NUMBER + 1, + }), + ).toBeDefined(); + expect( + validateExtensionLoomRow({ + ...validRow, + userEmail: "owner\u0000@example.com", + }), + ).toBeDefined(); + expect( + validateExtensionLoomRow({ + ...validRow, + spaceName: "Sales\nTeam", + }), + ).toBeDefined(); + expect( + validateExtensionLoomRow({ + ...validRow, + userEmail: "invalid", + }), + ).toBeDefined(); + expect( + validateExtensionLoomRow({ + ...validRow, + spaceName: "x".repeat(256), + }), + ).toBeDefined(); + expect( + validateExtensionLoomRow({ + ...validRow, + userEmail: `${"a".repeat(246)}@example.com`, + }), + ).toBeDefined(); + }); +}); + +describe("extension Loom import authorization and outcomes", () => { + beforeEach(() => { + vi.clearAllMocks(); + limitMock.mockReset(); + importLoomCsvForUserMock.mockReset(); + getEffectiveOrganizationRoleMock.mockReset(); + canManageOrganizationSettingsMock.mockReset(); + dbMock.mockReturnValue(mockDb); + mockDb.select.mockReturnValue(mockDb); + mockDb.from.mockReturnValue(mockDb); + mockDb.leftJoin.mockReturnValue(mockDb); + mockDb.innerJoin.mockReturnValue(mockDb); + whereMock.mockReturnValue({ limit: limitMock }); + userIsProMock.mockReturnValue(true); + }); + + it("rejects missing organizations before any import effect", async () => { + limitMock.mockResolvedValueOnce([]); + + await expect( + authorizeExtensionLoomImport({ + userId: "user-1" as never, + organizationId: "org-1" as never, + }), + ).rejects.toBeInstanceOf(ExtensionLoomAuthorizationError); + expect(importLoomCsvForUserMock).not.toHaveBeenCalled(); + }); + + it("rejects non-Pro organization administrators", async () => { + userIsProMock.mockReturnValueOnce(false); + getEffectiveOrganizationRoleMock.mockReturnValueOnce("admin"); + canManageOrganizationSettingsMock.mockReturnValueOnce(true); + limitMock.mockResolvedValueOnce([ + { + user: { id: "user-1", email: "owner@example.com" }, + ownerId: "owner-1", + memberRole: "admin", + }, + ]); + + await expect( + authorizeExtensionLoomImport({ + userId: "user-1" as never, + organizationId: "org-1" as never, + }), + ).rejects.toBeInstanceOf(ExtensionLoomAuthorizationError); + expect(importLoomCsvForUserMock).not.toHaveBeenCalled(); + }); + + it("rejects Pro organization members", async () => { + getEffectiveOrganizationRoleMock.mockReturnValueOnce("member"); + canManageOrganizationSettingsMock.mockReturnValueOnce(false); + limitMock.mockResolvedValueOnce([ + { + user: { id: "user-1", email: "member@example.com" }, + ownerId: "owner-1", + memberRole: "member", + }, + ]); + + await expect( + authorizeExtensionLoomImport({ + userId: "user-1" as never, + organizationId: "org-1" as never, + }), + ).rejects.toBeInstanceOf(ExtensionLoomAuthorizationError); + expect(importLoomCsvForUserMock).not.toHaveBeenCalled(); + }); + + it.each(["owner", "admin"])("accepts a Pro organization %s", async (role) => { + getEffectiveOrganizationRoleMock.mockReturnValueOnce(role); + canManageOrganizationSettingsMock.mockReturnValue(true); + limitMock.mockResolvedValue([ + { + user: { id: "user-1", email: "admin@example.com" }, + ownerId: "owner-1", + memberRole: "admin", + }, + ]); + + await expect( + authorizeExtensionLoomImport({ + userId: "user-1" as never, + organizationId: "org-1" as never, + }), + ).resolves.toEqual({ + user: { id: "user-1", email: "admin@example.com" }, + isPro: true, + }); + }); + + it("preserves a Space warning after starting the canonical video", async () => { + limitMock.mockResolvedValueOnce([]); + const warning = "Import started, but it could not be added to a space."; + importLoomCsvForUserMock.mockResolvedValueOnce({ + success: true, + importedCount: 1, + failedCount: 0, + results: [{ success: true, videoId: "video-started", error: warning }], + }); + + const result = await importExtensionLoomRow({ + organizationId: "org-1" as never, + row: { + ...validRow, + loomUrl: `https://loom.com/EMBED/${"A".repeat(32)}/?source=fixture`, + spaceName: "Team knowledge", + }, + user: { id: "user-1" } as never, + }); + + expect(result).toEqual({ + success: true, + videoId: "video-started", + error: warning, + }); + expect(importLoomCsvForUserMock).toHaveBeenCalledWith( + expect.objectContaining({ + rows: [{ ...validRow, spaceName: "Team knowledge" }], + }), + ); + }); + + it("returns an existing video without provisioning or restarting it", async () => { + limitMock.mockResolvedValueOnce([{ videoId: "video-existing" }]); + + const result = await importExtensionLoomRow({ + organizationId: "org-1" as never, + row: validRow, + user: { id: "user-1" } as never, + }); + + expect(result).toEqual({ + success: true, + videoId: "video-existing", + error: "Already imported; owner and Space membership are unchanged.", + existing: true, + }); + expect(importLoomCsvForUserMock).not.toHaveBeenCalled(); + }); + + it("surfaces unknown workflow-start outcomes without private errors", async () => { + limitMock.mockResolvedValueOnce([]).mockResolvedValueOnce([]); + importLoomCsvForUserMock.mockResolvedValueOnce({ + success: false, + results: [ + { + rowNumber: 1, + userEmail: validRow.userEmail, + success: false, + error: "Failed to start this import.", + }, + ], + importedCount: 0, + failedCount: 1, + }); + + const result = await importExtensionLoomRow({ + organizationId: "org-1" as never, + row: validRow, + user: { id: "user-1" } as never, + }); + + expect(result).toEqual({ + success: false, + error: + "Import status is unknown. Check your Cap library before retrying.", + uncertain: true, + }); + }); + + it("keeps persisted sources uncertain when workflow start fails", async () => { + limitMock + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ videoId: "video-persisted" }]); + importLoomCsvForUserMock.mockResolvedValueOnce({ + success: false, + results: [ + { + rowNumber: 1, + userEmail: validRow.userEmail, + success: false, + error: "Failed to start this import.", + }, + ], + importedCount: 0, + failedCount: 1, + }); + + const result = await importExtensionLoomRow({ + organizationId: "org-1" as never, + row: validRow, + user: { id: "user-1" } as never, + }); + + expect(result).toEqual({ + success: false, + videoId: "video-persisted", + error: + "Import status is unknown. Check your Cap library before retrying.", + uncertain: true, + }); + }); +}); diff --git a/apps/web/app/api/extension/import-loom/route.ts b/apps/web/app/api/extension/import-loom/route.ts new file mode 100644 index 0000000000..4fe2aebe63 --- /dev/null +++ b/apps/web/app/api/extension/import-loom/route.ts @@ -0,0 +1,177 @@ +import { + CurrentUser, + HttpAuthMiddleware, + Organisation, + Video, +} from "@cap/web-domain"; +import { + HttpApi, + HttpApiBuilder, + HttpApiEndpoint, + HttpApiError, + HttpApiGroup, +} from "@effect/platform"; +import { Effect, Layer, Schema } from "effect"; +import { + authorizeExtensionLoomImport, + ExtensionLoomAuthorizationError, + type ExtensionLoomImportResponse, + getExtensionLoomImportConfig, + importExtensionLoomRow, + MAX_EXTENSION_LOOM_EMAIL_LENGTH, + MAX_EXTENSION_LOOM_ROW_NUMBER, + MAX_EXTENSION_LOOM_ROWS, + MAX_EXTENSION_LOOM_SPACE_LENGTH, + MAX_EXTENSION_LOOM_URL_LENGTH, + validateExtensionLoomRow, +} from "@/lib/extension-loom-import"; +import { apiToHandler } from "@/lib/server"; + +export const dynamic = "force-dynamic"; +export const maxDuration = 120; + +const ImportRow = Schema.Struct({ + rowNumber: Schema.Int.pipe( + Schema.greaterThanOrEqualTo(1), + Schema.lessThanOrEqualTo(MAX_EXTENSION_LOOM_ROW_NUMBER), + ), + loomUrl: Schema.String.pipe(Schema.maxLength(MAX_EXTENSION_LOOM_URL_LENGTH)), + userEmail: Schema.String.pipe( + Schema.maxLength(MAX_EXTENSION_LOOM_EMAIL_LENGTH), + ), + spaceName: Schema.optional( + Schema.String.pipe(Schema.maxLength(MAX_EXTENSION_LOOM_SPACE_LENGTH)), + ), +}); + +const ImportPayload = Schema.Struct({ + organizationId: Organisation.OrganisationId, + row: ImportRow, +}); + +const Config = Schema.Struct({ + user: Schema.Struct({ + id: Schema.String, + email: Schema.String, + }), + organizations: Schema.Array( + Schema.Struct({ + id: Schema.String, + name: Schema.String, + canImport: Schema.Boolean, + }), + ), + activeOrganizationId: Schema.String, + isPro: Schema.Boolean, + defaultPublic: Schema.Boolean, + maxRows: Schema.Literal(MAX_EXTENSION_LOOM_ROWS), +}); + +const ImportResponse = Schema.Struct({ + success: Schema.Boolean, + videoId: Schema.optional(Video.VideoId), + error: Schema.optional(Schema.String), + existing: Schema.optional(Schema.Boolean), + uncertain: Schema.optional(Schema.Boolean), +}); + +class Api extends HttpApi.make("ExtensionLoomImportApi").add( + HttpApiGroup.make("loomImport") + .add( + HttpApiEndpoint.get("getConfig")`/api/extension/import-loom` + .middleware(HttpAuthMiddleware) + .addSuccess(Config) + .addError(HttpApiError.InternalServerError), + ) + .add( + HttpApiEndpoint.post("importRow")`/api/extension/import-loom` + .middleware(HttpAuthMiddleware) + .setPayload(ImportPayload) + .addSuccess(ImportResponse) + .addError(HttpApiError.BadRequest) + .addError(HttpApiError.Forbidden) + .addError(HttpApiError.InternalServerError), + ), +) {} + +const internalError = (cause: unknown) => + Effect.logError(cause).pipe( + Effect.andThen(Effect.fail(new HttpApiError.InternalServerError())), + ); + +const importRowError = ( + cause: unknown, +): Effect.Effect< + never, + HttpApiError.Forbidden | HttpApiError.InternalServerError +> => { + if (cause instanceof ExtensionLoomAuthorizationError) { + return Effect.fail(new HttpApiError.Forbidden()); + } + return internalError(cause); +}; + +const getConfig = () => + Effect.gen(function* () { + const user = yield* CurrentUser; + return yield* Effect.tryPromise({ + try: () => + getExtensionLoomImportConfig({ + userId: user.id, + activeOrganizationId: user.activeOrganizationId, + }), + catch: (cause) => cause, + }); + }).pipe(Effect.catchAll(internalError)); + +type ImportRowEffect = Effect.Effect< + ExtensionLoomImportResponse, + | HttpApiError.BadRequest + | HttpApiError.Forbidden + | HttpApiError.InternalServerError, + CurrentUser +>; + +const importRow = ({ + payload, +}: { + payload: Schema.Schema.Type; +}): ImportRowEffect => { + const validationError = validateExtensionLoomRow(payload.row); + if (validationError) return Effect.fail(new HttpApiError.BadRequest()); + + return Effect.gen(function* () { + const currentUser = yield* CurrentUser; + const authorization = yield* Effect.tryPromise({ + try: () => + authorizeExtensionLoomImport({ + userId: currentUser.id, + organizationId: payload.organizationId, + }), + catch: (cause) => cause, + }); + + return yield* Effect.tryPromise({ + try: () => + importExtensionLoomRow({ + organizationId: payload.organizationId, + row: payload.row, + user: authorization.user, + }), + catch: (cause) => cause, + }); + }).pipe(Effect.catchAll(importRowError)); +}; + +const ApiLive = HttpApiBuilder.api(Api).pipe( + Layer.provide( + HttpApiBuilder.group(Api, "loomImport", (handlers) => + handlers.handle("getConfig", getConfig).handle("importRow", importRow), + ), + ), +); + +const handler = apiToHandler(ApiLive); + +export const GET = handler; +export const POST = handler; diff --git a/apps/web/lib/extension-loom-import.ts b/apps/web/lib/extension-loom-import.ts new file mode 100644 index 0000000000..3aed2b7bf0 --- /dev/null +++ b/apps/web/lib/extension-loom-import.ts @@ -0,0 +1,410 @@ +import "server-only"; + +import { db } from "@cap/database"; +import { + importedVideos, + organizationMembers, + organizations, + users, + videos, +} from "@cap/database/schema"; +import { serverEnv } from "@cap/env"; +import { userIsPro } from "@cap/utils"; +import type { Organisation, User, Video } from "@cap/web-domain"; +import { and, eq, isNull, or } from "drizzle-orm"; +import { importLoomCsvForUser } from "@/lib/loom-import"; +import { + canManageOrganizationSettings, + getEffectiveOrganizationRole, +} from "@/lib/permissions/roles"; + +export const MAX_EXTENSION_LOOM_ROWS = 500; +export const MAX_EXTENSION_LOOM_ROW_NUMBER = 50_000; +export const MAX_EXTENSION_LOOM_URL_LENGTH = 2048; +export const MAX_EXTENSION_LOOM_EMAIL_LENGTH = 254; +export const MAX_EXTENSION_LOOM_SPACE_LENGTH = 255; + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const SHARE_ID_PATTERN = /^[0-9a-f]{32}$/i; +const EMBED_ID_PATTERN = /^[0-9a-f]{32}$/i; + +const hasControlCharacter = (value: string) => + [...value].some((character) => { + const code = character.charCodeAt(0); + return code < 32 || (code >= 127 && code <= 159); + }); + +export class ExtensionLoomAuthorizationError extends Error {} + +export type ExtensionLoomImportRow = { + rowNumber: number; + loomUrl: string; + userEmail: string; + spaceName?: string; +}; + +export type ExtensionLoomOrganization = { + id: Organisation.OrganisationId; + name: string; + canImport: boolean; +}; + +export type ExtensionLoomImportConfig = { + user: { id: User.UserId; email: string }; + organizations: ExtensionLoomOrganization[]; + activeOrganizationId: Organisation.OrganisationId | ""; + isPro: boolean; + defaultPublic: boolean; + maxRows: typeof MAX_EXTENSION_LOOM_ROWS; +}; + +export type ExtensionLoomImportResponse = { + success: boolean; + videoId?: Video.VideoId; + error?: string; + existing?: boolean; + uncertain?: boolean; +}; + +export const canonicalizeExtensionLoomUrl = ( + loomUrl: string, +): string | undefined => { + if ( + typeof loomUrl !== "string" || + loomUrl.length === 0 || + loomUrl.length > MAX_EXTENSION_LOOM_URL_LENGTH || + hasControlCharacter(loomUrl) + ) { + return undefined; + } + + let url: URL; + try { + url = new URL(loomUrl.trim()); + } catch { + return undefined; + } + + const hostname = url.hostname.toLowerCase(); + const hostAllowed = hostname === "loom.com" || hostname === "www.loom.com"; + const path = url.pathname.split("/"); + const trailingSlash = path.at(-1) === ""; + const validPath = + (path.length === 3 || (path.length === 4 && trailingSlash)) && + path[0] === "" && + path[1] !== undefined && + path[2] !== undefined; + const kind = validPath ? path[1]?.toLowerCase() : undefined; + const id = validPath ? path[2] : undefined; + const validShare = + kind === "share" && id !== undefined && SHARE_ID_PATTERN.test(id); + const validEmbed = + kind === "embed" && id !== undefined && EMBED_ID_PATTERN.test(id); + + if ( + url.protocol !== "https:" || + url.username !== "" || + url.password !== "" || + url.port !== "" || + !hostAllowed || + (!validShare && !validEmbed) || + id === undefined + ) { + return undefined; + } + + return `https://www.loom.com/share/${id.toLowerCase()}`; +}; + +export const validateExtensionLoomRow = ( + row: ExtensionLoomImportRow, +): string | undefined => { + if ( + !Number.isInteger(row.rowNumber) || + row.rowNumber < 1 || + row.rowNumber > MAX_EXTENSION_LOOM_ROW_NUMBER + ) { + return "Row number must be between 1 and 50000."; + } + + if ( + typeof row.loomUrl !== "string" || + row.loomUrl.length === 0 || + row.loomUrl.length > MAX_EXTENSION_LOOM_URL_LENGTH + ) { + return "Loom URL is missing or too long."; + } + + if (!canonicalizeExtensionLoomUrl(row.loomUrl)) { + return "Loom URL must be a valid Loom share or embed URL."; + } + + if ( + typeof row.userEmail !== "string" || + row.userEmail.length === 0 || + row.userEmail.length > MAX_EXTENSION_LOOM_EMAIL_LENGTH || + hasControlCharacter(row.userEmail) || + !EMAIL_PATTERN.test(row.userEmail) + ) { + return "User email is missing or invalid."; + } + + if ( + row.spaceName !== undefined && + (typeof row.spaceName !== "string" || + row.spaceName.length > MAX_EXTENSION_LOOM_SPACE_LENGTH || + hasControlCharacter(row.spaceName)) + ) { + return "Space name is too long."; + } + + return undefined; +}; + +export async function getExtensionLoomImportConfig({ + userId, + activeOrganizationId, +}: { + userId: User.UserId; + activeOrganizationId: Organisation.OrganisationId; +}): Promise { + const database = db(); + const [user] = await database + .select() + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + if (!user) throw new Error("Authenticated user was not found."); + + const organizationRows = await database + .select({ + id: organizations.id, + name: organizations.name, + ownerId: organizations.ownerId, + memberRole: organizationMembers.role, + }) + .from(organizations) + .leftJoin( + organizationMembers, + and( + eq(organizationMembers.organizationId, organizations.id), + eq(organizationMembers.userId, userId), + ), + ) + .where( + and( + isNull(organizations.tombstoneAt), + or( + eq(organizations.ownerId, userId), + eq(organizationMembers.userId, userId), + ), + ), + ) + .orderBy(organizations.name); + + const isPro = userIsPro(user); + const organizationsForUser = organizationRows.map((organization) => { + const role = getEffectiveOrganizationRole({ + userId, + ownerId: organization.ownerId, + memberRole: organization.memberRole, + }); + + return { + id: organization.id, + name: organization.name, + canImport: isPro && canManageOrganizationSettings(role), + }; + }); + const active = organizationsForUser.some( + (organization) => organization.id === activeOrganizationId, + ) + ? activeOrganizationId + : (organizationsForUser[0]?.id ?? ""); + + return { + user: { id: user.id, email: user.email }, + organizations: organizationsForUser, + activeOrganizationId: active, + isPro, + defaultPublic: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, + maxRows: MAX_EXTENSION_LOOM_ROWS, + }; +} + +export async function findExistingExtensionLoomVideo({ + organizationId, + loomUrl, +}: { + organizationId: Organisation.OrganisationId; + loomUrl: string; +}): Promise { + const path = new URL(loomUrl).pathname.replace(/\/$/, "").split("/"); + const loomVideoId = path[path.length - 1]; + if (!loomVideoId) return undefined; + + const [existing] = await db() + .select({ videoId: videos.id }) + .from(importedVideos) + .leftJoin( + videos, + and( + eq(videos.id, importedVideos.id), + eq(videos.orgId, importedVideos.orgId), + ), + ) + .where( + and( + eq(importedVideos.orgId, organizationId), + eq(importedVideos.source, "loom"), + eq(importedVideos.sourceId, loomVideoId), + ), + ) + .limit(1); + + return existing?.videoId ?? undefined; +} + +export async function authorizeExtensionLoomImport({ + userId, + organizationId, +}: { + userId: User.UserId; + organizationId: Organisation.OrganisationId; +}): Promise<{ user: typeof users.$inferSelect; isPro: boolean }> { + const [result] = await db() + .select({ + user: users, + ownerId: organizations.ownerId, + memberRole: organizationMembers.role, + }) + .from(users) + .innerJoin(organizations, eq(organizations.id, organizationId)) + .leftJoin( + organizationMembers, + and( + eq(organizationMembers.organizationId, organizations.id), + eq(organizationMembers.userId, userId), + ), + ) + .where( + and( + eq(users.id, userId), + eq(organizations.id, organizationId), + isNull(organizations.tombstoneAt), + ), + ) + .limit(1); + + if (!result) { + throw new ExtensionLoomAuthorizationError(); + } + + const isPro = userIsPro(result.user); + const role = getEffectiveOrganizationRole({ + userId, + ownerId: result.ownerId, + memberRole: result.memberRole, + }); + if (!isPro || !canManageOrganizationSettings(role)) { + throw new ExtensionLoomAuthorizationError(); + } + + return { user: result.user, isPro }; +} + +export async function importExtensionLoomRow({ + organizationId, + row, + user, +}: { + organizationId: Organisation.OrganisationId; + row: ExtensionLoomImportRow; + user: typeof users.$inferSelect; +}): Promise { + const canonicalLoomUrl = canonicalizeExtensionLoomUrl(row.loomUrl); + if (!canonicalLoomUrl) { + return { + success: false, + error: "Loom URL must be a valid Loom share or embed URL.", + }; + } + + const existing = await findExistingExtensionLoomVideo({ + organizationId, + loomUrl: canonicalLoomUrl, + }); + if (existing) { + return { + success: true, + videoId: existing, + error: "Already imported; owner and Space membership are unchanged.", + existing: true, + }; + } + + const result = await importLoomCsvForUser({ + rows: [{ ...row, loomUrl: canonicalLoomUrl }], + orgId: organizationId, + user, + }); + const rowResult = result.results[0]; + + if (rowResult?.success && rowResult.videoId) { + return { + success: true, + videoId: rowResult.videoId, + error: rowResult.error, + }; + } + + if (rowResult?.error === "Failed to start this import.") { + const persistedVideo = await findExistingExtensionLoomVideo({ + organizationId, + loomUrl: canonicalLoomUrl, + }); + return { + success: false, + error: + "Import status is unknown. Check your Cap library before retrying.", + ...(persistedVideo ? { videoId: persistedVideo } : {}), + uncertain: true, + }; + } + + const raceWinner = await findExistingExtensionLoomVideo({ + organizationId, + loomUrl: canonicalLoomUrl, + }); + if (raceWinner) { + if (rowResult?.error === "This Loom video has already been imported.") { + return { + success: true, + videoId: raceWinner, + error: "Already imported; owner and Space membership are unchanged.", + existing: true, + }; + } + return { + success: false, + videoId: raceWinner, + error: + "Import status is unknown. Check your Cap library before retrying.", + uncertain: true, + }; + } + + if (rowResult?.error === "This Loom video has already been imported.") { + return { + success: false, + error: "Already imported; owner and Space membership are unchanged.", + existing: true, + }; + } + + return { + success: false, + error: rowResult?.error ?? "Could not start this Loom import.", + }; +} From fea0be328d2e0d57bd97bf194455bae36215856d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:43:17 +0100 Subject: [PATCH 3/8] feat: queue durable Loom import batches --- .../__tests__/unit/loom-batch-import.test.ts | 725 +++++++++ .../api/extension/import-loom/batch/route.ts | 257 +++ apps/web/lib/loom-batch-import.ts | 1387 +++++++++++++++++ apps/web/lib/loom-batch.ts | 269 ++++ apps/web/workflows/import-loom-batch.ts | 135 ++ 5 files changed, 2773 insertions(+) create mode 100644 apps/web/__tests__/unit/loom-batch-import.test.ts create mode 100644 apps/web/app/api/extension/import-loom/batch/route.ts create mode 100644 apps/web/lib/loom-batch-import.ts create mode 100644 apps/web/lib/loom-batch.ts create mode 100644 apps/web/workflows/import-loom-batch.ts diff --git a/apps/web/__tests__/unit/loom-batch-import.test.ts b/apps/web/__tests__/unit/loom-batch-import.test.ts new file mode 100644 index 0000000000..ab237f8bbe --- /dev/null +++ b/apps/web/__tests__/unit/loom-batch-import.test.ts @@ -0,0 +1,725 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const databaseMocks = vi.hoisted(() => { + const selectResults: unknown[] = []; + const databaseTarget = { + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + from: vi.fn(), + leftJoin: vi.fn(), + where: vi.fn(), + limit: vi.fn(), + for: vi.fn(), + values: vi.fn(), + set: vi.fn(), + transaction: vi.fn(), + }; + const then = vi.fn(); + const database = new Proxy(databaseTarget, { + get(target, property, receiver) { + if (property === "then") return then; + return Reflect.get(target, property, receiver); + }, + }); + database.select.mockImplementation(() => database); + database.insert.mockImplementation(() => database); + database.update.mockImplementation(() => database); + database.from.mockImplementation(() => database); + database.leftJoin.mockImplementation(() => database); + database.where.mockImplementation(() => database); + database.limit.mockImplementation(() => database); + database.values.mockResolvedValue(undefined); + database.set.mockImplementation(() => database); + database.for.mockImplementation(() => Promise.resolve(selectResults.shift())); + database.transaction.mockImplementation( + (callback: (transaction: typeof database) => unknown) => callback(database), + ); + then.mockImplementation( + ( + resolve: (value: unknown) => unknown, + reject: (reason: unknown) => unknown, + ) => Promise.resolve(selectResults.shift()).then(resolve, reject), + ); + return { database, selectResults, then }; +}); + +const extensionMocks = vi.hoisted(() => ({ + authorize: vi.fn(), +})); + +const loomImportMocks = vi.hoisted(() => ({ + download: vi.fn(), +})); + +const provisioningMocks = vi.hoisted(() => ({ + provision: vi.fn(), +})); + +const workflowApiMocks = vi.hoisted(() => ({ + start: vi.fn(), +})); + +vi.mock("@cap/database", () => ({ + db: vi.fn(() => databaseMocks.database), +})); + +vi.mock("@cap/env", () => ({ + serverEnv: vi.fn(() => ({ CAP_VIDEOS_DEFAULT_PUBLIC: false })), +})); + +vi.mock("@cap/web-backend", () => ({ + Storage: {}, +})); + +vi.mock("server-only", () => ({})); + +vi.mock("@/lib/extension-loom-import", () => ({ + authorizeExtensionLoomImport: extensionMocks.authorize, + canonicalizeExtensionLoomUrl: (value: string) => { + const match = value + .trim() + .match( + /^https:\/\/(?:www\.)?loom\.com\/(?:share|embed)\/([0-9a-f]{32})\/?$/i, + ); + return match?.[1] + ? `https://www.loom.com/share/${match[1].toLowerCase()}` + : undefined; + }, + ExtensionLoomAuthorizationError: class extends Error {}, + validateExtensionLoomRow: () => undefined, +})); + +vi.mock("@/lib/loom-import", () => ({ + downloadLoomVideo: loomImportMocks.download, +})); + +vi.mock("@/lib/organization-provisioning", () => ({ + provisionOrganizationInvitee: provisioningMocks.provision, +})); + +vi.mock("@/lib/workflow-runtime", () => ({ + runWorkflowPromise: vi.fn(), +})); + +vi.mock("workflow/api", () => ({ + start: workflowApiMocks.start, +})); + +vi.mock("@/workflows/import-loom-video", () => ({ + importLoomVideoWorkflow: vi.fn(), +})); + +const firstLoomId = "0123456789abcdef0123456789abcdef"; +const secondLoomId = "fedcba9876543210fedcba9876543210"; +const thirdLoomId = "11111111111111111111111111111111"; +const fourthLoomId = "22222222222222222222222222222222"; +const fifthLoomId = "33333333333333333333333333333333"; + +const request = { + requestId: "019e312d-21ae-7a6f-8c4f-b24a34b0c54d", + expectedUserId: "user-1", + expectedDefaultPublic: false, + organizationId: "organization-1", + rows: [ + { + rowNumber: 2, + loomUrl: `https://loom.com/share/${firstLoomId}`, + userEmail: " Owner@Example.com ", + spaceName: " Product demos ", + }, + { + rowNumber: 3, + loomUrl: `https://www.loom.com/embed/${firstLoomId.toUpperCase()}`, + userEmail: "duplicate@example.com", + }, + { + rowNumber: 4, + loomUrl: `https://www.loom.com/share/${secondLoomId}`, + userEmail: "second@example.com", + spaceName: " ", + }, + ], + source: { + workspace: " Example workspace ", + from: "2026-01-01", + to: "2026-09-02", + totalRows: 4, + omittedRows: 1, + }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + databaseMocks.selectResults.length = 0; + databaseMocks.database.select.mockImplementation( + () => databaseMocks.database, + ); + databaseMocks.database.insert.mockImplementation( + () => databaseMocks.database, + ); + databaseMocks.database.update.mockImplementation( + () => databaseMocks.database, + ); + databaseMocks.database.from.mockImplementation(() => databaseMocks.database); + databaseMocks.database.leftJoin.mockImplementation( + () => databaseMocks.database, + ); + databaseMocks.database.where.mockImplementation(() => databaseMocks.database); + databaseMocks.database.limit.mockImplementation(() => databaseMocks.database); + databaseMocks.database.values.mockResolvedValue(undefined); + databaseMocks.database.set.mockImplementation(() => databaseMocks.database); + databaseMocks.database.for.mockImplementation(() => + Promise.resolve(databaseMocks.selectResults.shift()), + ); + databaseMocks.database.transaction.mockImplementation( + (callback: (transaction: typeof databaseMocks.database) => unknown) => + callback(databaseMocks.database), + ); + databaseMocks.then.mockImplementation( + ( + resolve: (value: unknown) => unknown, + reject: (reason: unknown) => unknown, + ) => + Promise.resolve(databaseMocks.selectResults.shift()).then( + resolve, + reject, + ), + ); +}); + +describe("Loom batch request normalization", () => { + it("canonicalizes and deduplicates Loom IDs while preserving source totals", async () => { + const { normalizeLoomBatchRequest } = await import( + "@/lib/loom-batch-import" + ); + const payload = normalizeLoomBatchRequest(request, "user-1"); + + expect(payload.rows).toEqual([ + { + rowNumber: 2, + loomUrl: `https://www.loom.com/share/${firstLoomId}`, + loomVideoId: firstLoomId, + userEmail: "owner@example.com", + spaceName: "Product demos", + }, + { + rowNumber: 4, + loomUrl: `https://www.loom.com/share/${secondLoomId}`, + loomVideoId: secondLoomId, + userEmail: "second@example.com", + spaceName: undefined, + }, + ]); + expect(payload.source).toEqual({ + workspace: "Example workspace", + from: "2026-01-01", + to: "2026-09-02", + totalRows: 4, + omittedRows: 2, + }); + expect(payload.defaultPublic).toBe(false); + }); + + it("derives stable IDs and request hashes from the complete normalized request", async () => { + const { getLoomBatchOperationId, normalizeLoomBatchRequest } = await import( + "@/lib/loom-batch-import" + ); + const first = normalizeLoomBatchRequest(request, "user-1"); + const second = normalizeLoomBatchRequest(request, "user-1"); + + expect(first.requestHash).toHaveLength(64); + expect(second.requestHash).toBe(first.requestHash); + expect( + getLoomBatchOperationId("user-1", "organization-1", request.requestId), + ).toHaveLength(15); + }); + + it("rejects identity and source metadata drift before enqueue", async () => { + const { normalizeLoomBatchRequest } = await import( + "@/lib/loom-batch-import" + ); + + expect(() => normalizeLoomBatchRequest(request, "user-2")).toThrow(); + expect(() => + normalizeLoomBatchRequest( + { + ...request, + source: { ...request.source, totalRows: 5 }, + }, + "user-1", + ), + ).toThrow("Loom source metadata is invalid."); + }); + + it("rejects null nested payloads and preserves monotonic progress", async () => { + const { + initialLoomBatchProgress, + isLoomBatchChildPayload, + isLoomBatchPayload, + mergeLoomBatchProgress, + } = await import("@/lib/loom-batch"); + const { normalizeLoomBatchRequest } = await import( + "@/lib/loom-batch-import" + ); + const payload = normalizeLoomBatchRequest(request, "user-1"); + const current = { + ...initialLoomBatchProgress(payload), + phase: "dispatching" as const, + preparedRows: 2, + dispatchedRows: 2, + }; + const stale = { + ...current, + preparedRows: 1, + dispatchedRows: 1, + }; + + expect(isLoomBatchPayload({ ...payload, source: null })).toBe(false); + expect( + isLoomBatchChildPayload({ + type: "loom_child", + version: 1, + parentId: "parent", + organizationId: "organization-1", + requestedByUserId: "user-1", + row: null, + }), + ).toBe(false); + expect( + isLoomBatchChildPayload({ + type: "loom_child", + version: 1, + parentId: "parent", + organizationId: "organization-1", + requestedByUserId: "user-1", + row: { + rowNumber: 2, + loomVideoId: firstLoomId, + userEmail: "owner@example.com", + }, + dispatch: null, + }), + ).toBe(false); + expect(mergeLoomBatchProgress(current, stale)).toBe(current); + }); +}); + +describe("Loom batch durable operation behavior", () => { + it("restarts a persisted queued parent after an ambiguous initial start", async () => { + const { normalizeLoomBatchRequest, startLoomBatchImport } = await import( + "@/lib/loom-batch-import" + ); + const payload = normalizeLoomBatchRequest(request, "user-1"); + const startBatchWorkflow = vi + .fn() + .mockRejectedValueOnce(new Error("ambiguous submit")) + .mockResolvedValueOnce(undefined); + databaseMocks.selectResults.push([{ id: "organization-1" }], [], []); + + await expect( + startLoomBatchImport({ + request, + currentUserId: "user-1" as never, + startBatchWorkflow, + }), + ).rejects.toThrow("ambiguous submit"); + databaseMocks.selectResults.push( + [{ id: "organization-1" }], + [ + { + userId: "user-1", + resourceId: "organization-1", + payload, + state: "queued", + }, + ], + ); + + const receipt = await startLoomBatchImport({ + request, + currentUserId: "user-1" as never, + startBatchWorkflow, + }); + + expect(startBatchWorkflow).toHaveBeenCalledTimes(2); + expect(startBatchWorkflow).toHaveBeenLastCalledWith(receipt.operationId); + expect(databaseMocks.database.insert).toHaveBeenCalledTimes(1); + expect(databaseMocks.database.update).not.toHaveBeenCalled(); + }); + + it("rejects reuse of the same request ID with a different payload", async () => { + const { + getLoomBatchOperationId, + normalizeLoomBatchRequest, + startLoomBatchImport, + } = await import("@/lib/loom-batch-import"); + const payload = normalizeLoomBatchRequest(request, "user-1"); + const conflictingPayload = { + ...payload, + requestHash: "f".repeat(64), + }; + databaseMocks.selectResults.push( + [{ id: "organization-1" }], + [ + { + userId: "user-1", + resourceId: "organization-1", + payload: conflictingPayload, + state: "queued", + }, + ], + ); + const startBatchWorkflow = vi.fn(); + + await expect( + startLoomBatchImport({ + request, + currentUserId: "user-1" as never, + startBatchWorkflow, + }), + ).rejects.toThrow( + "Request ID was already used for a different Loom batch.", + ); + expect(startBatchWorkflow).not.toHaveBeenCalled(); + expect(databaseMocks.database.insert).not.toHaveBeenCalled(); + expect( + getLoomBatchOperationId("user-1", "organization-1", request.requestId), + ).toHaveLength(15); + }); + + it("replays a saved child without repeating preparation side effects", async () => { + const { + dispatchLoomBatchChild, + getLoomBatchChildOperationId, + getLoomBatchOperationId, + normalizeLoomBatchRequest, + prepareLoomBatchRow, + } = await import("@/lib/loom-batch-import"); + const payload = normalizeLoomBatchRequest(request, "user-1"); + const row = payload.rows[0]; + expect(row).toBeDefined(); + if (!row) return; + const parentId = getLoomBatchOperationId( + "user-1", + "organization-1", + request.requestId, + ); + const childOperationId = getLoomBatchChildOperationId( + parentId, + row.loomVideoId, + ); + const childOperation = { + id: childOperationId, + userId: "user-1", + resourceId: "organization-1", + state: "queued", + payload: { + type: "loom_child", + version: 1, + parentId, + organizationId: "organization-1", + requestedByUserId: "user-1", + row: { + rowNumber: row.rowNumber, + loomVideoId: row.loomVideoId, + userEmail: row.userEmail, + spaceName: row.spaceName, + }, + dispatch: { + videoId: "video-1", + ownerId: "owner-1", + rawFileKey: "owner-1/video-1/raw-upload.mp4", + bucketId: null, + loomVideoId: row.loomVideoId, + }, + }, + result: null, + resultResourceId: "video-1", + errorCode: null, + errorMessage: null, + }; + databaseMocks.selectResults.push([childOperation]); + + await expect(prepareLoomBatchRow(parentId, payload, row)).resolves.toEqual({ + childOperationId, + state: "dispatch", + }); + databaseMocks.selectResults.push([childOperation]); + await expect(dispatchLoomBatchChild(childOperationId)).resolves.toBe(true); + + expect(workflowApiMocks.start).toHaveBeenCalledWith(expect.any(Function), [ + expect.objectContaining({ + agentOperationId: childOperationId, + videoId: "video-1", + }), + ]); + expect(loomImportMocks.download).not.toHaveBeenCalled(); + expect(provisioningMocks.provision).not.toHaveBeenCalled(); + expect(databaseMocks.database.transaction).not.toHaveBeenCalled(); + expect(databaseMocks.database.insert).not.toHaveBeenCalled(); + }); + + it("continues a running parent from its durable progress", async () => { + const { claimLoomBatchOperation, normalizeLoomBatchRequest } = await import( + "@/lib/loom-batch-import" + ); + const payload = normalizeLoomBatchRequest(request, "user-1"); + const progress = { + phase: "dispatching", + totalRows: 2, + preparedRows: 1, + dispatchedRows: 1, + readyRows: 0, + failedRows: 0, + uncertainRows: 0, + currentRowNumber: 2, + }; + const operation = { + id: "parent-operation", + userId: "user-1", + resourceId: "organization-1", + kind: "import_loom", + state: "running", + payload, + result: progress, + }; + databaseMocks.selectResults.push([operation], [operation]); + + await expect(claimLoomBatchOperation("parent-operation")).resolves.toEqual({ + payload, + progress, + }); + expect(databaseMocks.database.update).not.toHaveBeenCalled(); + expect(extensionMocks.authorize).toHaveBeenCalledWith({ + userId: "user-1", + organizationId: "organization-1", + }); + }); + + it("combines durable child states with unprepared rows in status counts", async () => { + const { + getLoomBatchChildOperationId, + getLoomBatchOperationId, + getLoomBatchStatus, + normalizeLoomBatchRequest, + } = await import("@/lib/loom-batch-import"); + const statusRequest = { + ...request, + rows: [ + { + rowNumber: 2, + loomUrl: `https://loom.com/share/${firstLoomId}`, + userEmail: "owner@example.com", + spaceName: "Product demos", + }, + { + rowNumber: 3, + loomUrl: `https://loom.com/share/${secondLoomId}`, + userEmail: "second@example.com", + }, + { + rowNumber: 4, + loomUrl: `https://loom.com/share/${thirdLoomId}`, + userEmail: "third@example.com", + }, + { + rowNumber: 5, + loomUrl: `https://loom.com/share/${fourthLoomId}`, + userEmail: "fourth@example.com", + }, + { + rowNumber: 6, + loomUrl: `https://loom.com/share/${fifthLoomId}`, + userEmail: "fifth@example.com", + }, + ], + source: { ...request.source, totalRows: 5, omittedRows: 0 }, + }; + const payload = normalizeLoomBatchRequest(statusRequest, "user-1"); + const operationId = getLoomBatchOperationId( + "user-1", + "organization-1", + request.requestId, + ); + const childPayload = (index: number) => { + const row = payload.rows[index]; + expect(row).toBeDefined(); + if (!row) throw new Error("Missing fixture row."); + return { + type: "loom_child", + version: 1, + parentId: operationId, + organizationId: "organization-1", + requestedByUserId: "user-1", + row: { + rowNumber: row.rowNumber, + loomVideoId: row.loomVideoId, + userEmail: row.userEmail, + spaceName: row.spaceName, + }, + }; + }; + const childRecord = ( + index: number, + state: "queued" | "running" | "succeeded" | "failed", + overrides: Record = {}, + ) => { + const row = payload.rows[index]; + if (!row) throw new Error("Missing fixture row."); + return { + id: getLoomBatchChildOperationId(operationId, row.loomVideoId), + userId: "user-1", + resourceId: "organization-1", + state, + payload: childPayload(index), + result: null, + resultResourceId: null, + errorCode: null, + errorMessage: null, + videoId: null, + uploadPhase: null, + uploadError: null, + ...overrides, + }; + }; + const now = new Date("2026-09-02T12:00:00.000Z"); + databaseMocks.selectResults.push( + [ + { + id: operationId, + userId: "user-1", + kind: "import_loom", + resourceId: "organization-1", + state: "running", + payload, + result: { + phase: "dispatching", + totalRows: 5, + preparedRows: 4, + dispatchedRows: 2, + readyRows: 1, + failedRows: 0, + uncertainRows: 1, + currentRowNumber: 5, + }, + errorMessage: null, + createdAt: now, + updatedAt: now, + completedAt: null, + }, + ], + [ + { + recorded: 4, + queued: 1, + processing: 1, + ready: 1, + failed: 0, + uncertain: 1, + }, + ], + [ + childRecord(0, "queued"), + childRecord(1, "running"), + childRecord(2, "succeeded", { + result: { videoId: "video-ready", existing: true }, + resultResourceId: "video-ready", + videoId: "video-ready", + }), + childRecord(3, "succeeded", { + result: { videoId: "deleted-video" }, + resultResourceId: "deleted-video", + }), + ], + ); + + const status = await getLoomBatchStatus({ + operationId, + organizationId: "organization-1" as never, + currentUserId: "user-1" as never, + }); + + expect(status.counts).toEqual({ + total: 5, + queued: 2, + processing: 1, + ready: 1, + failed: 0, + uncertain: 1, + }); + expect(status.rows.map((row) => row.state)).toEqual([ + "queued", + "processing", + "ready", + "uncertain", + "queued", + ]); + expect(status.state).toBe("running"); + expect(status.phase).toBe("dispatching"); + expect(status.rows[3]).toEqual( + expect.objectContaining({ + state: "uncertain", + videoId: "deleted-video", + }), + ); + }); +}); + +describe("Loom batch durability contract", () => { + it("prepares each row before dispatch and spaces starts by 1.5 seconds", () => { + const source = readFileSync( + join(process.cwd(), "workflows/import-loom-batch.ts"), + "utf8", + ); + const workflow = source.slice( + source.indexOf("export async function importLoomBatchWorkflow"), + ); + + expect(workflow.indexOf("prepareRow(")).toBeLessThan( + workflow.indexOf("dispatchRow("), + ); + expect(workflow).toContain('sleep("1500ms")'); + expect(workflow).toContain("let index = claimed.progress.preparedRows;"); + }); + + it("creates video, upload, source mapping, and child operation in one transaction", () => { + const source = readFileSync( + join(process.cwd(), "lib/loom-batch-import.ts"), + "utf8", + ); + const preparation = source.slice( + source.indexOf("export async function prepareLoomBatchRow"), + source.indexOf("export async function claimLoomBatchOperation"), + ); + + expect(preparation).toContain( + "return await db().transaction(async (tx) =>", + ); + expect(preparation).toContain("tx.insert(Db.videos)"); + expect(preparation).toContain("tx.insert(Db.videoUploads)"); + expect(preparation).toContain("tx.insert(Db.importedVideos)"); + expect(preparation).toContain("tx.insert(Db.agentApiOperations)"); + expect(source).toContain('if (locked.state === "running")'); + expect(source).toContain( + "serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC !== payload.defaultPublic", + ); + }); + + it("keeps routine status rows bounded and exposes explicit full reports", () => { + const route = readFileSync( + join(process.cwd(), "app/api/extension/import-loom/batch/route.ts"), + "utf8", + ); + const backend = readFileSync( + join(process.cwd(), "lib/loom-batch-import.ts"), + "utf8", + ); + + expect(route).toContain('report: Schema.optional(Schema.Literal("1"))'); + expect(backend).toContain("payload.rows.slice(0, 100)"); + expect(backend).toContain("rowsTruncated:"); + }); +}); diff --git a/apps/web/app/api/extension/import-loom/batch/route.ts b/apps/web/app/api/extension/import-loom/batch/route.ts new file mode 100644 index 0000000000..cdaf9cdb9e --- /dev/null +++ b/apps/web/app/api/extension/import-loom/batch/route.ts @@ -0,0 +1,257 @@ +import { + CurrentUser, + HttpAuthMiddleware, + Organisation, + User, +} from "@cap/web-domain"; +import { + HttpApi, + HttpApiBuilder, + HttpApiEndpoint, + HttpApiError, + HttpApiGroup, +} from "@effect/platform"; +import { Effect, Layer, Schema } from "effect"; +import { start } from "workflow/api"; +import { + ExtensionLoomAuthorizationError, + MAX_EXTENSION_LOOM_EMAIL_LENGTH, + MAX_EXTENSION_LOOM_ROW_NUMBER, + MAX_EXTENSION_LOOM_SPACE_LENGTH, + MAX_EXTENSION_LOOM_URL_LENGTH, +} from "@/lib/extension-loom-import"; +import { + MAX_LOOM_BATCH_ROWS, + MAX_LOOM_BATCH_SOURCE_ROWS, + MAX_LOOM_BATCH_WORKSPACE_LENGTH, +} from "@/lib/loom-batch"; +import { + getLoomBatchStatus, + LoomBatchConflictError, + LoomBatchNotFoundError, + LoomBatchValidationError, + startLoomBatchImport, +} from "@/lib/loom-batch-import"; +import { apiToHandler } from "@/lib/server"; +import { importLoomBatchWorkflow } from "@/workflows/import-loom-batch"; + +export const dynamic = "force-dynamic"; +export const maxDuration = 120; + +const BatchRow = Schema.Struct({ + rowNumber: Schema.Int.pipe( + Schema.greaterThanOrEqualTo(1), + Schema.lessThanOrEqualTo(MAX_EXTENSION_LOOM_ROW_NUMBER), + ), + loomUrl: Schema.String.pipe( + Schema.minLength(1), + Schema.maxLength(MAX_EXTENSION_LOOM_URL_LENGTH), + ), + userEmail: Schema.String.pipe( + Schema.minLength(1), + Schema.maxLength(MAX_EXTENSION_LOOM_EMAIL_LENGTH), + ), + spaceName: Schema.optional( + Schema.String.pipe(Schema.maxLength(MAX_EXTENSION_LOOM_SPACE_LENGTH)), + ), +}); + +const BatchSource = Schema.Struct({ + workspace: Schema.String.pipe( + Schema.minLength(1), + Schema.maxLength(MAX_LOOM_BATCH_WORKSPACE_LENGTH), + ), + from: Schema.String.pipe(Schema.length(10)), + to: Schema.String.pipe(Schema.length(10)), + totalRows: Schema.Int.pipe( + Schema.greaterThanOrEqualTo(1), + Schema.lessThanOrEqualTo(MAX_LOOM_BATCH_SOURCE_ROWS), + ), + omittedRows: Schema.Int.pipe( + Schema.greaterThanOrEqualTo(0), + Schema.lessThanOrEqualTo(MAX_LOOM_BATCH_SOURCE_ROWS), + ), +}); + +const StartPayload = Schema.Struct({ + requestId: Schema.UUID, + expectedUserId: User.UserId, + expectedDefaultPublic: Schema.Boolean, + organizationId: Organisation.OrganisationId, + rows: Schema.Array(BatchRow).pipe( + Schema.minItems(1), + Schema.maxItems(MAX_LOOM_BATCH_ROWS), + ), + source: BatchSource, +}); + +const StartResponse = Schema.Struct({ + operationId: Schema.String, + dashboardPath: Schema.String, +}); + +const StatusParams = Schema.Struct({ + operationId: Schema.String.pipe(Schema.length(15)), + organizationId: Organisation.OrganisationId, + report: Schema.optional(Schema.Literal("1")), +}); + +const StatusCounts = Schema.Struct({ + total: Schema.Int, + queued: Schema.Int, + processing: Schema.Int, + ready: Schema.Int, + failed: Schema.Int, + uncertain: Schema.Int, +}); + +const StatusRow = Schema.Struct({ + rowNumber: Schema.Int, + userEmail: Schema.String, + spaceName: Schema.optional(Schema.String), + loomVideoId: Schema.String, + state: Schema.Literal("queued", "processing", "ready", "failed", "uncertain"), + videoId: Schema.optional(Schema.String), + error: Schema.optional(Schema.String), + existing: Schema.optional(Schema.Boolean), +}); + +const StatusResponse = Schema.Struct({ + operationId: Schema.String, + organizationId: Schema.String, + state: Schema.Literal( + "queued", + "running", + "dispatched", + "complete", + "failed", + ), + phase: Schema.Literal( + "queued", + "preparing", + "dispatching", + "monitoring", + "complete", + "failed", + ), + source: BatchSource, + counts: StatusCounts, + currentRowNumber: Schema.NullOr(Schema.Int), + rows: Schema.Array(StatusRow), + rowsTruncated: Schema.Boolean, + error: Schema.optional(Schema.String), + createdAt: Schema.String, + updatedAt: Schema.String, + completedAt: Schema.NullOr(Schema.String), +}); + +class Api extends HttpApi.make("ExtensionLoomBatchImportApi").add( + HttpApiGroup.make("loomBatchImport") + .add( + HttpApiEndpoint.post("startBatch")`/api/extension/import-loom/batch` + .middleware(HttpAuthMiddleware) + .setPayload(StartPayload) + .addSuccess(StartResponse) + .addError(HttpApiError.BadRequest) + .addError(HttpApiError.Forbidden) + .addError(HttpApiError.Conflict) + .addError(HttpApiError.NotFound) + .addError(HttpApiError.InternalServerError), + ) + .add( + HttpApiEndpoint.get("getBatch")`/api/extension/import-loom/batch` + .middleware(HttpAuthMiddleware) + .setUrlParams(StatusParams) + .addSuccess(StatusResponse) + .addError(HttpApiError.BadRequest) + .addError(HttpApiError.Forbidden) + .addError(HttpApiError.Conflict) + .addError(HttpApiError.NotFound) + .addError(HttpApiError.InternalServerError), + ), +) {} + +const internalError = (cause: unknown) => + Effect.logError(cause).pipe( + Effect.andThen(Effect.fail(new HttpApiError.InternalServerError())), + ); + +type BatchHttpError = + | HttpApiError.BadRequest + | HttpApiError.Forbidden + | HttpApiError.Conflict + | HttpApiError.NotFound + | HttpApiError.InternalServerError; + +const mapBatchError = ( + cause: unknown, +): Effect.Effect => { + if (cause instanceof ExtensionLoomAuthorizationError) { + return Effect.fail(new HttpApiError.Forbidden()); + } + if (cause instanceof LoomBatchValidationError) { + return Effect.fail(new HttpApiError.BadRequest()); + } + if (cause instanceof LoomBatchConflictError) { + return Effect.fail(new HttpApiError.Conflict()); + } + if (cause instanceof LoomBatchNotFoundError) { + return Effect.fail(new HttpApiError.NotFound()); + } + return internalError(cause); +}; + +const startBatch = ({ + payload, +}: { + payload: Schema.Schema.Type; +}) => + Effect.gen(function* () { + const currentUser = yield* CurrentUser; + return yield* Effect.tryPromise({ + try: () => + startLoomBatchImport({ + request: { + ...payload, + rows: [...payload.rows], + }, + currentUserId: currentUser.id, + startBatchWorkflow: async (operationId) => { + await start(importLoomBatchWorkflow, [{ operationId }]); + }, + }), + catch: (cause) => cause, + }); + }).pipe(Effect.catchAll(mapBatchError)); + +const getBatch = ({ + urlParams, +}: { + urlParams: Schema.Schema.Type; +}) => + Effect.gen(function* () { + const currentUser = yield* CurrentUser; + return yield* Effect.tryPromise({ + try: () => + getLoomBatchStatus({ + operationId: urlParams.operationId, + organizationId: urlParams.organizationId, + currentUserId: currentUser.id, + includeAllRows: urlParams.report === "1", + }), + catch: (cause) => cause, + }); + }).pipe(Effect.catchAll(mapBatchError)); + +const ApiLive = HttpApiBuilder.api(Api).pipe( + Layer.provide( + HttpApiBuilder.group(Api, "loomBatchImport", (handlers) => + handlers.handle("startBatch", startBatch).handle("getBatch", getBatch), + ), + ), +); + +const handler = apiToHandler(ApiLive); + +export const GET = handler; +export const POST = handler; diff --git a/apps/web/lib/loom-batch-import.ts b/apps/web/lib/loom-batch-import.ts new file mode 100644 index 0000000000..ebbb90c34b --- /dev/null +++ b/apps/web/lib/loom-batch-import.ts @@ -0,0 +1,1387 @@ +import "server-only"; + +import { createHash } from "node:crypto"; +import { db } from "@cap/database"; +import * as Db from "@cap/database/schema"; +import { serverEnv } from "@cap/env"; +import { type DbClient, Storage } from "@cap/web-backend"; +import { Organisation, Space, User, Video } from "@cap/web-domain"; +import { and, eq, inArray, isNull, or, sql } from "drizzle-orm"; +import { Option } from "effect"; +import { start } from "workflow/api"; +import { + authorizeExtensionLoomImport, + canonicalizeExtensionLoomUrl, + ExtensionLoomAuthorizationError, + validateExtensionLoomRow, +} from "@/lib/extension-loom-import"; +import { + initialLoomBatchProgress, + isLoomBatchChildPayload, + isLoomBatchPayload, + isLoomBatchProgress, + LOOM_BATCH_OPERATION_KIND, + type LoomBatchChildPayload, + type LoomBatchChildResult, + type LoomBatchParentContext, + type LoomBatchPayload, + type LoomBatchPayloadRow, + type LoomBatchProgress, + type LoomBatchRequest, + type LoomBatchSource, + type LoomBatchStartResponse, + type LoomBatchStatus, + type LoomBatchStatusRow, + MAX_LOOM_BATCH_PAYLOAD_BYTES, + MAX_LOOM_BATCH_ROWS, + MAX_LOOM_BATCH_SOURCE_ROWS, + MAX_LOOM_BATCH_WORKSPACE_LENGTH, + mergeLoomBatchProgress, +} from "@/lib/loom-batch"; +import { downloadLoomVideo } from "@/lib/loom-import"; +import { provisionOrganizationInvitee } from "@/lib/organization-provisioning"; +import { runWorkflowPromise } from "@/lib/workflow-runtime"; +import { importLoomVideoWorkflow } from "@/workflows/import-loom-video"; + +type TransactionCallback = Parameters[0]; +type Transaction = Parameters[0]; + +type ChildOperation = Pick< + typeof Db.agentApiOperations.$inferSelect, + | "id" + | "userId" + | "resourceId" + | "state" + | "payload" + | "result" + | "resultResourceId" + | "errorCode" + | "errorMessage" +>; + +export type LoomBatchPreparation = { + childOperationId: string; + state: "dispatch" | "processing" | "ready" | "failed" | "uncertain"; +}; + +export class LoomBatchValidationError extends Error {} +export class LoomBatchConflictError extends Error {} +export class LoomBatchNotFoundError extends Error {} + +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +const hashHex = (value: string) => + createHash("sha256").update(value, "utf8").digest("hex"); + +const deterministicId = (namespace: string, ...parts: string[]) => + createHash("sha256") + .update([namespace, ...parts].join("\0"), "utf8") + .digest("base64url") + .slice(0, 15); + +export const getLoomBatchOperationId = ( + userId: string, + organizationId: string, + requestId: string, +) => deterministicId("loom_batch", userId, organizationId, requestId); + +export const getLoomBatchChildOperationId = ( + parentId: string, + loomVideoId: string, +) => deterministicId("loom_batch_child", parentId, loomVideoId); + +export const shouldStartLoomBatchParent = ( + state: typeof Db.agentApiOperations.$inferSelect.state | undefined, +) => state === undefined || state === "queued"; + +const isCalendarDate = (value: string) => { + if (!DATE_PATTERN.test(value)) return false; + const parsed = new Date(`${value}T00:00:00.000Z`); + return ( + !Number.isNaN(parsed.getTime()) && + parsed.toISOString().slice(0, 10) === value + ); +}; + +const normalizeSpaceName = (value: string | undefined) => { + const normalized = value?.trim().replace(/\s+/g, " ") ?? ""; + return normalized || undefined; +}; + +const extractCanonicalLoomId = (loomUrl: string) => + loomUrl.slice(loomUrl.lastIndexOf("/") + 1); + +export function normalizeLoomBatchRequest( + request: LoomBatchRequest, + currentUserId: string, +): LoomBatchPayload { + if (request.expectedUserId !== currentUserId) { + throw new ExtensionLoomAuthorizationError(); + } + if (typeof request.expectedDefaultPublic !== "boolean") { + throw new LoomBatchValidationError("Expected visibility is invalid."); + } + if (!UUID_PATTERN.test(request.requestId)) { + throw new LoomBatchValidationError("Request ID must be a UUID."); + } + if ( + !Array.isArray(request.rows) || + request.rows.length === 0 || + request.rows.length > MAX_LOOM_BATCH_ROWS + ) { + throw new LoomBatchValidationError( + `A Loom batch must contain between 1 and ${MAX_LOOM_BATCH_ROWS} rows.`, + ); + } + if ( + !request.source || + typeof request.source.workspace !== "string" || + request.source.workspace.trim().length === 0 || + request.source.workspace.trim().length > MAX_LOOM_BATCH_WORKSPACE_LENGTH || + !isCalendarDate(request.source.from) || + !isCalendarDate(request.source.to) || + request.source.from > request.source.to || + !Number.isInteger(request.source.totalRows) || + request.source.totalRows < 1 || + request.source.totalRows > MAX_LOOM_BATCH_SOURCE_ROWS || + !Number.isInteger(request.source.omittedRows) || + request.source.omittedRows < 0 || + request.source.totalRows !== + request.rows.length + request.source.omittedRows + ) { + throw new LoomBatchValidationError("Loom source metadata is invalid."); + } + + const rows: LoomBatchPayloadRow[] = []; + const seenVideoIds = new Set(); + for (const input of request.rows) { + const row = { + rowNumber: input.rowNumber, + loomUrl: typeof input.loomUrl === "string" ? input.loomUrl.trim() : "", + userEmail: + typeof input.userEmail === "string" + ? input.userEmail.trim().toLowerCase() + : "", + spaceName: + typeof input.spaceName === "string" + ? normalizeSpaceName(input.spaceName) + : undefined, + }; + const validationError = validateExtensionLoomRow(row); + if (validationError) throw new LoomBatchValidationError(validationError); + const canonicalLoomUrl = canonicalizeExtensionLoomUrl(row.loomUrl); + if (!canonicalLoomUrl) { + throw new LoomBatchValidationError("Loom URL is invalid."); + } + const loomVideoId = extractCanonicalLoomId(canonicalLoomUrl); + if (seenVideoIds.has(loomVideoId)) continue; + seenVideoIds.add(loomVideoId); + rows.push({ ...row, loomUrl: canonicalLoomUrl, loomVideoId }); + } + if (rows.length === 0) { + throw new LoomBatchValidationError( + "The Loom batch contains no unique videos.", + ); + } + + const source: LoomBatchSource = { + workspace: request.source.workspace.trim(), + from: request.source.from, + to: request.source.to, + totalRows: request.source.totalRows, + omittedRows: + request.source.omittedRows + (request.rows.length - rows.length), + }; + const normalizedRequest = { + requestId: request.requestId.toLowerCase(), + expectedUserId: currentUserId, + expectedDefaultPublic: request.expectedDefaultPublic, + organizationId: request.organizationId, + rows, + source, + }; + const requestHash = hashHex(JSON.stringify(normalizedRequest)); + const payload: LoomBatchPayload = { + type: "loom_batch", + version: 1, + requestId: normalizedRequest.requestId, + requestHash, + organizationId: request.organizationId, + requestedByUserId: currentUserId, + defaultPublic: request.expectedDefaultPublic, + rows, + source, + createdAt: new Date().toISOString(), + }; + if ( + Buffer.byteLength(JSON.stringify(payload), "utf8") > + MAX_LOOM_BATCH_PAYLOAD_BYTES + ) { + throw new LoomBatchValidationError("The Loom batch payload is too large."); + } + return payload; +} + +const initialRunningProgress = ( + payload: LoomBatchPayload, +): LoomBatchProgress => ({ + ...initialLoomBatchProgress(payload), + phase: "preparing", +}); + +const assertMatchingParent = ( + operation: Pick< + typeof Db.agentApiOperations.$inferSelect, + "userId" | "resourceId" | "payload" + >, + payload: LoomBatchPayload, +) => { + if ( + operation.userId !== payload.requestedByUserId || + operation.resourceId !== payload.organizationId || + !isLoomBatchPayload(operation.payload) || + operation.payload.requestHash !== payload.requestHash + ) { + throw new LoomBatchConflictError( + "Request ID was already used for a different Loom batch.", + ); + } +}; + +export async function startLoomBatchImport({ + request, + currentUserId, + startBatchWorkflow, +}: { + request: LoomBatchRequest; + currentUserId: User.UserId; + startBatchWorkflow: (operationId: string) => Promise; +}): Promise { + const organizationId = Organisation.OrganisationId.make( + request.organizationId, + ); + const payload = normalizeLoomBatchRequest(request, currentUserId); + if (serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC !== payload.defaultPublic) { + throw new LoomBatchConflictError( + "Cap visibility changed. Refresh the import setup and try again.", + ); + } + await authorizeExtensionLoomImport({ + userId: currentUserId, + organizationId, + }); + const operationId = getLoomBatchOperationId( + currentUserId, + organizationId, + payload.requestId, + ); + const progress = initialLoomBatchProgress(payload); + + const shouldStartParent = await db().transaction(async (tx) => { + const [organization] = await tx + .select({ id: Db.organizations.id }) + .from(Db.organizations) + .where( + and( + eq(Db.organizations.id, organizationId), + isNull(Db.organizations.tombstoneAt), + ), + ) + .limit(1) + .for("update"); + if (!organization) throw new ExtensionLoomAuthorizationError(); + + const [existing] = await tx + .select({ + userId: Db.agentApiOperations.userId, + resourceId: Db.agentApiOperations.resourceId, + payload: Db.agentApiOperations.payload, + state: Db.agentApiOperations.state, + }) + .from(Db.agentApiOperations) + .where(eq(Db.agentApiOperations.id, operationId)) + .limit(1) + .for("update"); + if (existing) { + assertMatchingParent(existing, payload); + return shouldStartLoomBatchParent(existing.state); + } + + const activeOperations = await tx + .select({ payload: Db.agentApiOperations.payload }) + .from(Db.agentApiOperations) + .where( + and( + eq(Db.agentApiOperations.kind, LOOM_BATCH_OPERATION_KIND), + eq(Db.agentApiOperations.resourceId, organizationId), + inArray(Db.agentApiOperations.state, ["queued", "running"]), + ), + ) + .for("update"); + if ( + activeOperations.some((operation) => + isLoomBatchPayload(operation.payload), + ) + ) { + throw new LoomBatchConflictError( + "Another Loom batch is already being prepared for this organization.", + ); + } + + await tx.insert(Db.agentApiOperations).values({ + id: operationId, + userId: currentUserId, + kind: LOOM_BATCH_OPERATION_KIND, + resourceId: organizationId, + state: "queued", + payload, + result: progress, + }); + return true; + }); + + if (shouldStartParent) await startBatchWorkflow(operationId); + + return { + operationId, + dashboardPath: `/dashboard/import/loom/status?operationId=${encodeURIComponent(operationId)}&organizationId=${encodeURIComponent(organizationId)}`, + }; +} + +const assertChildScope = ( + operation: ChildOperation, + parent: LoomBatchParentContext, + row: LoomBatchPayloadRow, +) => { + if ( + operation.userId !== parent.requestedByUserId || + operation.resourceId !== parent.organizationId || + !isLoomBatchChildPayload(operation.payload) || + operation.payload.parentId !== + getLoomBatchOperationId( + parent.requestedByUserId, + parent.organizationId, + parent.requestId, + ) || + operation.payload.row.rowNumber !== row.rowNumber || + operation.payload.row.loomVideoId !== row.loomVideoId || + operation.payload.row.userEmail !== row.userEmail || + operation.payload.row.spaceName !== row.spaceName + ) { + throw new LoomBatchConflictError("Loom batch child operation is invalid."); + } +}; + +const preparationFromChild = ( + operation: ChildOperation, +): LoomBatchPreparation => { + if (operation.state === "queued") { + return { + childOperationId: operation.id, + state: + isLoomBatchChildPayload(operation.payload) && operation.payload.dispatch + ? "dispatch" + : "uncertain", + }; + } + if (operation.state === "running") { + return { childOperationId: operation.id, state: "processing" }; + } + if (operation.state === "succeeded") { + return { childOperationId: operation.id, state: "ready" }; + } + return { + childOperationId: operation.id, + state: + operation.errorCode === "LOOM_IMPORT_UNCERTAIN" ? "uncertain" : "failed", + }; +}; + +const getChildOperation = async (childOperationId: string) => { + const [operation] = await db() + .select({ + id: Db.agentApiOperations.id, + userId: Db.agentApiOperations.userId, + resourceId: Db.agentApiOperations.resourceId, + state: Db.agentApiOperations.state, + payload: Db.agentApiOperations.payload, + result: Db.agentApiOperations.result, + resultResourceId: Db.agentApiOperations.resultResourceId, + errorCode: Db.agentApiOperations.errorCode, + errorMessage: Db.agentApiOperations.errorMessage, + }) + .from(Db.agentApiOperations) + .where(eq(Db.agentApiOperations.id, childOperationId)) + .limit(1); + return operation; +}; + +const childPayload = ( + parentId: string, + parent: LoomBatchParentContext, + row: LoomBatchPayloadRow, + dispatch?: LoomBatchChildPayload["dispatch"], +): LoomBatchChildPayload => ({ + type: "loom_child", + version: 1, + parentId, + organizationId: parent.organizationId, + requestedByUserId: parent.requestedByUserId, + row: { + rowNumber: row.rowNumber, + loomVideoId: row.loomVideoId, + userEmail: row.userEmail, + ...(row.spaceName ? { spaceName: row.spaceName } : {}), + }, + ...(dispatch ? { dispatch } : {}), +}); + +const getExistingImport = ( + tx: Transaction, + parent: LoomBatchParentContext, + row: LoomBatchPayloadRow, +) => + tx + .select({ + mappingId: Db.importedVideos.id, + videoId: Db.videos.id, + uploadPhase: Db.videoUploads.phase, + }) + .from(Db.importedVideos) + .leftJoin( + Db.videos, + and( + eq(Db.videos.id, Db.importedVideos.id), + eq(Db.videos.orgId, Db.importedVideos.orgId), + ), + ) + .leftJoin(Db.videoUploads, eq(Db.videoUploads.videoId, Db.videos.id)) + .where( + and( + eq( + Db.importedVideos.orgId, + Organisation.OrganisationId.make(parent.organizationId), + ), + eq(Db.importedVideos.source, "loom"), + eq(Db.importedVideos.sourceId, row.loomVideoId), + ), + ) + .limit(1) + .for("update"); + +const insertTerminalChild = async ({ + tx, + childOperationId, + parentId, + parent, + row, + state, + videoId, + error, +}: { + tx: Transaction; + childOperationId: string; + parentId: string; + parent: LoomBatchParentContext; + row: LoomBatchPayloadRow; + state: "ready" | "failed" | "uncertain"; + videoId?: string; + error?: string; +}) => { + const now = new Date(); + const result: LoomBatchChildResult | null = + state === "ready" && videoId ? { videoId, existing: true } : null; + await tx.insert(Db.agentApiOperations).values({ + id: childOperationId, + userId: User.UserId.make(parent.requestedByUserId), + kind: LOOM_BATCH_OPERATION_KIND, + resourceId: Organisation.OrganisationId.make(parent.organizationId), + resultResourceId: videoId ? Video.VideoId.make(videoId) : null, + state: state === "ready" ? "succeeded" : "failed", + payload: childPayload(parentId, parent, row), + result, + errorCode: + state === "uncertain" + ? "LOOM_IMPORT_UNCERTAIN" + : state === "failed" + ? "LOOM_IMPORT_PREPARATION_FAILED" + : null, + errorMessage: error?.slice(0, 2_000) ?? null, + updatedAt: now, + completedAt: now, + }); + return { childOperationId, state } satisfies LoomBatchPreparation; +}; + +const recordTerminalChild = async ({ + parentId, + parent, + row, + fallbackState, + error, +}: { + parentId: string; + parent: LoomBatchParentContext; + row: LoomBatchPayloadRow; + fallbackState: "failed" | "uncertain"; + error: string; +}): Promise => { + const childOperationId = getLoomBatchChildOperationId( + parentId, + row.loomVideoId, + ); + try { + return await db().transaction(async (tx) => { + const [existingChild] = await tx + .select({ + id: Db.agentApiOperations.id, + userId: Db.agentApiOperations.userId, + resourceId: Db.agentApiOperations.resourceId, + state: Db.agentApiOperations.state, + payload: Db.agentApiOperations.payload, + result: Db.agentApiOperations.result, + resultResourceId: Db.agentApiOperations.resultResourceId, + errorCode: Db.agentApiOperations.errorCode, + errorMessage: Db.agentApiOperations.errorMessage, + }) + .from(Db.agentApiOperations) + .where(eq(Db.agentApiOperations.id, childOperationId)) + .limit(1) + .for("update"); + if (existingChild) { + assertChildScope(existingChild, parent, row); + return preparationFromChild(existingChild); + } + const [existingImport] = await getExistingImport(tx, parent, row); + if (existingImport) { + if ( + existingImport.videoId && + (existingImport.uploadPhase === null || + existingImport.uploadPhase === "complete") + ) { + return insertTerminalChild({ + tx, + childOperationId, + parentId, + parent, + row, + state: "ready", + videoId: existingImport.videoId, + }); + } + return insertTerminalChild({ + tx, + childOperationId, + parentId, + parent, + row, + state: "uncertain", + videoId: existingImport.videoId ?? undefined, + error: + "A Loom source mapping already exists, but its import is not complete.", + }); + } + return insertTerminalChild({ + tx, + childOperationId, + parentId, + parent, + row, + state: fallbackState, + error, + }); + }); + } catch (cause) { + const existingChild = await getChildOperation(childOperationId); + if (!existingChild) throw cause; + assertChildScope(existingChild, parent, row); + return preparationFromChild(existingChild); + } +}; + +const getOrganizationOwnerByEmail = async ( + organizationId: Organisation.OrganisationId, + email: string, +) => { + const [member] = await db() + .select({ id: Db.users.id }) + .from(Db.users) + .innerJoin(Db.organizations, eq(Db.organizations.id, organizationId)) + .leftJoin( + Db.organizationMembers, + and( + eq(Db.organizationMembers.organizationId, organizationId), + eq(Db.organizationMembers.userId, Db.users.id), + ), + ) + .where( + and( + eq(Db.users.email, email), + or( + eq(Db.organizations.ownerId, Db.users.id), + eq(Db.organizationMembers.userId, Db.users.id), + ), + ), + ) + .limit(1); + return member?.id; +}; + +const getOrProvisionOwner = async ( + parent: LoomBatchParentContext, + row: LoomBatchPayloadRow, +) => { + const organizationId = Organisation.OrganisationId.make( + parent.organizationId, + ); + const existing = await getOrganizationOwnerByEmail( + organizationId, + row.userEmail, + ); + if (existing) return existing; + const provisioned = await provisionOrganizationInvitee({ + organizationId, + email: row.userEmail, + invitedByUserId: User.UserId.make(parent.requestedByUserId), + role: "member", + }); + return provisioned.userId; +}; + +const insertSpacePlacement = async ({ + tx, + parent, + row, + ownerId, + videoId, +}: { + tx: Transaction; + parent: LoomBatchParentContext; + row: LoomBatchPayloadRow; + ownerId: User.UserId; + videoId: Video.VideoId; +}) => { + if (!row.spaceName) return; + const organizationId = Organisation.OrganisationId.make( + parent.organizationId, + ); + const normalizedName = row.spaceName; + const [existingSpace] = await tx + .select({ id: Db.spaces.id }) + .from(Db.spaces) + .where( + and( + eq(Db.spaces.organizationId, organizationId), + sql`LOWER(${Db.spaces.name}) = ${normalizedName.toLowerCase()}`, + ), + ) + .limit(1); + const spaceId = + existingSpace?.id ?? + Space.SpaceId.make( + deterministicId( + "loom_batch_space", + parent.organizationId, + normalizedName.toLowerCase(), + ), + ); + if (!existingSpace) { + await tx.insert(Db.spaces).values({ + id: spaceId, + name: normalizedName, + organizationId, + createdById: User.UserId.make(parent.requestedByUserId), + iconUrl: null, + }); + } + for (const [userId, role] of [ + [User.UserId.make(parent.requestedByUserId), "admin"], + [ownerId, ownerId === parent.requestedByUserId ? "admin" : "member"], + ] as const) { + await tx + .insert(Db.spaceMembers) + .values({ + id: deterministicId("loom_batch_space_member", spaceId, userId), + spaceId, + userId, + role, + }) + .onDuplicateKeyUpdate({ + set: { role: sql`${Db.spaceMembers.role}` }, + }); + } + await tx + .insert(Db.spaceVideos) + .values({ + id: deterministicId("loom_batch_space_video", spaceId, videoId), + spaceId, + videoId, + addedById: User.UserId.make(parent.requestedByUserId), + }) + .onDuplicateKeyUpdate({ + set: { id: sql`${Db.spaceVideos.id}` }, + }); +}; + +export async function prepareLoomBatchRow( + parentId: string, + parent: LoomBatchParentContext, + row: LoomBatchPayloadRow, +): Promise { + const childOperationId = getLoomBatchChildOperationId( + parentId, + row.loomVideoId, + ); + const existingChild = await getChildOperation(childOperationId); + if (existingChild) { + assertChildScope(existingChild, parent, row); + return preparationFromChild(existingChild); + } + + const [existingImport] = await db().transaction((tx) => + getExistingImport(tx, parent, row), + ); + if (existingImport) { + return recordTerminalChild({ + parentId, + parent, + row, + fallbackState: "uncertain", + error: + "A Loom source mapping already exists, but its import is not complete.", + }); + } + + const download = await downloadLoomVideo(row.loomUrl); + if ( + !download.success || + !download.videoId || + download.videoId !== row.loomVideoId + ) { + return recordTerminalChild({ + parentId, + parent, + row, + fallbackState: "failed", + error: download.error ?? "The Loom video could not be prepared.", + }); + } + + let ownerId: User.UserId; + try { + ownerId = await getOrProvisionOwner(parent, row); + } catch { + return recordTerminalChild({ + parentId, + parent, + row, + fallbackState: "failed", + error: "Could not add this email to the organization.", + }); + } + + const organizationId = Organisation.OrganisationId.make( + parent.organizationId, + ); + const writableResult = await Storage.getWritableAccessForUser( + ownerId, + organizationId, + ) + .pipe(runWorkflowPromise) + .then( + (value) => ({ ok: true as const, value }), + () => ({ ok: false as const }), + ); + if (!writableResult.ok) { + return recordTerminalChild({ + parentId, + parent, + row, + fallbackState: "failed", + error: "Could not prepare storage for this import.", + }); + } + + const videoId = Video.VideoId.make( + deterministicId("loom_batch_video", childOperationId), + ); + const rawFileKey = `${ownerId}/${videoId}/raw-upload.mp4`; + const dispatch = { + videoId, + ownerId, + rawFileKey, + bucketId: Option.getOrNull(writableResult.value.bucketId), + loomVideoId: row.loomVideoId, + }; + + try { + return await db().transaction(async (tx) => { + const [operation] = await tx + .select({ + id: Db.agentApiOperations.id, + userId: Db.agentApiOperations.userId, + resourceId: Db.agentApiOperations.resourceId, + state: Db.agentApiOperations.state, + payload: Db.agentApiOperations.payload, + result: Db.agentApiOperations.result, + resultResourceId: Db.agentApiOperations.resultResourceId, + errorCode: Db.agentApiOperations.errorCode, + errorMessage: Db.agentApiOperations.errorMessage, + }) + .from(Db.agentApiOperations) + .where(eq(Db.agentApiOperations.id, childOperationId)) + .limit(1) + .for("update"); + if (operation) { + assertChildScope(operation, parent, row); + return preparationFromChild(operation); + } + const [mapping] = await getExistingImport(tx, parent, row); + if (mapping) { + if ( + mapping.videoId && + (mapping.uploadPhase === null || mapping.uploadPhase === "complete") + ) { + return insertTerminalChild({ + tx, + childOperationId, + parentId, + parent, + row, + state: "ready", + videoId: mapping.videoId, + }); + } + return insertTerminalChild({ + tx, + childOperationId, + parentId, + parent, + row, + state: "uncertain", + videoId: mapping.videoId ?? undefined, + error: + "A Loom source mapping already exists, but its import is not complete.", + }); + } + + await tx.insert(Db.videos).values({ + id: videoId, + name: + download.videoName?.slice(0, 255) ?? + `Loom Import - ${new Date().toISOString().slice(0, 10)}`, + ownerId, + orgId: organizationId, + source: { type: "webMP4" }, + bucket: dispatch.bucketId, + storageIntegrationId: Option.getOrNull( + writableResult.value.storageIntegrationId, + ), + public: parent.defaultPublic, + duration: download.durationSeconds, + width: download.width, + height: download.height, + }); + await tx.insert(Db.videoUploads).values({ + videoId, + phase: "uploading", + processingProgress: 0, + processingMessage: "Importing from Loom...", + }); + await tx.insert(Db.importedVideos).values({ + id: videoId, + orgId: organizationId, + source: "loom", + sourceId: row.loomVideoId, + }); + await insertSpacePlacement({ tx, parent, row, ownerId, videoId }); + await tx.insert(Db.agentApiOperations).values({ + id: childOperationId, + userId: User.UserId.make(parent.requestedByUserId), + kind: LOOM_BATCH_OPERATION_KIND, + resourceId: organizationId, + resultResourceId: videoId, + state: "queued", + payload: childPayload(parentId, parent, row, dispatch), + }); + return { + childOperationId, + state: "dispatch", + } satisfies LoomBatchPreparation; + }); + } catch (cause) { + const concurrentChild = await getChildOperation(childOperationId); + if (concurrentChild) { + assertChildScope(concurrentChild, parent, row); + return preparationFromChild(concurrentChild); + } + const [concurrentMapping] = await db().transaction((tx) => + getExistingImport(tx, parent, row), + ); + if (concurrentMapping) { + return recordTerminalChild({ + parentId, + parent, + row, + fallbackState: "uncertain", + error: + "A Loom source mapping was created concurrently without a durable batch outcome.", + }); + } + throw cause; + } +} + +export async function claimLoomBatchOperation(operationId: string) { + const [operation] = await db() + .select() + .from(Db.agentApiOperations) + .where(eq(Db.agentApiOperations.id, operationId)) + .limit(1); + if (!operation || !isLoomBatchPayload(operation.payload)) { + throw new LoomBatchNotFoundError("Loom batch operation was not found."); + } + const payload = operation.payload; + await authorizeExtensionLoomImport({ + userId: User.UserId.make(payload.requestedByUserId), + organizationId: Organisation.OrganisationId.make(payload.organizationId), + }); + + return db().transaction(async (tx) => { + const [locked] = await tx + .select() + .from(Db.agentApiOperations) + .where(eq(Db.agentApiOperations.id, operationId)) + .limit(1) + .for("update"); + if ( + !locked || + !isLoomBatchPayload(locked.payload) || + locked.userId !== locked.payload.requestedByUserId || + locked.resourceId !== locked.payload.organizationId + ) { + throw new LoomBatchConflictError( + "Loom batch operation scope is invalid.", + ); + } + if (locked.state === "succeeded" || locked.state === "failed") return null; + if (locked.state === "running") { + const progress = isLoomBatchProgress(locked.result) + ? locked.result + : initialRunningProgress(locked.payload); + return { payload: locked.payload, progress }; + } + const progress = initialRunningProgress(locked.payload); + await tx + .update(Db.agentApiOperations) + .set({ state: "running", result: progress, updatedAt: new Date() }) + .where( + and( + eq(Db.agentApiOperations.id, operationId), + eq(Db.agentApiOperations.state, "queued"), + ), + ); + return { payload: locked.payload, progress }; + }); +} + +export async function setLoomBatchProgress( + operationId: string, + progress: LoomBatchProgress, +) { + await db().transaction(async (tx) => { + const [operation] = await tx + .select({ + state: Db.agentApiOperations.state, + result: Db.agentApiOperations.result, + }) + .from(Db.agentApiOperations) + .where(eq(Db.agentApiOperations.id, operationId)) + .limit(1) + .for("update"); + if (!operation || operation.state !== "running") { + throw new LoomBatchConflictError("Loom batch is no longer running."); + } + const nextProgress = isLoomBatchProgress(operation.result) + ? mergeLoomBatchProgress(operation.result, progress) + : progress; + await tx + .update(Db.agentApiOperations) + .set({ result: nextProgress, updatedAt: new Date() }) + .where(eq(Db.agentApiOperations.id, operationId)); + }); +} + +export async function dispatchLoomBatchChild(childOperationId: string) { + const operation = await getChildOperation(childOperationId); + if (!operation || !isLoomBatchChildPayload(operation.payload)) { + throw new LoomBatchConflictError( + "Loom batch child operation was not found.", + ); + } + if (operation.state !== "queued") return false; + const dispatch = operation.payload.dispatch; + if (!dispatch) { + throw new LoomBatchConflictError("Loom batch child is not dispatchable."); + } + await start(importLoomVideoWorkflow, [ + { + videoId: dispatch.videoId, + userId: dispatch.ownerId, + rawFileKey: dispatch.rawFileKey, + bucketId: dispatch.bucketId, + loomVideoId: dispatch.loomVideoId, + agentOperationId: childOperationId, + }, + ]); + return true; +} + +export async function completeLoomBatchOperation( + operationId: string, + progress: LoomBatchProgress, +) { + await db().transaction(async (tx) => { + const [operation] = await tx + .select({ + state: Db.agentApiOperations.state, + result: Db.agentApiOperations.result, + }) + .from(Db.agentApiOperations) + .where(eq(Db.agentApiOperations.id, operationId)) + .limit(1) + .for("update"); + if (!operation || operation.state !== "running") return; + const mergedProgress = isLoomBatchProgress(operation.result) + ? mergeLoomBatchProgress(operation.result, progress) + : progress; + const now = new Date(); + await tx + .update(Db.agentApiOperations) + .set({ + state: "succeeded", + result: { + ...mergedProgress, + phase: "dispatched", + currentRowNumber: null, + }, + errorCode: null, + errorMessage: null, + updatedAt: now, + completedAt: now, + }) + .where( + and( + eq(Db.agentApiOperations.id, operationId), + eq(Db.agentApiOperations.state, "running"), + ), + ); + }); +} + +export async function failLoomBatchOperation( + operationId: string, + error: unknown, +) { + const now = new Date(); + await db() + .update(Db.agentApiOperations) + .set({ + state: "failed", + errorCode: "LOOM_BATCH_FAILED", + errorMessage: + error instanceof Error + ? error.message.slice(0, 2_000) + : "Loom batch failed.", + updatedAt: now, + completedAt: now, + }) + .where( + and( + eq(Db.agentApiOperations.id, operationId), + inArray(Db.agentApiOperations.state, ["queued", "running"]), + ), + ); +} + +type ChildStatusRecord = ChildOperation & { + videoId: string | null; + uploadPhase: typeof Db.videoUploads.$inferSelect.phase | null; + uploadError: string | null; +}; + +const statusRowFromChild = ( + parent: LoomBatchPayload, + row: LoomBatchPayloadRow, + operation: ChildStatusRecord | undefined, + parentState: typeof Db.agentApiOperations.$inferSelect.state, +): LoomBatchStatusRow => { + const base = { + rowNumber: row.rowNumber, + userEmail: row.userEmail, + ...(row.spaceName ? { spaceName: row.spaceName } : {}), + loomVideoId: row.loomVideoId, + }; + if (!operation) { + if (parentState === "failed") { + return { + ...base, + state: "uncertain", + error: "Batch stopped before a durable outcome was recorded.", + }; + } + if (parentState === "succeeded") { + return { + ...base, + state: "uncertain", + error: "No durable outcome was recorded for this row.", + }; + } + return { ...base, state: "queued" }; + } + try { + assertChildScope(operation, parent, row); + } catch { + return { + ...base, + state: "uncertain", + error: "The durable row outcome does not match this batch.", + }; + } + const childResult = + operation.result && typeof operation.result === "object" + ? (operation.result as LoomBatchChildResult) + : undefined; + const retainedVideoId = operation.videoId ?? childResult?.videoId; + if (operation.state === "failed") { + return { + ...base, + state: + operation.errorCode === "LOOM_IMPORT_UNCERTAIN" + ? "uncertain" + : "failed", + ...(retainedVideoId ? { videoId: retainedVideoId } : {}), + ...(operation.errorMessage ? { error: operation.errorMessage } : {}), + }; + } + if (operation.state === "succeeded") { + if ( + operation.videoId && + (operation.uploadPhase === null || operation.uploadPhase === "complete") + ) { + return { + ...base, + state: "ready", + videoId: operation.videoId, + ...(childResult?.existing ? { existing: true } : {}), + }; + } + return { + ...base, + state: "uncertain", + ...(retainedVideoId ? { videoId: retainedVideoId } : {}), + error: "The row completed without a completed Cap upload.", + }; + } + if (operation.uploadPhase === "error") { + return { + ...base, + state: "failed", + ...(retainedVideoId ? { videoId: retainedVideoId } : {}), + error: operation.uploadError ?? "The Cap import failed.", + }; + } + if (operation.state === "queued" && parentState === "failed") { + return { + ...base, + state: "uncertain", + ...(retainedVideoId ? { videoId: retainedVideoId } : {}), + error: "Batch stopped before dispatch was confirmed.", + }; + } + return { + ...base, + state: operation.state === "running" ? "processing" : "queued", + ...(retainedVideoId ? { videoId: retainedVideoId } : {}), + }; +}; + +export async function getLoomBatchStatus({ + operationId, + organizationId, + currentUserId, + includeAllRows = false, +}: { + operationId: string; + organizationId: Organisation.OrganisationId; + currentUserId: User.UserId; + includeAllRows?: boolean; +}): Promise { + await authorizeExtensionLoomImport({ userId: currentUserId, organizationId }); + const [operation] = await db() + .select() + .from(Db.agentApiOperations) + .where( + and( + eq(Db.agentApiOperations.id, operationId), + eq(Db.agentApiOperations.kind, LOOM_BATCH_OPERATION_KIND), + eq(Db.agentApiOperations.resourceId, organizationId), + eq(Db.agentApiOperations.userId, currentUserId), + ), + ) + .limit(1); + if (!operation || !isLoomBatchPayload(operation.payload)) { + throw new LoomBatchNotFoundError("Loom batch operation was not found."); + } + const payload = operation.payload; + const childIds = payload.rows.map((row) => + getLoomBatchChildOperationId(operationId, row.loomVideoId), + ); + const counts = { + total: payload.rows.length, + queued: 0, + processing: 0, + ready: 0, + failed: 0, + uncertain: 0, + }; + let recordedRows = 0; + for (let index = 0; index < childIds.length; index += 1_000) { + const chunk = childIds.slice(index, index + 1_000); + const [aggregate] = await db() + .select({ + recorded: sql`COUNT(*)`, + queued: sql`COALESCE(SUM(CASE WHEN ${Db.agentApiOperations.state} = 'queued' AND (${Db.videoUploads.phase} IS NULL OR ${Db.videoUploads.phase} <> 'error') THEN 1 ELSE 0 END), 0)`, + processing: sql`COALESCE(SUM(CASE WHEN ${Db.agentApiOperations.state} = 'running' AND (${Db.videoUploads.phase} IS NULL OR ${Db.videoUploads.phase} <> 'error') THEN 1 ELSE 0 END), 0)`, + ready: sql`COALESCE(SUM(CASE WHEN ${Db.agentApiOperations.state} = 'succeeded' AND (${Db.videoUploads.phase} IS NULL OR ${Db.videoUploads.phase} = 'complete') AND ${Db.videos.id} IS NOT NULL THEN 1 ELSE 0 END), 0)`, + failed: sql`COALESCE(SUM(CASE WHEN (${Db.agentApiOperations.state} = 'failed' AND (${Db.agentApiOperations.errorCode} IS NULL OR ${Db.agentApiOperations.errorCode} <> 'LOOM_IMPORT_UNCERTAIN')) OR (${Db.agentApiOperations.state} IN ('queued', 'running') AND ${Db.videoUploads.phase} = 'error') THEN 1 ELSE 0 END), 0)`, + uncertain: sql`COALESCE(SUM(CASE WHEN (${Db.agentApiOperations.state} = 'failed' AND ${Db.agentApiOperations.errorCode} = 'LOOM_IMPORT_UNCERTAIN') OR (${Db.agentApiOperations.state} = 'succeeded' AND (${Db.videos.id} IS NULL OR (${Db.videoUploads.phase} IS NOT NULL AND ${Db.videoUploads.phase} <> 'complete'))) THEN 1 ELSE 0 END), 0)`, + }) + .from(Db.agentApiOperations) + .leftJoin( + Db.videos, + eq(Db.videos.id, Db.agentApiOperations.resultResourceId), + ) + .leftJoin(Db.videoUploads, eq(Db.videoUploads.videoId, Db.videos.id)) + .where( + and( + inArray(Db.agentApiOperations.id, chunk), + eq(Db.agentApiOperations.kind, LOOM_BATCH_OPERATION_KIND), + eq(Db.agentApiOperations.resourceId, organizationId), + eq(Db.agentApiOperations.userId, currentUserId), + sql`JSON_UNQUOTE(JSON_EXTRACT(${Db.agentApiOperations.payload}, '$.type')) = 'loom_child'`, + sql`JSON_EXTRACT(${Db.agentApiOperations.payload}, '$.version') = 1`, + sql`JSON_UNQUOTE(JSON_EXTRACT(${Db.agentApiOperations.payload}, '$.parentId')) = ${operationId}`, + ), + ); + recordedRows += Number(aggregate?.recorded ?? 0); + counts.queued += Number(aggregate?.queued ?? 0); + counts.processing += Number(aggregate?.processing ?? 0); + counts.ready += Number(aggregate?.ready ?? 0); + counts.failed += Number(aggregate?.failed ?? 0); + counts.uncertain += Number(aggregate?.uncertain ?? 0); + } + const unpreparedRows = Math.max(0, payload.rows.length - recordedRows); + if (operation.state === "failed" || operation.state === "succeeded") { + counts.uncertain += unpreparedRows; + } else { + counts.queued += unpreparedRows; + } + if (operation.state === "failed") { + counts.uncertain += counts.queued; + counts.queued = 0; + } + + const detailRows = includeAllRows ? payload.rows : payload.rows.slice(0, 100); + const detailIds = detailRows.map((row) => + getLoomBatchChildOperationId(operationId, row.loomVideoId), + ); + const childRows: ChildStatusRecord[] = []; + for (let index = 0; index < detailIds.length; index += 1_000) { + const chunk = detailIds.slice(index, index + 1_000); + childRows.push( + ...(await db() + .select({ + id: Db.agentApiOperations.id, + userId: Db.agentApiOperations.userId, + resourceId: Db.agentApiOperations.resourceId, + state: Db.agentApiOperations.state, + payload: Db.agentApiOperations.payload, + result: Db.agentApiOperations.result, + resultResourceId: Db.agentApiOperations.resultResourceId, + errorCode: Db.agentApiOperations.errorCode, + errorMessage: Db.agentApiOperations.errorMessage, + videoId: Db.videos.id, + uploadPhase: Db.videoUploads.phase, + uploadError: Db.videoUploads.processingError, + }) + .from(Db.agentApiOperations) + .leftJoin( + Db.videos, + eq(Db.videos.id, Db.agentApiOperations.resultResourceId), + ) + .leftJoin(Db.videoUploads, eq(Db.videoUploads.videoId, Db.videos.id)) + .where( + and( + inArray(Db.agentApiOperations.id, chunk), + eq(Db.agentApiOperations.kind, LOOM_BATCH_OPERATION_KIND), + eq(Db.agentApiOperations.resourceId, organizationId), + eq(Db.agentApiOperations.userId, currentUserId), + ), + )), + ); + } + const childById = new Map(childRows.map((row) => [row.id, row])); + const rows = detailRows.map((row) => + statusRowFromChild( + payload, + row, + childById.get(getLoomBatchChildOperationId(operationId, row.loomVideoId)), + operation.state, + ), + ); + const progress = isLoomBatchProgress(operation.result) + ? operation.result + : initialLoomBatchProgress(payload); + const hasPendingRows = counts.queued + counts.processing > 0; + const state = + operation.state === "failed" + ? "failed" + : operation.state === "queued" + ? "queued" + : operation.state === "running" + ? "running" + : hasPendingRows + ? "dispatched" + : "complete"; + const phase = + state === "failed" + ? "failed" + : state === "queued" + ? "queued" + : state === "dispatched" + ? "monitoring" + : state === "complete" + ? "complete" + : progress.phase === "dispatching" + ? "dispatching" + : "preparing"; + + return { + operationId, + organizationId, + state, + phase, + source: payload.source, + counts, + currentRowNumber: progress.currentRowNumber, + rows, + rowsTruncated: !includeAllRows && payload.rows.length > detailRows.length, + ...(operation.errorMessage ? { error: operation.errorMessage } : {}), + createdAt: operation.createdAt.toISOString(), + updatedAt: operation.updatedAt.toISOString(), + completedAt: operation.completedAt?.toISOString() ?? null, + }; +} diff --git a/apps/web/lib/loom-batch.ts b/apps/web/lib/loom-batch.ts new file mode 100644 index 0000000000..2aabd10282 --- /dev/null +++ b/apps/web/lib/loom-batch.ts @@ -0,0 +1,269 @@ +export const LOOM_BATCH_OPERATION_KIND = "import_loom" as const; +export const MAX_LOOM_BATCH_ROWS = 5_000; +export const MAX_LOOM_BATCH_SOURCE_ROWS = 50_000; +export const MAX_LOOM_BATCH_WORKSPACE_LENGTH = 255; +export const MAX_LOOM_BATCH_PAYLOAD_BYTES = 4 * 1024 * 1024; + +export type LoomBatchRowInput = { + rowNumber: number; + loomUrl: string; + userEmail: string; + spaceName?: string; +}; + +export type LoomBatchSource = { + workspace: string; + from: string; + to: string; + totalRows: number; + omittedRows: number; +}; + +export type LoomBatchRequest = { + requestId: string; + expectedUserId: string; + expectedDefaultPublic: boolean; + organizationId: string; + rows: LoomBatchRowInput[]; + source: LoomBatchSource; +}; + +export type LoomBatchStartResponse = { + operationId: string; + dashboardPath: string; +}; + +export type LoomBatchStatusState = + | "queued" + | "running" + | "dispatched" + | "complete" + | "failed"; + +export type LoomBatchStatusPhase = + | "queued" + | "preparing" + | "dispatching" + | "monitoring" + | "complete" + | "failed"; + +export type LoomBatchRowState = + | "queued" + | "processing" + | "ready" + | "failed" + | "uncertain"; + +export type LoomBatchStatusCounts = { + total: number; + queued: number; + processing: number; + ready: number; + failed: number; + uncertain: number; +}; + +export type LoomBatchStatusRow = { + rowNumber: number; + userEmail: string; + spaceName?: string; + loomVideoId: string; + state: LoomBatchRowState; + videoId?: string; + error?: string; + existing?: boolean; +}; + +export type LoomBatchStatus = { + operationId: string; + organizationId: string; + state: LoomBatchStatusState; + phase: LoomBatchStatusPhase; + source: LoomBatchSource; + counts: LoomBatchStatusCounts; + currentRowNumber: number | null; + rows: LoomBatchStatusRow[]; + rowsTruncated: boolean; + error?: string; + createdAt: string; + updatedAt: string; + completedAt: string | null; +}; + +export type LoomBatchPayloadRow = LoomBatchRowInput & { + loomVideoId: string; +}; + +export type LoomBatchPayload = { + type: "loom_batch"; + version: 1; + requestId: string; + requestHash: string; + organizationId: string; + requestedByUserId: string; + defaultPublic: boolean; + rows: LoomBatchPayloadRow[]; + source: LoomBatchSource; + createdAt: string; +}; + +export type LoomBatchParentContext = Pick< + LoomBatchPayload, + "requestId" | "organizationId" | "requestedByUserId" | "defaultPublic" +>; + +export type LoomBatchChildDispatch = { + videoId: string; + ownerId: string; + rawFileKey: string; + bucketId: string | null; + loomVideoId: string; +}; + +export type LoomBatchChildPayload = { + type: "loom_child"; + version: 1; + parentId: string; + organizationId: string; + requestedByUserId: string; + row: Omit; + dispatch?: LoomBatchChildDispatch; +}; + +export type LoomBatchChildResult = { + videoId?: string; + existing?: boolean; +}; + +export type LoomBatchProgress = { + phase: "queued" | "preparing" | "dispatching" | "dispatched"; + totalRows: number; + preparedRows: number; + dispatchedRows: number; + readyRows: number; + failedRows: number; + uncertainRows: number; + currentRowNumber: number | null; +}; + +export const initialLoomBatchProgress = ( + payload: Pick, +): LoomBatchProgress => ({ + phase: "queued", + totalRows: payload.rows.length, + preparedRows: 0, + dispatchedRows: 0, + readyRows: 0, + failedRows: 0, + uncertainRows: 0, + currentRowNumber: null, +}); + +export const mergeLoomBatchProgress = ( + current: LoomBatchProgress, + next: LoomBatchProgress, +) => { + if (next.preparedRows < current.preparedRows) return current; + if ( + next.preparedRows === current.preparedRows && + next.dispatchedRows < current.dispatchedRows + ) { + return current; + } + return next; +}; + +export const isLoomBatchPayload = ( + value: unknown, +): value is LoomBatchPayload => { + if (!value || typeof value !== "object") return false; + const payload = value as Partial; + return ( + payload.type === "loom_batch" && + payload.version === 1 && + typeof payload.requestId === "string" && + typeof payload.requestHash === "string" && + /^[0-9a-f]{64}$/.test(payload.requestHash) && + typeof payload.organizationId === "string" && + typeof payload.requestedByUserId === "string" && + typeof payload.defaultPublic === "boolean" && + Array.isArray(payload.rows) && + payload.rows.length > 0 && + payload.rows.length <= MAX_LOOM_BATCH_ROWS && + payload.rows.every( + (row) => + row !== null && + typeof row === "object" && + Number.isInteger(row.rowNumber) && + typeof row.loomUrl === "string" && + typeof row.loomVideoId === "string" && + /^[0-9a-f]{32}$/.test(row.loomVideoId) && + typeof row.userEmail === "string" && + (row.spaceName === undefined || typeof row.spaceName === "string"), + ) && + payload.source !== undefined && + payload.source !== null && + typeof payload.source === "object" && + typeof payload.source.workspace === "string" && + typeof payload.source.from === "string" && + typeof payload.source.to === "string" && + Number.isInteger(payload.source.totalRows) && + Number.isInteger(payload.source.omittedRows) && + typeof payload.createdAt === "string" + ); +}; + +export const isLoomBatchChildPayload = ( + value: unknown, +): value is LoomBatchChildPayload => { + if (!value || typeof value !== "object") return false; + const payload = value as Partial; + const row = payload.row; + return ( + payload.type === "loom_child" && + payload.version === 1 && + typeof payload.parentId === "string" && + typeof payload.organizationId === "string" && + typeof payload.requestedByUserId === "string" && + row !== undefined && + row !== null && + typeof row === "object" && + Number.isInteger(row.rowNumber) && + typeof row.loomVideoId === "string" && + /^[0-9a-f]{32}$/.test(row.loomVideoId) && + typeof row.userEmail === "string" && + (row.spaceName === undefined || typeof row.spaceName === "string") && + (payload.dispatch === undefined || + (payload.dispatch !== null && + typeof payload.dispatch === "object" && + typeof payload.dispatch.videoId === "string" && + typeof payload.dispatch.ownerId === "string" && + typeof payload.dispatch.rawFileKey === "string" && + (payload.dispatch.bucketId === null || + typeof payload.dispatch.bucketId === "string") && + typeof payload.dispatch.loomVideoId === "string" && + payload.dispatch.loomVideoId === row.loomVideoId)) + ); +}; + +export const isLoomBatchProgress = ( + value: unknown, +): value is LoomBatchProgress => { + if (!value || typeof value !== "object") return false; + const progress = value as Partial; + return ( + progress.phase !== undefined && + ["queued", "preparing", "dispatching", "dispatched"].includes( + progress.phase, + ) && + Number.isInteger(progress.totalRows) && + Number.isInteger(progress.preparedRows) && + Number.isInteger(progress.dispatchedRows) && + Number.isInteger(progress.readyRows) && + Number.isInteger(progress.failedRows) && + Number.isInteger(progress.uncertainRows) && + (progress.currentRowNumber === null || + Number.isInteger(progress.currentRowNumber)) + ); +}; diff --git a/apps/web/workflows/import-loom-batch.ts b/apps/web/workflows/import-loom-batch.ts new file mode 100644 index 0000000000..a6d1e2698c --- /dev/null +++ b/apps/web/workflows/import-loom-batch.ts @@ -0,0 +1,135 @@ +import { sleep } from "workflow"; +import type { + LoomBatchParentContext, + LoomBatchPayloadRow, + LoomBatchProgress, +} from "@/lib/loom-batch"; +import { + claimLoomBatchOperation, + completeLoomBatchOperation, + dispatchLoomBatchChild, + failLoomBatchOperation, + type LoomBatchPreparation, + prepareLoomBatchRow, + setLoomBatchProgress, +} from "@/lib/loom-batch-import"; + +const advancePreparation = ( + progress: LoomBatchProgress, + row: LoomBatchPayloadRow, + preparation: LoomBatchPreparation, +): LoomBatchProgress => ({ + ...progress, + phase: + preparation.state === "dispatch" || preparation.state === "processing" + ? "dispatching" + : "preparing", + preparedRows: + progress.preparedRows + + (preparation.state === "dispatch" || preparation.state === "processing" + ? 0 + : 1), + readyRows: progress.readyRows + (preparation.state === "ready" ? 1 : 0), + failedRows: progress.failedRows + (preparation.state === "failed" ? 1 : 0), + uncertainRows: + progress.uncertainRows + (preparation.state === "uncertain" ? 1 : 0), + currentRowNumber: row.rowNumber, +}); + +async function claimOperation(operationId: string) { + "use step"; + + return claimLoomBatchOperation(operationId); +} + +async function prepareRow( + operationId: string, + parent: LoomBatchParentContext, + row: LoomBatchPayloadRow, + progress: LoomBatchProgress, +) { + "use step"; + + const preparation = await prepareLoomBatchRow(operationId, parent, row); + const nextProgress = advancePreparation(progress, row, preparation); + await setLoomBatchProgress(operationId, nextProgress); + return { preparation, progress: nextProgress }; +} + +async function dispatchRow( + operationId: string, + preparation: LoomBatchPreparation, + progress: LoomBatchProgress, +) { + "use step"; + + if (preparation.state === "dispatch") { + await dispatchLoomBatchChild(preparation.childOperationId); + } + if (preparation.state !== "dispatch" && preparation.state !== "processing") { + return progress; + } + const nextProgress: LoomBatchProgress = { + ...progress, + phase: "dispatching", + preparedRows: progress.preparedRows + 1, + dispatchedRows: progress.dispatchedRows + 1, + }; + await setLoomBatchProgress(operationId, nextProgress); + return nextProgress; +} + +async function completeOperation( + operationId: string, + progress: LoomBatchProgress, +) { + "use step"; + + await completeLoomBatchOperation(operationId, progress); +} + +async function failOperation(operationId: string, error: unknown) { + "use step"; + + await failLoomBatchOperation(operationId, error); +} + +export async function importLoomBatchWorkflow(input: { operationId: string }) { + "use workflow"; + + try { + const claimed = await claimOperation(input.operationId); + if (!claimed) return; + let progress = claimed.progress; + const parent = { + requestId: claimed.payload.requestId, + organizationId: claimed.payload.organizationId, + requestedByUserId: claimed.payload.requestedByUserId, + defaultPublic: claimed.payload.defaultPublic, + }; + for ( + let index = claimed.progress.preparedRows; + index < claimed.payload.rows.length; + index++ + ) { + const row = claimed.payload.rows[index]; + if (!row) continue; + const prepared = await prepareRow( + input.operationId, + parent, + row, + progress, + ); + progress = await dispatchRow( + input.operationId, + prepared.preparation, + prepared.progress, + ); + if (index < claimed.payload.rows.length - 1) await sleep("1500ms"); + } + await completeOperation(input.operationId, progress); + } catch (error) { + await failOperation(input.operationId, error); + throw error; + } +} From 1122fa8ec99f1714622d381835fbad9b890be58b Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:43:17 +0100 Subject: [PATCH 4/8] feat: show Loom import progress in dashboard --- .../import/loom/status/LoomBatchStatus.tsx | 686 ++++++++++++++++++ .../dashboard/import/loom/status/page.tsx | 23 + 2 files changed, 709 insertions(+) create mode 100644 apps/web/app/(org)/dashboard/import/loom/status/LoomBatchStatus.tsx create mode 100644 apps/web/app/(org)/dashboard/import/loom/status/page.tsx diff --git a/apps/web/app/(org)/dashboard/import/loom/status/LoomBatchStatus.tsx b/apps/web/app/(org)/dashboard/import/loom/status/LoomBatchStatus.tsx new file mode 100644 index 0000000000..bb0e975e1b --- /dev/null +++ b/apps/web/app/(org)/dashboard/import/loom/status/LoomBatchStatus.tsx @@ -0,0 +1,686 @@ +"use client"; + +import { Button, Card } from "@cap/ui"; +import { Effect } from "effect"; +import * as Cause from "effect/Cause"; +import { + AlertCircle, + ArrowLeft, + CheckCircle2, + CircleDashed, + Download, + LoaderCircle, + RefreshCw, +} from "lucide-react"; +import Link from "next/link"; +import { useEffectMutation, useEffectQuery } from "@/lib/EffectRuntime"; +import type { LoomBatchStatus as LoomBatchStatusData } from "@/lib/loom-batch"; + +const DISPLAY_ROW_LIMIT = 100; + +class BatchStatusError extends Error { + readonly status: number; + + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +function formatDate(value: string | null) { + if (!value) return "—"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "—"; + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(date); +} + +function formatSourceDate(value: string | null) { + if (!value) return "—"; + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { + const date = new Date(`${value}T00:00:00Z`); + if (Number.isNaN(date.getTime())) return "—"; + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeZone: "UTC", + }).format(date); + } + return formatDate(value); +} + +function unwrapCause(error: unknown) { + if (!Cause.isCause(error)) return error; + const failure = Cause.failureOption(error); + return failure._tag === "Some" ? failure.value : error; +} + +function errorStatus(error: unknown) { + const unwrappedError = unwrapCause(error); + return unwrappedError instanceof BatchStatusError + ? unwrappedError.status + : undefined; +} + +function isNonRetryableError(error: unknown) { + const status = errorStatus(error); + return status !== undefined && status >= 400 && status < 500; +} + +function errorMessage(error: unknown) { + if (errorStatus(error) === 401) { + return "Your Cap session has expired. Sign in again to view this import."; + } + if (errorStatus(error) === 403) { + return "You do not have permission to view this organization's Loom import."; + } + if (errorStatus(error) === 404) { + return "This Loom import could not be found for the selected organization."; + } + const unwrappedError = unwrapCause(error); + if (unwrappedError instanceof Error && unwrappedError.message) { + return unwrappedError.message; + } + return "This Loom import link is incomplete."; +} + +function phaseLabel(phase: LoomBatchStatusData["phase"]) { + if (phase === "queued") return "Queued"; + if (phase === "preparing") return "Preparing imports"; + if (phase === "dispatching") return "Starting Cap videos"; + if (phase === "monitoring") return "Finishing video processing"; + if (phase === "complete") return "Complete"; + return "Needs attention"; +} + +function stateLabel(state: LoomBatchStatusData["state"]) { + if (state === "queued") return "Queued"; + if (state === "running") return "In progress"; + if (state === "dispatched") return "Started"; + if (state === "complete") return "Complete"; + return "Needs attention"; +} + +function rowStateLabel(state: LoomBatchStatusData["rows"][number]["state"]) { + if (state === "queued") return "Queued"; + if (state === "processing") return "Processing"; + if (state === "ready") return "Ready"; + if (state === "failed") return "Failed"; + return "Uncertain"; +} + +function rowStateClass(state: LoomBatchStatusData["rows"][number]["state"]) { + if (state === "ready") return "bg-green-3 text-green-11"; + if (state === "failed") return "bg-red-3 text-red-11"; + if (state === "uncertain") return "bg-yellow-3 text-yellow-11"; + return "bg-blue-3 text-blue-11"; +} + +function csvCell(value: string | number | boolean | null | undefined) { + let text = String(value ?? ""); + let prefixLength = 0; + while (prefixLength < text.length) { + const code = text.charCodeAt(prefixLength); + if (!(code <= 32 || (code >= 127 && code <= 159))) break; + prefixLength += 1; + } + if (prefixLength < text.length && "=+-@".includes(text[prefixLength] ?? "")) + text = `'${text}`; + return `"${text.replaceAll('"', '""')}"`; +} + +const isNonNegativeInteger = (value: unknown): value is number => + typeof value === "number" && Number.isSafeInteger(value) && value >= 0; + +const isCalendarDate = (value: unknown): value is string => + typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value); + +const isRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === "object" && !Array.isArray(value); + +function isStatusRow(value: unknown): boolean { + if (!isRecord(value)) return false; + return ( + isNonNegativeInteger(value.rowNumber) && + typeof value.userEmail === "string" && + typeof value.loomVideoId === "string" && + typeof value.state === "string" && + ["queued", "processing", "ready", "failed", "uncertain"].includes( + value.state, + ) && + (value.spaceName === undefined || typeof value.spaceName === "string") && + (value.videoId === undefined || typeof value.videoId === "string") && + (value.error === undefined || typeof value.error === "string") && + (value.existing === undefined || typeof value.existing === "boolean") + ); +} + +function isStatusResponse(value: unknown): value is LoomBatchStatusData { + if (!isRecord(value)) return false; + const candidate = value; + const counts = candidate.counts; + const source = candidate.source; + if ( + !isRecord(counts) || + !isRecord(source) || + !Array.isArray(candidate.rows) + ) { + return false; + } + return ( + typeof candidate.operationId === "string" && + typeof candidate.organizationId === "string" && + typeof candidate.state === "string" && + ["queued", "running", "dispatched", "complete", "failed"].includes( + candidate.state, + ) && + typeof candidate.phase === "string" && + [ + "queued", + "preparing", + "dispatching", + "monitoring", + "complete", + "failed", + ].includes(candidate.phase) && + candidate.rows.every(isStatusRow) && + typeof candidate.rowsTruncated === "boolean" && + (candidate.currentRowNumber === null || + isNonNegativeInteger(candidate.currentRowNumber)) && + isNonNegativeInteger(counts.total) && + isNonNegativeInteger(counts.queued) && + isNonNegativeInteger(counts.processing) && + isNonNegativeInteger(counts.ready) && + isNonNegativeInteger(counts.failed) && + isNonNegativeInteger(counts.uncertain) && + typeof source.workspace === "string" && + isCalendarDate(source.from) && + isCalendarDate(source.to) && + isNonNegativeInteger(source.totalRows) && + isNonNegativeInteger(source.omittedRows) && + typeof candidate.createdAt === "string" && + typeof candidate.updatedAt === "string" && + (candidate.completedAt === null || + typeof candidate.completedAt === "string") && + (candidate.error === undefined || typeof candidate.error === "string") + ); +} + +function getBatchStatusEffect({ + operationId, + organizationId, + report, + signal, +}: { + operationId: string; + organizationId: string; + report?: boolean; + signal?: AbortSignal; +}) { + return Effect.gen(function* () { + const url = new URL( + "/api/extension/import-loom/batch", + window.location.origin, + ); + url.searchParams.set("operationId", operationId); + url.searchParams.set("organizationId", organizationId); + if (report) url.searchParams.set("report", "1"); + const response = yield* Effect.tryPromise({ + try: () => fetch(url, { cache: "no-store", signal }), + catch: (cause: unknown) => + cause instanceof Error + ? cause + : new Error("Failed to load Loom import status."), + }); + + if (!response.ok) { + return yield* Effect.fail( + new BatchStatusError( + response.status, + `Loom import status request failed (${response.status}).`, + ), + ); + } + + const responseBody: unknown = yield* Effect.tryPromise({ + try: () => response.json() as Promise, + catch: (cause: unknown) => + cause instanceof Error + ? cause + : new Error("The Loom import status response was invalid."), + }); + if (!isStatusResponse(responseBody)) { + return yield* Effect.fail( + new BatchStatusError( + 502, + "Cap returned an invalid Loom import status response.", + ), + ); + } + const status = responseBody; + if ( + status.operationId !== operationId || + status.organizationId !== organizationId + ) { + return yield* Effect.fail( + new BatchStatusError( + 502, + "Cap returned a status for a different Loom import.", + ), + ); + } + if ( + report && + (status.rowsTruncated || status.rows.length < status.counts.total) + ) { + return yield* Effect.fail( + new BatchStatusError( + 502, + "Cap returned an incomplete Loom import report.", + ), + ); + } + + return status; + }); +} + +function buildCsvReport(status: LoomBatchStatusData) { + const header = [ + "rowNumber", + "userEmail", + "spaceName", + "loomVideoId", + "state", + "videoId", + "existing", + "error", + ].join(","); + const rows = status.rows.map((row) => + [ + row.rowNumber, + row.userEmail, + row.spaceName, + row.loomVideoId, + row.state, + row.videoId, + row.existing, + row.error, + ] + .map(csvCell) + .join(","), + ); + const blob = new Blob([`\uFEFF${[header, ...rows].join("\r\n")}`], { + type: "text/csv;charset=utf-8", + }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `loom-import-${status.operationId.replace(/[^a-zA-Z0-9_-]/g, "_")}.csv`; + link.click(); + window.setTimeout(() => URL.revokeObjectURL(url), 10_000); +} + +function Stat({ label, value }: { label: string; value: number }) { + return ( +
+

+ {value.toLocaleString()} +

+

{label}

+
+ ); +} + +export function LoomBatchStatus({ + operationId, + organizationId, +}: { + operationId?: string; + organizationId?: string; +}) { + const query = useEffectQuery({ + queryKey: ["loom-batch-status", operationId, organizationId], + queryFn: (context) => { + if (!operationId || !organizationId) { + return Effect.fail( + new BatchStatusError(400, "This Loom import link is incomplete."), + ); + } + return getBatchStatusEffect({ + operationId, + organizationId, + signal: context.signal, + }); + }, + enabled: Boolean(operationId && organizationId), + throwOnDefect: true, + staleTime: 0, + retry: (failureCount, error) => + !isNonRetryableError(error) && failureCount < 3, + refetchInterval: (currentQuery) => { + if (isNonRetryableError(currentQuery.state.error)) return false; + const status = currentQuery.state.data; + return status && + (status.phase === "monitoring" || + status.state === "queued" || + status.state === "running" || + status.counts.processing > 0) + ? status.counts.total > 500 + ? 15_000 + : 3_000 + : false; + }, + }); + const reportMutation = useEffectMutation({ + throwOnDefect: true, + mutationFn: () => { + if (!operationId || !organizationId) { + return Effect.fail( + new BatchStatusError(400, "This Loom import link is incomplete."), + ); + } + return getBatchStatusEffect({ + operationId, + organizationId, + report: true, + }); + }, + onSuccess: (report) => buildCsvReport(report), + }); + + if (query.isLoading) { + return ( +
+ + Loading Loom import status... +
+ ); + } + + if (query.isError || !query.data) { + const status = errorStatus(query.error); + return ( +
+ + + Back to Cap videos + + +
+ +
+

+ Unable to load Loom import +

+

+ {errorMessage(query.error)} +

+ {status === 401 && ( + + )} + {status !== 401 && status !== 403 && status !== 404 && ( + + )} +
+
+
+
+ ); + } + + const status = query.data; + const displayedRows = status.rows.slice(0, DISPLAY_ROW_LIMIT); + const progressTotal = status.counts.total; + const progressValue = Math.min( + status.counts.ready + status.counts.failed + status.counts.uncertain, + progressTotal, + ); + const progressPercent = + progressTotal === 0 + ? 100 + : Math.round((progressValue / progressTotal) * 100); + const isMonitoring = status.phase === "monitoring"; + const hasIssues = + status.phase === "failed" || + status.state === "failed" || + status.counts.failed > 0 || + status.counts.uncertain > 0; + const hasPendingRows = + status.counts.queued > 0 || status.counts.processing > 0; + const isTerminal = + !hasPendingRows && + (status.state === "complete" || + status.state === "failed" || + status.phase === "complete" || + status.phase === "failed"); + const hasOngoingIssues = hasIssues && !isTerminal; + + return ( +
+
+
+ + + Back to Cap videos + +

+ Importing your Loom videos +

+

+ {phaseLabel(status.phase)} · {stateLabel(status.state)} +

+
+
+ + {reportMutation.isError && ( +

+ {errorMessage(reportMutation.error)} +

+ )} +
+
+ + +
+
+

Loom workspace

+

+ {status.source.workspace} +

+
+
+

Organization scope

+

+ {status.organizationId} +

+
+
+

Source window

+

+ {formatSourceDate(status.source.from)} –{" "} + {formatSourceDate(status.source.to)} +

+
+
+

Last updated

+

+ {formatDate(status.updatedAt)} +

+
+
+

+ {status.source.omittedRows > 0 + ? `${status.source.omittedRows.toLocaleString()} source records were not importable. The full source report remains in the extension.` + : "All eligible source rows were included."}{" "} + Organization scope comes from the import receipt; Cap will not switch + your active organization on this page. +

+
+ + +
+ {hasIssues ? ( + + ) : isMonitoring ? ( + + ) : status.phase === "complete" ? ( + + ) : ( + + )} +
+

+ {hasOngoingIssues + ? "Import is continuing with issues" + : hasIssues + ? "Finished with issues" + : isMonitoring + ? "Cap is finishing video processing" + : status.phase === "complete" + ? "Loom import complete" + : "Cap is preparing your import"} +

+

+ {hasOngoingIssues + ? "Some rows need attention while the import continues. Check Cap before retrying, and do not blindly retry uncertain rows." + : hasIssues + ? "Check Cap before retrying. Do not blindly retry uncertain rows." + : isMonitoring + ? "The browser handoff is finished. You can leave this page open or come back later." + : "Your Loom account is not changed while Cap imports these videos."} +

+
+
+
+
+ + {progressValue.toLocaleString()} of{" "} + {progressTotal.toLocaleString()} rows classified + + {progressPercent}% +
+
+
+
+
+ {status.currentRowNumber !== null && ( +

+ Current row: {status.currentRowNumber.toLocaleString()} +

+ )} + + +
+ + + + + +
+ + {status.error && ( +
+ {status.error} +
+ )} + + +
+
+

Row results

+

+ {status.rowsTruncated + ? `Showing the first ${DISPLAY_ROW_LIMIT.toLocaleString()} rows. Download the report for all rows.` + : "Download a local copy if you need to review the result later."} +

+
+ + Start another import + +
+
+ + + + + + + + + + + + {displayedRows.map((row) => ( + + + + + + + + ))} + +
RowOwnerSpaceStatusDetails
+ {row.rowNumber} + {row.userEmail} + {row.spaceName || "—"} + + + {rowStateLabel(row.state)} + + + {row.error || + (row.existing ? "Already imported" : row.videoId || "—")} +
+
+
+ +

+ Cap does not change or delete your Loom videos, and Loom folders are not + copied. Imported videos use Cap's configured default privacy. For + uncertain rows, check Cap before contacting support instead of blindly + retrying the import. +

+
+ ); +} diff --git a/apps/web/app/(org)/dashboard/import/loom/status/page.tsx b/apps/web/app/(org)/dashboard/import/loom/status/page.tsx new file mode 100644 index 0000000000..42ac4f39c0 --- /dev/null +++ b/apps/web/app/(org)/dashboard/import/loom/status/page.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from "next"; +import { LoomBatchStatus } from "./LoomBatchStatus"; + +export const metadata: Metadata = { + title: "Loom import status — Cap", +}; + +function firstParam(value: string | string[] | undefined) { + return Array.isArray(value) ? value[0] : value; +} + +export default async function Page({ + searchParams, +}: PageProps<"/dashboard/import/loom/status">) { + const params = await searchParams; + + return ( + + ); +} From 450f010c13c6bd178c6ebe18d19339d679a1de8f Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:43:17 +0100 Subject: [PATCH 5/8] feat: add extension CSV inventory tools --- apps/chrome-extension/import.html | 12 + apps/chrome-extension/src/importer/api.ts | 117 ++ .../src/importer/inventory-table.tsx | 353 +++++ .../src/importer/inventory.test.ts | 801 +++++++++++ .../src/importer/inventory.ts | 534 +++++++ apps/chrome-extension/src/importer/main.tsx | 1253 +++++++++++++++++ .../src/importer/mapping-panel.tsx | 157 +++ .../src/importer/persistence.test.ts | 105 ++ .../src/importer/persistence.ts | 240 ++++ .../src/importer/queue.test.ts | 204 +++ apps/chrome-extension/src/importer/queue.ts | 64 + apps/chrome-extension/src/importer/styles.css | 1247 ++++++++++++++++ 12 files changed, 5087 insertions(+) create mode 100644 apps/chrome-extension/import.html create mode 100644 apps/chrome-extension/src/importer/api.ts create mode 100644 apps/chrome-extension/src/importer/inventory-table.tsx create mode 100644 apps/chrome-extension/src/importer/inventory.test.ts create mode 100644 apps/chrome-extension/src/importer/inventory.ts create mode 100644 apps/chrome-extension/src/importer/main.tsx create mode 100644 apps/chrome-extension/src/importer/mapping-panel.tsx create mode 100644 apps/chrome-extension/src/importer/persistence.test.ts create mode 100644 apps/chrome-extension/src/importer/persistence.ts create mode 100644 apps/chrome-extension/src/importer/queue.test.ts create mode 100644 apps/chrome-extension/src/importer/queue.ts create mode 100644 apps/chrome-extension/src/importer/styles.css diff --git a/apps/chrome-extension/import.html b/apps/chrome-extension/import.html new file mode 100644 index 0000000000..fd44ac8a80 --- /dev/null +++ b/apps/chrome-extension/import.html @@ -0,0 +1,12 @@ + + + + + + Import from Loom · Cap + + +
+ + + diff --git a/apps/chrome-extension/src/importer/api.ts b/apps/chrome-extension/src/importer/api.ts new file mode 100644 index 0000000000..7f6ae81d59 --- /dev/null +++ b/apps/chrome-extension/src/importer/api.ts @@ -0,0 +1,117 @@ +import { ApiRequestError } from "../shared/api"; +import type { ExtensionAuth, ExtensionSettings } from "../shared/types"; + +export type ImportContext = { + user: { id: string; email: string }; + organizations: { id: string; name: string; canImport: boolean }[]; + activeOrganizationId: string; + isPro: boolean; + defaultPublic: boolean; + maxRows: number; +}; + +export type ImportResponse = { + success: boolean; + videoId?: string; + error?: string; + existing?: boolean; + uncertain?: boolean; +}; + +type Connection = { settings: ExtensionSettings; auth: ExtensionAuth }; + +const isObject = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const request = async ( + { settings, auth }: Connection, + body?: unknown, +): Promise => { + const response = await fetch( + new URL("/api/extension/import-loom", settings.apiBaseUrl), + { + method: body === undefined ? "GET" : "POST", + headers: { + Authorization: `Bearer ${auth.authApiKey}`, + "Content-Type": "application/json", + }, + body: body === undefined ? undefined : JSON.stringify(body), + credentials: "omit", + redirect: "error", + cache: "no-store", + signal: AbortSignal.timeout(body === undefined ? 15_000 : 125_000), + }, + ); + if (!response.ok) { + const messages: Record = { + 400: "Cap rejected this import. Check the video link, owner and Space.", + 401: "Your Cap session expired. Sign in again to continue.", + 403: "Importing requires a Cap Pro account and an organization admin or owner role.", + 404: "This Cap server does not support the extension importer yet.", + 429: "Too many import requests. Wait a moment before continuing.", + }; + throw new ApiRequestError( + response.status, + messages[response.status] ?? + "Cap could not confirm the request. Check your dashboard before trying again.", + ); + } + return response.json(); +}; + +export const fetchImportContext = async ( + connection: Connection, +): Promise => { + const data = await request(connection); + if ( + !isObject(data) || + !isObject(data.user) || + typeof data.user.id !== "string" || + typeof data.user.email !== "string" || + !Array.isArray(data.organizations) || + !data.organizations.every( + (org: unknown) => + isObject(org) && + typeof org.id === "string" && + typeof org.name === "string" && + typeof org.canImport === "boolean", + ) || + typeof data.activeOrganizationId !== "string" || + typeof data.isPro !== "boolean" || + typeof data.defaultPublic !== "boolean" || + typeof data.maxRows !== "number" || + !Number.isInteger(data.maxRows) || + data.maxRows < 1 || + data.maxRows > 500 + ) { + throw new Error( + "Cap returned an invalid importer response. Try reconnecting.", + ); + } + return data as ImportContext; +}; + +export const importLoomRow = async ( + connection: Connection, + organizationId: string, + row: { + rowNumber: number; + loomUrl: string; + userEmail: string; + spaceName?: string; + }, +): Promise => { + const data = await request(connection, { organizationId, row }); + if ( + !isObject(data) || + typeof data.success !== "boolean" || + (data.videoId !== undefined && typeof data.videoId !== "string") || + (data.error !== undefined && typeof data.error !== "string") || + (data.existing !== undefined && typeof data.existing !== "boolean") || + (data.uncertain !== undefined && typeof data.uncertain !== "boolean") || + (data.success && !data.videoId) + ) { + throw new Error("Cap did not confirm this import. Check your dashboard."); + } + return data as ImportResponse; +}; diff --git a/apps/chrome-extension/src/importer/inventory-table.tsx b/apps/chrome-extension/src/importer/inventory-table.tsx new file mode 100644 index 0000000000..6fdb65521e --- /dev/null +++ b/apps/chrome-extension/src/importer/inventory-table.tsx @@ -0,0 +1,353 @@ +import { + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, + ExternalLinkIcon, + FileVideoIcon, + SearchIcon, +} from "lucide-react"; +import { Fragment, useDeferredValue, useMemo, useState } from "react"; +import type { InventoryRow } from "./inventory"; +import type { ImportOutcome } from "./queue"; + +const PAGE_SIZE = 50; +type Filter = "all" | "ready" | "attention" | "selected"; + +export const outcomeLabel = (outcome: ImportOutcome) => { + switch (outcome.state) { + case "sending": + return "Starting…"; + case "started": + return "Started in Cap"; + case "existing": + return "Already in Cap"; + case "failed": + return "Not started"; + case "uncertain": + return "Check in Cap"; + } +}; + +const issueLabel = (row: InventoryRow) => { + switch (row.issue) { + case "missing-link": + return "Missing link"; + case "invalid-link": + return "Invalid link"; + case "invalid-owner": + return "Needs owner"; + case "duplicate": + return "Duplicate"; + default: + return row.issue + ? "Needs attention" + : row.reviewRequired + ? "Needs review" + : "Ready"; + } +}; + +export const canSubmitRow = (outcome?: ImportOutcome) => + !outcome || outcome.state === "failed"; + +export const InventoryTable = ({ + rows, + headers, + selected, + outcomes, + disabled, + apiBaseUrl, + onSelect, +}: { + rows: InventoryRow[]; + headers: string[]; + selected: Set; + outcomes: Record; + disabled: boolean; + apiBaseUrl: string; + onSelect: (records: number[], value: boolean) => void; +}) => { + const [query, setQuery] = useState(""); + const [filter, setFilter] = useState("all"); + const [page, setPage] = useState(0); + const [expanded, setExpanded] = useState(null); + const deferredQuery = useDeferredValue(query.trim().toLowerCase()); + const filtered = useMemo( + () => + rows.filter((row) => { + if (filter === "ready" && (row.issue || row.reviewRequired)) + return false; + if (filter === "attention" && !row.issue && !row.reviewRequired) + return false; + if ( + filter === "selected" && + (row.issue || !selected.has(row.sourceRecord)) + ) + return false; + return ( + !deferredQuery || + [ + row.title, + row.url, + row.originalOwner, + row.ownerEmail, + row.spaceName, + ].some((value) => value.toLowerCase().includes(deferredQuery)) + ); + }), + [rows, filter, selected, deferredQuery], + ); + const currentPage = Math.min( + page, + Math.max(0, Math.ceil(filtered.length / PAGE_SIZE) - 1), + ); + const visible = filtered.slice( + currentPage * PAGE_SIZE, + (currentPage + 1) * PAGE_SIZE, + ); + const selectable = visible.filter( + (row) => + !row.issue && + !row.reviewRequired && + canSubmitRow(outcomes[row.sourceRecord]), + ); + const allSelected = + selectable.length > 0 && + selectable.every((row) => selected.has(row.sourceRecord)); + return ( +
+
+
+ {( + [ + ["all", "All videos"], + ["ready", "Ready"], + ["attention", "Needs attention"], + ["selected", "Selected"], + ] as const + ).map(([value, label]) => ( + + ))} +
+ +
+
+ + + + + + + + + + + + + {visible.map((row) => { + const outcome = outcomes[row.sourceRecord]; + return ( + + + + + + + + + + {expanded === row.sourceRecord ? ( + + + + ) : null} + + ); + })} + {!visible.length ? ( + + + + ) : null} + +
+ + onSelect( + selectable.map((row) => row.sourceRecord), + event.target.checked, + ) + } + /> + VideoCap ownerSpaceStatus + Source details +
+ + onSelect([row.sourceRecord], event.target.checked) + } + /> + +
+ + + {row.title || + `Untitled video · record ${row.sourceRecord}`} + +
+
+ {row.videoId ? ( + + View on Loom{" "} + + + ) : ( + "No usable video link" + )} + {row.duration ? {row.duration} : null} +
+
+ {row.ownerEmail || "Not assigned"} + {row.originalOwner && + row.originalOwner !== row.ownerEmail ? ( + From {row.originalOwner} + ) : null} + + {row.spaceName || No Space} + + + {outcome ? outcomeLabel(outcome) : issueLabel(row)} + + {outcome?.message ? ( +

{outcome.message}

+ ) : null} + {outcome?.videoId ? ( + + Open in Cap + + ) : null} +
+ +
+
+ Source record {row.sourceRecord} + {outcome?.message || row.detail ? ( +

{outcome?.message || row.detail}

+ ) : null} +
+ {headers.map((header, index) => ( +
+
{header}
+
{row.raw[index] || "—"}
+
+ ))} +
+
+
+ No videos match this view. +
+
+
+ + {filtered.length + ? `${currentPage * PAGE_SIZE + 1}–${Math.min((currentPage + 1) * PAGE_SIZE, filtered.length)}` + : "0"}{" "} + of {filtered.length.toLocaleString()} records + +
+ + + Page {currentPage + 1} of{" "} + {Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))} + + +
+
+
+ ); +}; diff --git a/apps/chrome-extension/src/importer/inventory.test.ts b/apps/chrome-extension/src/importer/inventory.test.ts new file mode 100644 index 0000000000..e46c3e0c13 --- /dev/null +++ b/apps/chrome-extension/src/importer/inventory.test.ts @@ -0,0 +1,801 @@ +import { describe, expect, it } from "vitest"; +import { + buildInventory, + detectColumns, + exportImportCsv, + exportInventoryCsv, + type InventoryOptions, + type InventoryTable, + MAX_FILE_BYTES, + MAX_ROWS, + parseInventory, + toImportRows, +} from "./inventory"; + +const firstId = "0123456789abcdef0123456789abcdef"; +const secondId = "fedcba9876543210fedcba9876543210"; +const firstUrl = `https://www.loom.com/share/${firstId}`; +const secondUrl = `https://www.loom.com/share/${secondId}`; +const owner = "alex@example.test"; +const options: InventoryOptions = { + ownerEmail: "", + spaceName: "", + ownerMode: "column", + spaceMode: "none", +}; + +function prepare( + table: InventoryTable, + overrides: Partial = {}, +) { + return buildInventory(table, detectColumns(table.headers), { + ...options, + ...overrides, + }); +} + +function tableFor(records: string[][]): InventoryTable { + return { headers: ["Video Link", "Creator Email", "Video Name"], records }; +} + +describe("inventory parsing", () => { + it("reads BOM, CRLF, quoted commas, escaped quotes, and multiline fields", () => { + const table = parseInventory( + `\uFEFFVideo Link,Video Name,Creator Email\r\n${firstUrl},"A, ""quoted""\r\nwalkthrough",${owner}\r\n`, + "loom.CSV", + ); + + expect(table).toEqual({ + headers: ["Video Link", "Video Name", "Creator Email"], + records: [[firstUrl, 'A, "quoted"\r\nwalkthrough', owner]], + }); + }); + + it("reads quoted tabs and CR-only records in TSV", () => { + expect( + parseInventory('title\towner\r"A\tB"\talex@example.test\r', "loom.tsv"), + ).toEqual({ + headers: ["title", "owner"], + records: [["A\tB", owner]], + }); + }); + + it("detects tab-separated text when no known extension is supplied", () => { + expect( + parseInventory("title\towner\nDemo\talex@example.test", "inventory.txt") + .records, + ).toEqual([["Demo", owner]]); + }); + + it("preserves original header spacing and pads short records", () => { + expect( + parseInventory(" URL ,Title,Owner\nvideo,Demo", "inventory.csv"), + ).toEqual({ + headers: [" URL ", "Title", "Owner"], + records: [["video", "Demo", ""]], + }); + }); + + it("retains missing-link metadata records without deduplicating them", () => { + const table = parseInventory( + "Video Link,Video Name\n,Unlinked demo\n,Unlinked demo\n", + "inventory.csv", + ); + expect(table.records).toEqual([ + ["", "Unlinked demo"], + ["", "Unlinked demo"], + ]); + }); + + it("retains interior empty records but creates no phantom EOF record", () => { + expect( + parseInventory( + "Title,Owner\nOne,alex@example.test\n\nTwo,alex@example.test\n", + "inventory.csv", + ).records, + ).toEqual([ + ["One", owner], + ["", ""], + ["Two", owner], + ]); + }); + + it.each([ + 'Header\n"unclosed', + 'Header\nfoo"bar', + 'Header\n"closed"unexpected', + 'Header\n "misplaced"', + ])("rejects malformed quotes: %s", (source) => { + expect(() => parseInventory(source, "inventory.csv")).toThrow( + /quotes|quoted/i, + ); + }); + + it("rejects overflowing records instead of silently dropping values", () => { + expect(() => parseInventory("A,B\none,two,three", "inventory.csv")).toThrow( + /Record 1 has more values/, + ); + }); + + it.each(["A,\none,two", "A,a\none,two", "A, A \none,two"])( + "rejects blank or ambiguous headers: %s", + (source) => { + expect(() => parseInventory(source, "inventory.csv")).toThrow(/header/i); + }, + ); + + it.each(["", "\uFEFF", " \r\n\t", "A,B", "A,B\n", "A,B\n,\n,"])( + "rejects empty or header-only input: %s", + (source) => { + expect(() => parseInventory(source, "inventory.csv")).toThrow( + /empty|no data/i, + ); + }, + ); + + it("rejects files over the UTF-8 byte limit, not just character count", () => { + expect(() => + parseInventory("x".repeat(MAX_FILE_BYTES + 1), "inventory.csv"), + ).toThrow(/10 MB/); + expect(() => + parseInventory( + `Header\n${"é".repeat(MAX_FILE_BYTES / 2)}`, + "inventory.csv", + ), + ).toThrow(/10 MB/); + }); + + it("accepts the record limit and rejects the next record", () => { + const source = `Header\n${"value\n".repeat(MAX_ROWS)}`; + expect(parseInventory(source, "inventory.csv").records).toHaveLength( + MAX_ROWS, + ); + expect(() => parseInventory(`${source}extra`, "inventory.csv")).toThrow( + /50,000 records/, + ); + }); + + it("bounds column padding before a huge header can exhaust memory", () => { + const source = `${Array.from({ length: 257 }, (_, index) => `Column ${index}`).join(",")}\nvalue`; + expect(() => parseInventory(source, "inventory.csv")).toThrow( + /256 columns/, + ); + }); + + it("reads flat JSON records with stable union headers and scalar values", () => { + const table = parseInventory( + JSON.stringify([ + { video_link: firstUrl, title: "Demo", duration: 30, approved: true }, + { video_link: null, owner: owner, approved: false }, + ]), + "inventory.JSON", + ); + + expect(table).toEqual({ + headers: ["video_link", "title", "duration", "approved", "owner"], + records: [ + [firstUrl, "Demo", "30", "true", ""], + ["", "", "", "false", owner], + ], + }); + }); + + it("reads the videos wrapper and detects JSON without an extension", () => { + expect( + parseInventory(`{ "videos": [{ "title": "Demo" }] }`, "inventory") + .records, + ).toEqual([["Demo"]]); + }); + + it("does not read inherited object values for absent JSON keys", () => { + expect( + parseInventory( + '[{"__proto__":"source","constructor":"plain"},{"title":"Demo"}]', + "inventory.json", + ).records, + ).toEqual([ + ["source", "plain", ""], + ["", "", "Demo"], + ]); + }); + + it.each([ + "not-json", + "null", + "{}", + '{"videos":{}}', + "[null]", + "[1]", + "[[1]]", + '[{"title":{"nested":true}}]', + '[{"title":["nested"]}]', + ])("rejects malformed or nested JSON: %s", (source) => { + expect(() => parseInventory(source, "inventory.json")).toThrow( + /JSON|flat|nested/i, + ); + }); + + it.each(["[]", '[{"title":null}]', '[{"title":""}]'])( + "rejects JSON without data: %s", + (source) => { + expect(() => parseInventory(source, "inventory.json")).toThrow( + /header|no data/i, + ); + }, + ); + + it("rejects too many JSON records before building the table", () => { + const source = JSON.stringify( + Array.from({ length: MAX_ROWS + 1 }, () => ({ title: "Demo" })), + ); + expect(() => parseInventory(source, "inventory.json")).toThrow( + /50,000 records/, + ); + }); + + it.each(['[{"id":9007199254740993}]', '[{"duration":1e400}]'])( + "rejects unsafe JSON numbers instead of rounding source metadata: %s", + (source) => { + expect(() => parseInventory(source, "inventory.json")).toThrow( + /unsafe number/, + ); + }, + ); + + it("does not mistake a bracket in a CSV header for JSON", () => { + expect( + parseInventory("[Video],Owner\nDemo,alex@example.test", "inventory.csv") + .headers, + ).toEqual(["[Video]", "Owner"]); + }); +}); + +describe("column mapping", () => { + it("recognizes native Loom columns and prefers Creator Email to Creator", () => { + expect( + detectColumns([ + "Video Name", + "Creator", + "Video Link", + "Creator Email", + "Created At", + "Duration", + "Folder", + ]), + ).toEqual({ + url: 2, + title: 0, + owner: 3, + space: -1, + createdAt: 4, + duration: 5, + }); + }); + + it("recognizes canonical columns and review owner email", () => { + expect( + detectColumns(["loom_video_url", "user_email", "space_name"]).owner, + ).toBe(1); + expect( + detectColumns(["loom_video_url", "original_creator_email", "space_name"]), + ).toMatchObject({ url: 0, owner: 1, space: 2 }); + expect(detectColumns(["Creator"]).owner).toBe(0); + }); + + it("never silently maps folder paths or folder names into Cap Spaces", () => { + expect( + detectColumns(["Folder", "folder_path", "folder_name", "Space"]).space, + ).toBe(-1); + expect(detectColumns([" Folder ", " SPACE NAME "]).space).toBe(1); + }); + + it("leaves unknown columns unset", () => { + expect(detectColumns(["Unrelated"])).toEqual({ + url: -1, + title: -1, + owner: -1, + space: -1, + createdAt: -1, + duration: -1, + }); + }); +}); + +describe("inventory preparation", () => { + it.each([ + firstUrl, + `http://loom.com/share/${firstId}/`, + `https://loom.com/embed/${firstId}?sid=synthetic#section`, + `https://www.loom.com/embed/${firstId.toUpperCase()}/?from=inventory`, + `HTTPS://WWW.LOOM.COM/share/${firstId}`, + ])("canonicalizes supported Loom links: %s", (url) => { + const [row] = prepare(tableFor([[url, owner, "Demo"]])); + expect(row).toMatchObject({ + url: firstUrl, + videoId: firstId, + issue: null, + sourceRecord: 1, + index: 0, + }); + }); + + it.each([ + `https://loom.com.evil.test/share/${firstId}`, + `https://evil-loom.com/share/${firstId}`, + `https://cdn.loom.com/share/${firstId}`, + `https://loom.com./share/${firstId}`, + `https://%6coom.com/share/${firstId}`, + `https://user:password@loom.com/share/${firstId}`, + `https://@loom.com/share/${firstId}`, + `https://www.loom.com:443/share/${firstId}`, + `http://loom.com:80/share/${firstId}`, + `ftp://loom.com/share/${firstId}`, + `//loom.com/share/${firstId}`, + `loom.com/share/${firstId}`, + `https:////loom.com/share/${firstId}`, + `https://loom.com\\share\\${firstId}`, + `https://loom.com/share/${firstId}/extra`, + `https://loom.com/share/${firstId}extra`, + `https://loom.com/library/${firstId}`, + "https://loom.com/share/short-id", + `https://loom.com/share/${"g".repeat(32)}`, + `https://loom.com/share/${firstId}?line\nbreak`, + "javascript:alert(1)", + ])("rejects malformed, lookalike, or unsupported links: %s", (url) => { + const [row] = prepare(tableFor([[url, owner, "Demo"]])); + expect(row.issue).toBe("invalid-link"); + expect(row.videoId).toBeNull(); + }); + + it("removes only native Loom text escaping and leaves the source intact", () => { + const table: InventoryTable = { + headers: [ + "Video Link", + "Creator Email", + "Video Name", + "Created At", + "Duration", + "space_name", + ], + records: [ + [ + firstUrl, + "Alex\\@example.test", + "Demo \\- A \\| B \\! C:\\files", + "2026\\-01\\-02", + "00:30", + "Team \\| Notes", + ], + ], + }; + const original = structuredClone(table); + const [row] = prepare(table, { spaceMode: "column" }); + + expect(row).toMatchObject({ + originalOwner: "Alex@example.test", + ownerEmail: owner, + title: "Demo - A | B \\! C:\\files", + createdAt: "2026-01-02", + duration: "00:30", + spaceName: "Team | Notes", + issue: null, + }); + expect(table).toEqual(original); + expect(row.raw).toEqual(original.records[0]); + expect(row.raw).not.toBe(table.records[0]); + }); + + it("keeps original ownership when an explicit owner override is chosen", () => { + const [row] = prepare( + tableFor([[firstUrl, "source@example.test", "Demo"]]), + { + ownerMode: "override", + ownerEmail: " Alex@Example.Test ", + spaceMode: "override", + spaceName: " Product / Guides ", + }, + ); + expect(row).toMatchObject({ + originalOwner: "source@example.test", + ownerEmail: owner, + spaceName: "Product / Guides", + issue: null, + }); + }); + + it("does not silently fall back to an override when column ownership is missing", () => { + const [row] = prepare(tableFor([[firstUrl, "", "Demo"]]), { + ownerEmail: owner, + }); + expect(row.issue).toBe("invalid-owner"); + expect(row.ownerEmail).toBe(""); + }); + + it.each([ + "", + "Alex", + "alex@example", + "alex@@example.test", + "alex @example.test", + "alex\u0000@example.test", + `${"x".repeat(256)}@example.test`, + ])("blocks invalid owner email: %s", (email) => { + expect(prepare(tableFor([[firstUrl, email, "Demo"]]))[0].issue).toBe( + "invalid-owner", + ); + }); + + it("leaves Spaces empty unless column or override mapping is explicitly selected", () => { + const table = { + headers: ["Video Link", "Creator", "space_name"], + records: [[firstUrl, owner, "Product"]], + }; + expect(prepare(table)[0].spaceName).toBe(""); + expect(prepare(table, { spaceMode: "column" })[0].spaceName).toBe( + "Product", + ); + }); + + it("normalizes Space whitespace consistently before validating assignments", () => { + const [row] = prepare(tableFor([[firstUrl, owner, "Demo"]]), { + spaceMode: "override", + spaceName: " Product\t\n Guides ", + }); + expect(row.spaceName).toBe("Product Guides"); + expect(row.issue).toBeNull(); + }); + + it.each([ + "x".repeat(256), + "Product\u0000Guides", + "Product\u007fGuides", + "Product\u0080Guides", + ])("blocks invalid Space assignments: %s", (spaceName) => { + const rows = prepare(tableFor([[firstUrl, owner, "Demo"]]), { + spaceMode: "override", + spaceName, + }); + expect(rows[0].issue).toBe("invalid-space"); + expect(() => toImportRows(rows)).toThrow(/255 characters/); + }); + + it("does not guess links or IDs from titles, other metadata, or repeated missing records", () => { + const rows = prepare( + tableFor([ + ["", owner, firstId], + ["", owner, firstId], + ["", owner, firstUrl], + ]), + ); + expect(rows.map((row) => row.issue)).toEqual([ + "missing-link", + "missing-link", + "missing-link", + ]); + expect(rows.map((row) => row.videoId)).toEqual([null, null, null]); + expect(rows.map((row) => row.sourceRecord)).toEqual([1, 2, 3]); + }); + + it("marks later duplicates across share and embed URLs while retaining every source record", () => { + const rows = prepare( + tableFor([ + [firstUrl, owner, "First"], + [ + `https://loom.com/embed/${firstId.toUpperCase()}?x=1`, + owner, + "Second", + ], + [secondUrl, owner, "Third"], + ]), + ); + expect(rows.map((row) => row.issue)).toEqual([null, "duplicate", null]); + expect(rows[1].detail).toContain("record 1"); + expect(rows[1].title).toBe("Second"); + }); + + it("does not hide first-record ownership problems by substituting a later duplicate", () => { + const rows = prepare( + tableFor([ + [firstUrl, "", "First"], + [firstUrl, owner, "Later"], + ]), + ); + expect(rows.map((row) => row.issue)).toEqual([ + "invalid-owner", + "duplicate", + ]); + }); + + it.each([ + "", + " ", + "pending", + "excluded", + "rejected", + "false", + "no", + "unknown", + "0", + "future-status", + ])("requires explicit review for decision %s", (decision) => { + const table = { + headers: ["Video Link", "Creator", "review_decision"], + records: [[firstUrl, owner, decision]], + }; + expect(prepare(table)[0]).toMatchObject({ + issue: null, + reviewRequired: true, + }); + }); + + it.each(["approved", "include", "yes", "true", " APPROVED "])( + "permits automatic selection for decision %s", + (decision) => { + const table = { + headers: ["Video Link", "Creator", "decision"], + records: [[firstUrl, owner, decision]], + }; + expect(prepare(table)[0].reviewRequired).toBe(false); + }, + ); + + it("fails closed when multiple review columns disagree", () => { + const table = { + headers: ["Video Link", "Creator", "import", "Include"], + records: [[firstUrl, owner, "yes", "no"]], + }; + expect(prepare(table)[0].reviewRequired).toBe(true); + }); + + it.each(["cap_import_status", "_cap_import_status", "__CAP_IMPORT_STATUS"])( + "requires explicit review when reopening an audit report with %s", + (header) => { + for (const state of [ + "not-submitted", + "started", + "existing", + "uncertain", + ]) { + const table = { + headers: ["Video Link", "Creator", header], + records: [[firstUrl, owner, state]], + }; + expect(prepare(table)[0].reviewRequired).toBe(true); + } + }, + ); +}); + +describe("import preparation and exports", () => { + it("produces only the canonical submission fields with optional Space", () => { + const rows = prepare(tableFor([[firstUrl, owner, "Demo"]])); + expect(toImportRows(rows)).toEqual([ + { loom_video_url: firstUrl, user_email: owner }, + ]); + expect( + toImportRows( + prepare(tableFor([[firstUrl, owner, "Demo"]]), { + spaceMode: "override", + spaceName: "Product", + }), + ), + ).toEqual([ + { loom_video_url: firstUrl, user_email: owner, space_name: "Product" }, + ]); + expect(toImportRows([])).toEqual([]); + }); + + it("rejects any invalid selected record rather than silently filtering it out", () => { + const rows = prepare( + tableFor([ + [firstUrl, owner, "Demo"], + ["", owner, "Missing"], + ]), + ); + expect(() => toImportRows(rows)).toThrow(/Record 2 is not ready/); + expect(() => exportImportCsv(rows)).toThrow(/Record 2 is not ready/); + expect(toImportRows([rows[0]])).toHaveLength(1); + }); + + it("revalidates prepared URLs and owner emails at the submission boundary", () => { + const [row] = prepare(tableFor([[firstUrl, owner, "Demo"]])); + expect(() => toImportRows([{ ...row, url: "https://evil.test" }])).toThrow( + /not ready/, + ); + expect(() => toImportRows([{ ...row, ownerEmail: "invalid" }])).toThrow( + /not ready/, + ); + }); + + it("permits reviewed records only when the caller explicitly supplies them", () => { + const table = { + headers: ["Video Link", "Creator", "decision"], + records: [[firstUrl, owner, "pending"]], + }; + const rows = prepare(table); + expect(rows[0].reviewRequired).toBe(true); + expect(toImportRows(rows)).toHaveLength(1); + }); + + it("allows exports larger than one 500-record API batch", () => { + const table = tableFor( + Array.from({ length: 501 }, (_, index) => [ + `https://loom.com/share/${index.toString(16).padStart(32, "0")}`, + owner, + "Demo", + ]), + ); + const rows = prepare(table); + expect(toImportRows(rows)).toHaveLength(501); + expect( + parseInventory(exportImportCsv(rows), "ready.csv").records, + ).toHaveLength(501); + }); + + it("exports BOM and exactly three canonical columns with RFC escaping", () => { + const rows = prepare(tableFor([[firstUrl, owner, "Not exported"]]), { + spaceMode: "override", + spaceName: 'Product, "Guides"\nTeam', + }); + const csv = exportImportCsv(rows); + expect( + csv.startsWith("\uFEFFloom_video_url,user_email,space_name\r\n"), + ).toBe(true); + expect(parseInventory(csv, "ready.csv")).toEqual({ + headers: ["loom_video_url", "user_email", "space_name"], + records: [[firstUrl, owner, 'Product, "Guides" Team']], + }); + }); + + it.each([ + "+owner@example.test", + "-owner@example.test", + "=owner@example.test", + ])( + "preserves %s for direct import but rejects assignment-changing CSV escaping", + (email) => { + const rows = prepare(tableFor([[firstUrl, email, "Demo"]])); + expect(toImportRows(rows)[0].user_email).toBe(email); + expect(() => exportImportCsv(rows)).toThrow( + /spreadsheet formula character/, + ); + }, + ); + + it.each(["-Notes", "+Notes", "@Notes", "=SUM(1,2)", " \t=SUM(1,2)"])( + "preserves Space %s for direct import but rejects dangerous canonical CSV", + (spaceName) => { + const rows = prepare(tableFor([[firstUrl, owner, "Demo"]]), { + spaceMode: "override", + spaceName, + }); + expect(toImportRows(rows)[0].space_name).toBe(spaceName.trim()); + expect(() => exportImportCsv(rows)).toThrow(/import directly into Cap/); + }, + ); + + it("exports every original record and maps prepared fields by stable index", () => { + const table = tableFor([ + [firstUrl, owner, "First"], + ["", owner, "Missing"], + [firstUrl, owner, "Duplicate"], + ]); + const rows = prepare(table); + const exported = parseInventory( + exportInventoryCsv(table, [...rows].reverse()), + "audit.csv", + ); + expect(exported.records.map((record) => record.slice(0, 3))).toEqual( + table.records, + ); + const sourceColumn = exported.headers.indexOf("source_record_number"); + const statusColumn = exported.headers.indexOf("validation_status"); + expect(exported.records.map((record) => record[sourceColumn])).toEqual([ + "1", + "2", + "3", + ]); + expect(exported.records.map((record) => record[statusColumn])).toEqual([ + "ready", + "missing-link", + "duplicate", + ]); + }); + + it("prefixes generated headers until they do not collide with source headers", () => { + const table = { + headers: [ + "Video Link", + "Creator", + "source_record_number", + "cap_source_record_number", + " Prepared_User_Email ", + ], + records: [[firstUrl, owner, "source", "also source", "original"]], + }; + const exported = parseInventory( + exportInventoryCsv(table, prepare(table)), + "audit.csv", + ); + expect(exported.headers.slice(0, table.headers.length)).toEqual( + table.headers, + ); + expect(exported.headers).toContain("cap_cap_source_record_number"); + expect(exported.headers).toContain("cap_prepared_user_email"); + expect(exported.records[0].slice(0, 5)).toEqual(table.records[0]); + }); + + it.each([ + "=1+1", + "+cmd", + "-cmd", + "@cmd", + " =1+1", + "\t=1+1", + " \t@cmd", + "\tplain", + "\rplain", + "\nplain", + "\u0000=1+1", + "\u0080@cmd", + ])( + "neutralizes formula-like raw metadata in an audit export: %s", + (value) => { + const table = tableFor([[firstUrl, owner, value]]); + const exported = parseInventory( + exportInventoryCsv(table, prepare(table)), + "audit.csv", + ); + expect(exported.records[0][2]).toBe(`'${value}`); + expect(table.records[0][2]).toBe(value); + }, + ); + + it("neutralizes formula-like source headers and prepared assignments in audit exports", () => { + const table = { + headers: ["Video Link", "Creator", "=header"], + records: [[firstUrl, "+owner@example.test", "plain"]], + }; + const rows = prepare(table, { spaceMode: "override", spaceName: "-Notes" }); + const exported = parseInventory( + exportInventoryCsv(table, rows), + "audit.csv", + ); + expect(exported.headers[2]).toBe("'=header"); + expect( + exported.records[0][exported.headers.indexOf("prepared_user_email")], + ).toBe("'+owner@example.test"); + expect( + exported.records[0][exported.headers.indexOf("prepared_space_name")], + ).toBe("'-Notes"); + }); + + it("retains unprepared source records and marks them as needing review", () => { + const table = tableFor([ + [firstUrl, owner, "First"], + [secondUrl, owner, "Second"], + ]); + const exported = parseInventory( + exportInventoryCsv(table, [prepare(table)[0]]), + "audit.csv", + ); + expect(exported.records).toHaveLength(2); + expect( + exported.records[1][exported.headers.indexOf("validation_status")], + ).toBe("not-prepared"); + expect( + exported.records[1][exported.headers.indexOf("review_required")], + ).toBe("true"); + }); + + it("rejects ambiguous prepared indexes instead of overwriting audit records", () => { + const table = tableFor([[firstUrl, owner, "First"]]); + const [row] = prepare(table); + expect(() => exportInventoryCsv(table, [row, row])).toThrow( + /does not match/, + ); + expect(() => exportInventoryCsv(table, [{ ...row, index: 2 }])).toThrow( + /does not match/, + ); + }); +}); diff --git a/apps/chrome-extension/src/importer/inventory.ts b/apps/chrome-extension/src/importer/inventory.ts new file mode 100644 index 0000000000..4d7958d598 --- /dev/null +++ b/apps/chrome-extension/src/importer/inventory.ts @@ -0,0 +1,534 @@ +export const MAX_FILE_BYTES = 10 * 1024 * 1024; +export const MAX_ROWS = 50_000; + +export const MAX_COLUMNS = 256; + +export type InventoryTable = { + headers: string[]; + records: string[][]; +}; + +export type ColumnMapping = { + url: number; + title: number; + owner: number; + space: number; + createdAt: number; + duration: number; +}; + +export type InventoryOptions = { + ownerEmail: string; + spaceName: string; + ownerMode: "column" | "override"; + spaceMode: "none" | "column" | "override"; +}; + +export type InventoryRow = { + index: number; + sourceRecord: number; + url: string; + videoId: string | null; + title: string; + originalOwner: string; + ownerEmail: string; + spaceName: string; + createdAt: string; + duration: string; + issue: + | "missing-link" + | "invalid-link" + | "invalid-owner" + | "invalid-space" + | "duplicate" + | null; + detail: string; + reviewRequired: boolean; + raw: string[]; +}; + +export type ImportCsvRow = { + loom_video_url: string; + user_email: string; + space_name?: string; +}; + +function checkedTable(headers: string[], records: string[][]): InventoryTable { + if (headers.length === 0 || headers.some((header) => !header.trim())) { + throw new Error("Every column needs a non-empty header."); + } + if (headers.length > MAX_COLUMNS) { + throw new Error(`Files are limited to ${MAX_COLUMNS} columns.`); + } + const uniqueHeaders = new Set( + headers.map((header) => header.trim().toLowerCase()), + ); + if (uniqueHeaders.size !== headers.length) { + throw new Error("Column headers must be unique, ignoring case and spaces."); + } + if (records.length > MAX_ROWS) { + throw new Error( + `Files are limited to ${MAX_ROWS.toLocaleString("en-US")} records.`, + ); + } + if (!records.some((record) => record.some((value) => value.trim()))) { + throw new Error( + "This file has no data records. Include a header and at least one record.", + ); + } + return { + headers, + records: records.map((record, index) => { + if (record.length > headers.length) { + throw new Error( + `Record ${index + 1} has more values than the header. Check its delimiters and quotes.`, + ); + } + return Array.from( + { length: headers.length }, + (_, column) => record[column] ?? "", + ); + }), + }; +} + +function parseDelimited(text: string, delimiter: string): InventoryTable { + const records: string[][] = []; + let record: string[] = []; + let field = ""; + let state: "plain" | "quoted" | "closed" = "plain"; + let started = false; + + const finishField = () => { + record.push(field); + if (record.length > MAX_COLUMNS) { + throw new Error(`Files are limited to ${MAX_COLUMNS} columns.`); + } + field = ""; + state = "plain"; + }; + const finishRecord = () => { + finishField(); + records.push(record); + if (records.length > MAX_ROWS + 1) { + throw new Error( + `Files are limited to ${MAX_ROWS.toLocaleString("en-US")} records.`, + ); + } + record = []; + started = false; + }; + + for (let index = 0; index < text.length; index++) { + const character = text[index]; + if (state === "quoted") { + if (character !== '"') { + field += character; + } else if (text[index + 1] === '"') { + field += '"'; + index++; + } else { + state = "closed"; + } + continue; + } + if (character === delimiter) { + finishField(); + started = true; + } else if (character === "\n" || character === "\r") { + finishRecord(); + if (character === "\r" && text[index + 1] === "\n") index++; + } else if (state === "closed" || (character === '"' && field !== "")) { + throw new Error( + `Malformed quotes near record ${Math.max(1, records.length)}. Quote the entire field and escape quotes by doubling them.`, + ); + } else if (character === '"') { + state = "quoted"; + started = true; + } else { + field += character; + started = true; + } + } + if (state === "quoted") { + throw new Error( + "A quoted field is not closed. Check the file's final quotes.", + ); + } + if (started) finishRecord(); + return checkedTable(records[0] ?? [], records.slice(1)); +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseJson(text: string): InventoryTable { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + throw new Error( + "This file is not valid JSON. Use an array of flat objects or an object with a videos array.", + ); + } + const records = isObject(parsed) ? parsed.videos : parsed; + if (!Array.isArray(records)) { + throw new Error( + "JSON must contain an array of flat objects or an object with a videos array.", + ); + } + if (records.length > MAX_ROWS) { + throw new Error( + `Files are limited to ${MAX_ROWS.toLocaleString("en-US")} records.`, + ); + } + const headers = new Set(); + const objects: Record[] = []; + for (const [index, record] of records.entries()) { + if (!isObject(record)) { + throw new Error(`JSON record ${index + 1} must be a flat object.`); + } + for (const [key, value] of Object.entries(record)) { + if (value !== null && typeof value === "object") { + throw new Error( + `JSON record ${index + 1} contains nested data in "${key}". Use flat values.`, + ); + } + if ( + typeof value === "number" && + (!Number.isFinite(value) || + (Number.isInteger(value) && !Number.isSafeInteger(value))) + ) { + throw new Error( + `JSON record ${index + 1} contains an unsafe number in "${key}". Store long numeric identifiers as strings.`, + ); + } + headers.add(key); + if (headers.size > MAX_COLUMNS) { + throw new Error(`Files are limited to ${MAX_COLUMNS} columns.`); + } + } + objects.push(record); + } + const columns = [...headers]; + return checkedTable( + columns, + objects.map((record) => + columns.map((key) => { + const value = Object.hasOwn(record, key) ? record[key] : null; + return value === null ? "" : String(value); + }), + ), + ); +} + +function detectDelimiter(text: string): string { + let quoted = false; + let commas = 0; + let tabs = 0; + for (const character of text) { + if (character === '"') quoted = !quoted; + if (quoted) continue; + if (character === "\n" || character === "\r") break; + if (character === ",") commas++; + if (character === "\t") tabs++; + } + return tabs > commas ? "\t" : ","; +} + +export function parseInventory(text: string, filename: string): InventoryTable { + if ( + text.length > MAX_FILE_BYTES || + new TextEncoder().encode(text).byteLength > MAX_FILE_BYTES + ) { + throw new Error("Choose a file no larger than 10 MB."); + } + const source = text.replace(/^\uFEFF/, ""); + if (!source.trim()) throw new Error("This file is empty."); + const extension = filename.split(".").pop()?.toLowerCase(); + if ( + extension === "json" || + (extension !== "csv" && extension !== "tsv" && /^\s*[[{]/.test(source)) + ) { + return parseJson(source); + } + const delimiter = + extension === "tsv" + ? "\t" + : extension === "csv" + ? "," + : detectDelimiter(source); + return parseDelimited(source, delimiter); +} + +function normalizedHeader(header: string): string { + return header + .trim() + .toLowerCase() + .replace(/[\s-]+/g, "_"); +} + +export function detectColumns(headers: string[]): ColumnMapping { + const normalized = headers.map(normalizedHeader); + const findColumn = (aliases: string[]) => { + for (const alias of aliases) { + const index = normalized.indexOf(alias); + if (index !== -1) return index; + } + return -1; + }; + return { + url: findColumn([ + "loom_video_url", + "video_link", + "video_url", + "loom_url", + "url", + "link", + ]), + title: findColumn(["video_name", "video_title", "title", "name"]), + owner: findColumn([ + "user_email", + "creator_email", + "original_creator_email", + "owner_email", + "email", + "creator", + "owner", + ]), + space: findColumn(["space_name"]), + createdAt: findColumn(["created_at", "date_created", "created"]), + duration: findColumn(["duration", "video_duration", "length"]), + }; +} + +function readableText(value: string): string { + return value.replace(/\\([@|-])/g, "$1").trim(); +} + +function loomVideoId(value: string): string | null { + const match = + /^https?:\/\/(?:www\.)?loom\.com\/(?:share|embed)\/([a-f0-9]{32})\/?(?:[?#][^\s\\]*)?$/i.exec( + value, + ); + return match?.[1]?.toLowerCase() ?? null; +} + +function hasControlCharacter(value: string): boolean { + return [...value].some((character) => { + const code = character.charCodeAt(0); + return code < 32 || (code >= 127 && code <= 159); + }); +} + +function validEmail(value: string): boolean { + return ( + value.length <= 254 && + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) && + !hasControlCharacter(value) + ); +} + +function validSpace(value: string): boolean { + return value.length <= 255 && !hasControlCharacter(value); +} + +export function buildInventory( + table: InventoryTable, + mapping: ColumnMapping, + options: InventoryOptions, +): InventoryRow[] { + const seen = new Map(); + const decisionColumns = table.headers.flatMap((header, index) => + [ + "review_decision", + "decision", + "import", + "include", + "cap_import_status", + ].includes(normalizedHeader(header).replace(/^_+/, "")) + ? [index] + : [], + ); + const approvedDecisions = new Set(["approved", "include", "yes", "true"]); + return table.records.map((record, index) => { + const mapped = (column: number) => readableText(record[column] ?? ""); + const inputUrl = (record[mapping.url] ?? "").trim(); + const videoId = loomVideoId(inputUrl); + const originalOwner = mapped(mapping.owner); + const ownerEmail = ( + options.ownerMode === "override" + ? options.ownerEmail.trim() + : originalOwner + ).toLowerCase(); + const inputSpace = + options.spaceMode === "override" + ? options.spaceName.trim() + : options.spaceMode === "column" + ? mapped(mapping.space) + : ""; + const spaceName = inputSpace.replace(/\s+/g, " "); + const reviewRequired = decisionColumns.some((column) => { + const decision = record[column]?.trim().toLowerCase() ?? ""; + return !approvedDecisions.has(decision); + }); + let issue: InventoryRow["issue"] = null; + let detail = reviewRequired + ? "Review this record before selecting it for import." + : ""; + if (!inputUrl) { + issue = "missing-link"; + detail = + "This record has no Loom link. Its source metadata has been preserved."; + } else if (!videoId) { + issue = "invalid-link"; + detail = + "Use an http or https loom.com share or embed link with a 32-character video ID."; + } else if (seen.has(videoId)) { + issue = "duplicate"; + detail = `This video also appears in record ${seen.get(videoId)}. Only its first record is eligible.`; + } else if (!validEmail(ownerEmail)) { + issue = "invalid-owner"; + detail = "Assign a valid owner email before importing this video."; + } else if (!validSpace(spaceName)) { + issue = "invalid-space"; + detail = + "Use a Space name no longer than 255 characters without control characters."; + } + if (videoId && !seen.has(videoId)) seen.set(videoId, index + 1); + return { + index, + sourceRecord: index + 1, + url: videoId ? `https://www.loom.com/share/${videoId}` : inputUrl, + videoId, + title: mapped(mapping.title), + originalOwner, + ownerEmail, + spaceName, + createdAt: mapped(mapping.createdAt), + duration: mapped(mapping.duration), + issue, + detail, + reviewRequired, + raw: [...record], + }; + }); +} + +export function toImportRows(rows: InventoryRow[]): ImportCsvRow[] { + const invalid = rows.find( + (row) => + row.issue !== null || + !loomVideoId(row.url) || + !validEmail(row.ownerEmail) || + !validSpace(row.spaceName), + ); + if (invalid) { + throw new Error( + `Record ${invalid.sourceRecord} is not ready to import. ${invalid.detail || "Check its Loom link, owner email, and Space name."}`, + ); + } + return rows.map((row) => ({ + loom_video_url: row.url, + user_email: row.ownerEmail, + ...(row.spaceName ? { space_name: row.spaceName } : {}), + })); +} + +function formulaLike(value: string): boolean { + for (const character of value) { + if (character === "\t" || character === "\r" || character === "\n") + return true; + const code = character.charCodeAt(0); + if (!character.trim() || code < 32 || (code >= 127 && code <= 159)) + continue; + return "=+@-".includes(character); + } + return false; +} + +function csvCell(value: string): string { + const safe = formulaLike(value) ? `'${value}` : value; + return /[",\t\r\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe; +} + +function csvFile(records: string[][]): string { + return `\uFEFF${records.map((record) => record.map(csvCell).join(",")).join("\r\n")}\r\n`; +} + +export function exportImportCsv(rows: InventoryRow[]): string { + const prepared = toImportRows(rows); + if ( + prepared.some( + (row) => formulaLike(row.user_email) || formulaLike(row.space_name ?? ""), + ) + ) { + throw new Error( + "This owner or Space starts with a spreadsheet formula character. Rename it for CSV export, or import directly into Cap.", + ); + } + return csvFile([ + ["loom_video_url", "user_email", "space_name"], + ...prepared.map((row) => [ + row.loom_video_url, + row.user_email, + row.space_name ?? "", + ]), + ]); +} + +export function exportInventoryCsv( + table: InventoryTable, + rows: InventoryRow[], +): string { + const byIndex = new Map(); + for (const row of rows) { + if ( + !Number.isInteger(row.index) || + row.index < 0 || + row.index >= table.records.length || + byIndex.has(row.index) + ) { + throw new Error( + "The prepared inventory does not match the source records. Reload the file before downloading it.", + ); + } + byIndex.set(row.index, row); + } + const usedHeaders = new Set( + table.headers.map((header) => header.trim().toLowerCase()), + ); + const preparedHeaders = [ + "source_record_number", + "prepared_loom_video_url", + "prepared_user_email", + "prepared_space_name", + "validation_status", + "validation_detail", + "review_required", + ].map((header) => { + let candidate = header; + while (usedHeaders.has(candidate)) candidate = `cap_${candidate}`; + usedHeaders.add(candidate); + return candidate; + }); + return csvFile([ + [...table.headers, ...preparedHeaders], + ...table.records.map((record, index) => { + const row = byIndex.get(index); + return [ + ...record, + String(index + 1), + row?.url ?? "", + row?.ownerEmail ?? "", + row?.spaceName ?? "", + row + ? (row.issue ?? (row.reviewRequired ? "review-required" : "ready")) + : "not-prepared", + row?.detail ?? "This record has not been prepared.", + String(row?.reviewRequired ?? true), + ]; + }), + ]); +} diff --git a/apps/chrome-extension/src/importer/main.tsx b/apps/chrome-extension/src/importer/main.tsx new file mode 100644 index 0000000000..ded071a551 --- /dev/null +++ b/apps/chrome-extension/src/importer/main.tsx @@ -0,0 +1,1253 @@ +import { + ArrowRightIcon, + CheckCircle2Icon, + DownloadIcon, + FileSpreadsheetIcon, + FolderInputIcon, + InfoIcon, + LockKeyholeIcon, + PauseIcon, + RefreshCwIcon, + ShieldCheckIcon, + UploadIcon, + XIcon, +} from "lucide-react"; +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from "react"; +import { createRoot } from "react-dom/client"; +import { mountPageNav } from "../shared/page-nav"; +import { sendServiceWorkerMessage } from "../shared/runtime"; +import { + AUTH_KEY, + defaultSettings, + loadAuth, + loadPendingAuth, + loadSettings, + SETTINGS_KEY, +} from "../shared/storage"; +import type { ExtensionAuth, ExtensionSettings } from "../shared/types"; +import { fetchImportContext, type ImportContext, importLoomRow } from "./api"; +import { + buildInventory, + type ColumnMapping, + detectColumns, + exportImportCsv, + exportInventoryCsv, + type InventoryOptions, + type InventoryRow, + MAX_FILE_BYTES, + parseInventory, +} from "./inventory"; +import { canSubmitRow, InventoryTable } from "./inventory-table"; +import { MappingPanel } from "./mapping-panel"; +import { + clearImportInventory, + type ImportDraft, + type ImportRun, + loadImportInventory, + saveImportDraft, + saveImportRun, +} from "./persistence"; +import { type ImportOutcome, runImportQueue } from "./queue"; +import "../shared/paper.css"; +import "./styles.css"; + +const EMPTY_OUTCOMES: Record = {}; +const EMPTY_SELECTION: number[] = []; +const messageOf = (error: unknown) => + error instanceof Error + ? error.message + : "Something went wrong. Please try again."; + +const downloadCsv = (filename: string, content: string) => { + const url = URL.createObjectURL( + new Blob([content], { type: "text/csv;charset=utf-8" }), + ); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 10_000); +}; + +const ConfirmImport = ({ + rows, + organization, + accountEmail, + defaultPublic, + onClose, + onConfirm, +}: { + rows: InventoryRow[]; + organization: string; + accountEmail: string; + defaultPublic: boolean; + onClose: () => void; + onConfirm: () => void; +}) => { + const dialog = useRef(null); + const titleId = useId(); + const [reviewed, setReviewed] = useState(false); + useEffect(() => { + dialog.current?.showModal(); + }, []); + return ( + +
{ + event.preventDefault(); + if (reviewed) onConfirm(); + }} + > +
+ + + + +
+

Ready to bring these over?

+

+ You’re starting {rows.length.toLocaleString()} video{" "} + {rows.length === 1 ? "import" : "imports"} into{" "} + {organization}. +

+
+
+
Signed in as
+
{accountEmail}
+
+
+
Video owners
+
+ {new Set(rows.map((row) => row.ownerEmail)).size}{" "} + {new Set(rows.map((row) => row.ownerEmail)).size === 1 + ? "owner" + : "owners"} +
+
+
+
Cap visibility
+
+ {defaultPublic ? "Public · anyone with the link" : "Private"} +
+
+
+
Loom access settings
+
Not copied
+
+
+

+ Missing members may be added to this organization. Named Spaces may be + created, and video owners added to them. Titles come from Loom; + preview titles do not rename videos. +

+

+ A valid link isn’t proof of download access. Private, unshared or + password-protected videos may fail. We never change their Loom sharing + settings. +

+ +
+ + +
+
+
+ ); +}; + +function App() { + const [draft, setDraft] = useState(null); + const [run, setRun] = useState(null); + const [loaded, setLoaded] = useState(false); + const [blockedTab, setBlockedTab] = useState(false); + const [settings, setSettings] = useState(defaultSettings); + const [auth, setAuth] = useState(null); + const [context, setContext] = useState(null); + const [connecting, setConnecting] = useState(false); + const [authPending, setAuthPending] = useState(false); + const [connectionError, setConnectionError] = useState(null); + const [error, setError] = useState(null); + const [loadingFile, setLoadingFile] = useState(false); + const [savingDraft, setSavingDraft] = useState(false); + const [dragging, setDragging] = useState(false); + const [running, setRunning] = useState(false); + const [pausing, setPausing] = useState(false); + const [confirming, setConfirming] = useState(false); + const fileInput = useRef(null); + const stopRequested = useRef(false); + const runningRef = useRef(false); + const loadingFileRef = useRef(false); + const pendingDraftSaves = useRef(0); + const draftSaveFailed = useRef(false); + const draftRevision = useRef(0); + const connectionVersion = useRef(0); + const runRef = useRef(null); + const table = draft?.table; + const mapping = draft?.mapping; + const options = draft?.options; + const selection = draft?.selected ?? EMPTY_SELECTION; + const rows = useMemo( + () => + table && mapping && options + ? buildInventory(table, mapping, options) + : [], + [table, mapping, options], + ); + const selected = useMemo(() => new Set(selection), [selection]); + const outcomes = + run?.draftId === draft?.id + ? (run?.outcomes ?? EMPTY_OUTCOMES) + : EMPTY_OUTCOMES; + const hasResults = Object.keys(outcomes).length > 0; + const organizationId = + draft?.organizationId || context?.activeOrganizationId || ""; + const organization = context?.organizations.find( + (item) => item.id === organizationId, + ); + const selectedRows = useMemo( + () => rows.filter((row) => selected.has(row.sourceRecord) && !row.issue), + [rows, selected], + ); + const pendingRows = useMemo( + () => + selectedRows.filter((row) => canSubmitRow(outcomes[row.sourceRecord])), + [selectedRows, outcomes], + ); + const summary = useMemo(() => { + let ready = 0; + let missing = 0; + let duplicates = 0; + let review = 0; + const owners = new Set(); + for (const row of rows) { + if (!row.issue && !row.reviewRequired) ready += 1; + if (row.issue === "missing-link") missing += 1; + if (row.issue === "duplicate") duplicates += 1; + if (row.reviewRequired && !row.issue) review += 1; + if (row.ownerEmail) owners.add(row.ownerEmail); + } + return { + ready, + missing, + duplicates, + review, + owners: owners.size, + attention: rows.length - ready, + }; + }, [rows]); + const totals = useMemo(() => { + const values = Object.values(outcomes); + return { + started: values.filter((value) => value.state === "started").length, + existing: values.filter((value) => value.state === "existing").length, + failed: values.filter((value) => value.state === "failed").length, + uncertain: values.filter((value) => value.state === "uncertain").length, + sending: values.some((value) => value.state === "sending"), + }; + }, [outcomes]); + const maxRows = context?.maxRows ?? 500; + const runMatchesConnection = + !hasResults || + (run?.apiBaseUrl === settings.apiBaseUrl && + run.userId === context?.user.id && + run.organizationId === organizationId); + const canImport = Boolean( + auth && + context?.isPro && + organization?.canImport && + runMatchesConnection && + pendingRows.length > 0 && + pendingRows.length <= maxRows && + !running && + !loadingFile && + !connecting && + loaded && + !blockedTab, + ); + + const refreshConnection = useCallback(async () => { + const version = ++connectionVersion.current; + setConnecting(true); + setContext(null); + setConnectionError(null); + try { + const [nextSettings, nextAuth, pending] = await Promise.all([ + loadSettings(), + loadAuth(), + loadPendingAuth(), + ]); + if (version !== connectionVersion.current) return; + setSettings(nextSettings); + setAuth(nextAuth); + setAuthPending(Boolean(pending && !nextAuth)); + if (nextAuth) { + const nextContext = await fetchImportContext({ + settings: nextSettings, + auth: nextAuth, + }); + if (version !== connectionVersion.current) return; + setContext(nextContext); + } + } catch (caught) { + if (version === connectionVersion.current) + setConnectionError(messageOf(caught)); + } finally { + if (version === connectionVersion.current) setConnecting(false); + } + }, []); + + useEffect(() => { + let canceled = false; + let release = () => {}; + const lifetime = new Promise((resolve) => { + release = resolve; + }); + void navigator.locks + .request( + "cap-loom-importer-inventory", + { ifAvailable: true }, + async (lock) => { + if (!lock) { + if (!canceled) { + setBlockedTab(true); + setLoaded(true); + } + return; + } + try { + const saved = await loadImportInventory(); + if (canceled) return; + setDraft(saved.draft); + if (saved.run && saved.run.draftId === saved.draft?.id) { + await saveImportRun(saved.run); + runRef.current = saved.run; + setRun(saved.run); + } + } catch (caught) { + if (!canceled) + setError( + `Could not restore the local inventory: ${messageOf(caught)}`, + ); + } finally { + if (!canceled) setLoaded(true); + } + await lifetime; + }, + ) + .catch((caught: unknown) => { + if (!canceled) { + setBlockedTab(true); + setLoaded(true); + setError(messageOf(caught)); + } + }); + void refreshConnection(); + const storageChanged = ( + changes: Record, + area: string, + ) => { + if (area === "local" && (changes[AUTH_KEY] || changes[SETTINGS_KEY])) { + stopRequested.current = true; + setConfirming(false); + void refreshConnection(); + } + }; + chrome.storage.onChanged.addListener(storageChanged); + return () => { + canceled = true; + stopRequested.current = true; + release(); + chrome.storage.onChanged.removeListener(storageChanged); + connectionVersion.current += 1; + }; + }, [refreshConnection]); + + useEffect(() => { + if (!authPending) return; + let pending = false; + const timer = window.setInterval(() => { + if (pending) return; + pending = true; + void sendServiceWorkerMessage({ + target: "service-worker", + type: "bootstrap", + }) + .then((response) => { + if (!response.ok) { + setConnectionError(response.error); + setAuthPending(false); + return; + } + setAuthPending(Boolean(response.authPending && !response.auth)); + if (response.authError) setConnectionError(response.authError); + if (response.auth) void refreshConnection(); + }) + .catch((caught: unknown) => { + setConnectionError(messageOf(caught)); + setAuthPending(false); + }) + .finally(() => { + pending = false; + }); + }, 1000); + return () => window.clearInterval(timer); + }, [authPending, refreshConnection]); + + useEffect(() => { + const warn = (event: BeforeUnloadEvent) => { + if ( + !runningRef.current && + pendingDraftSaves.current === 0 && + !draftSaveFailed.current + ) + return; + event.preventDefault(); + event.returnValue = ""; + }; + window.addEventListener("beforeunload", warn); + return () => window.removeEventListener("beforeunload", warn); + }, []); + + const persistDraft = (nextDraft: ImportDraft) => { + const revision = ++draftRevision.current; + pendingDraftSaves.current += 1; + setSavingDraft(true); + setDraft(nextDraft); + void saveImportDraft(nextDraft) + .then(() => { + if (revision === draftRevision.current) draftSaveFailed.current = false; + }) + .catch((caught: unknown) => { + if (revision !== draftRevision.current) return; + draftSaveFailed.current = true; + setError(`Could not save your review locally: ${messageOf(caught)}`); + }) + .finally(() => { + pendingDraftSaves.current -= 1; + if (pendingDraftSaves.current === 0) setSavingDraft(false); + }); + }; + + const signIn = async () => { + setConnectionError(null); + setAuthPending(true); + try { + const response = await sendServiceWorkerMessage({ + target: "service-worker", + type: "auth-start", + }); + if (!response.ok) throw new Error(response.error); + setAuthPending(Boolean(response.authPending)); + if (response.auth) await refreshConnection(); + } catch (caught) { + setConnectionError(messageOf(caught)); + setAuthPending(false); + } + }; + + const openFile = async (file?: File) => { + if ( + !file || + loadingFileRef.current || + runningRef.current || + hasResults || + blockedTab + ) + return; + loadingFileRef.current = true; + setLoadingFile(true); + setError(null); + try { + if (file.size > MAX_FILE_BYTES) + throw new Error("Choose a file smaller than 10 MB."); + const nextTable = parseInventory(await file.text(), file.name); + const nextMapping = detectColumns(nextTable.headers); + const nextOptions: InventoryOptions = { + ownerMode: nextMapping.owner >= 0 ? "column" : "override", + ownerEmail: context?.user.email ?? "", + spaceMode: nextMapping.space >= 0 ? "column" : "none", + spaceName: "", + }; + const nextRows = buildInventory(nextTable, nextMapping, nextOptions); + const nextDraft: ImportDraft = { + id: crypto.randomUUID(), + fileName: file.name, + table: nextTable, + mapping: nextMapping, + options: nextOptions, + selected: nextRows + .filter((row) => !row.issue && !row.reviewRequired) + .map((row) => row.sourceRecord), + organizationId, + }; + await clearImportInventory(); + await saveImportDraft(nextDraft); + draftRevision.current += 1; + draftSaveFailed.current = false; + setDraft(nextDraft); + setRun(null); + runRef.current = null; + setConfirming(false); + } catch (caught) { + setError(messageOf(caught)); + } finally { + loadingFileRef.current = false; + setLoadingFile(false); + if (fileInput.current) fileInput.current.value = ""; + } + }; + + const updateMapping = ( + nextMapping: ColumnMapping, + nextOptions: InventoryOptions, + ) => { + if (!draft || runningRef.current || loadingFileRef.current || hasResults) + return; + persistDraft({ + ...draft, + mapping: nextMapping, + options: nextOptions, + }); + setConfirming(false); + }; + + const changeSelection = (records: number[], value: boolean) => { + if (!draft || runningRef.current || loadingFileRef.current) return; + const next = new Set(draft.selected); + for (const record of records) { + if (value) next.add(record); + else next.delete(record); + } + persistDraft({ ...draft, selected: Array.from(next) }); + setConfirming(false); + }; + + const clearInventory = async () => { + if (runningRef.current || loadingFileRef.current) return; + if ( + hasResults && + !window.confirm( + "Clear the locally saved inventory and progress? Any imports already started will continue in Cap.", + ) + ) + return; + try { + setDraft(null); + await clearImportInventory(); + draftRevision.current += 1; + draftSaveFailed.current = false; + setRun(null); + runRef.current = null; + setError(null); + setConfirming(false); + } catch (caught) { + setError(messageOf(caught)); + } + }; + + const exportSelected = () => { + try { + downloadCsv("cap-loom-import.csv", exportImportCsv(pendingRows)); + setError(null); + } catch (caught) { + setError(messageOf(caught)); + } + }; + + const exportReport = () => { + if (!draft) return; + try { + const headers = [...draft.table.headers]; + const usedHeaders = new Set( + headers.map((header) => header.trim().toLowerCase()), + ); + for (const label of [ + "cap_import_status", + "cap_video_url", + "cap_import_message", + "cap_import_selected", + ]) { + let name = label; + while (usedHeaders.has(name)) name = `_${name}`; + usedHeaders.add(name); + headers.push(name); + } + const reportRows = rows.map((row) => { + const outcome = outcomes[row.sourceRecord]; + return { + ...row, + raw: [ + ...row.raw, + outcome?.state ?? "not-submitted", + outcome?.videoId + ? new URL( + `/s/${encodeURIComponent(outcome.videoId)}`, + run?.apiBaseUrl ?? settings.apiBaseUrl, + ).toString() + : "", + outcome?.message ?? "", + String(!row.issue && selected.has(row.sourceRecord)), + ], + }; + }); + downloadCsv( + "cap-loom-inventory-report.csv", + exportInventoryCsv( + { headers, records: reportRows.map((row) => row.raw) }, + reportRows, + ), + ); + setError(null); + } catch (caught) { + setError(messageOf(caught)); + } + }; + + const startImport = async () => { + if ( + !canImport || + !draft || + !auth || + !context || + runningRef.current || + loadingFileRef.current + ) + return; + const targetRows = [...pendingRows]; + const connection = { settings, auth }; + const targetOrg = organizationId; + runningRef.current = true; + stopRequested.current = false; + setRunning(true); + setConfirming(false); + setError(null); + try { + await saveImportDraft({ ...draft, organizationId: targetOrg }); + draftSaveFailed.current = false; + const nextRun: ImportRun = + hasResults && runRef.current?.draftId === draft.id + ? runRef.current + : { + draftId: draft.id, + userId: context.user.id, + organizationId: targetOrg, + apiBaseUrl: settings.apiBaseUrl, + outcomes: {}, + }; + await saveImportRun(nextRun); + runRef.current = nextRun; + setRun(nextRun); + setDraft({ ...draft, organizationId: targetOrg }); + await runImportQueue({ + rows: targetRows, + shouldStop: () => stopRequested.current, + submit: async (row) => { + const [currentAuth, currentSettings] = await Promise.all([ + loadAuth(), + loadSettings(), + ]); + if ( + currentAuth?.authApiKey !== connection.auth.authApiKey || + currentSettings.apiBaseUrl !== connection.settings.apiBaseUrl + ) + throw new Error( + "Your Cap connection changed. Check the dashboard before continuing.", + ); + return importLoomRow(connection, targetOrg, { + rowNumber: row.sourceRecord, + loomUrl: row.url, + userEmail: row.ownerEmail, + ...(row.spaceName ? { spaceName: row.spaceName } : {}), + }); + }, + onUpdate: async (outcome) => { + const current = runRef.current; + if (!current) + throw new Error("The local import record is unavailable."); + const updated = { + ...current, + outcomes: { ...current.outcomes, [outcome.sourceRecord]: outcome }, + }; + await saveImportRun(updated); + runRef.current = updated; + setRun(updated); + }, + }); + } catch (caught) { + setError( + `Import paused. ${messageOf(caught)} Any started imports continue in Cap.`, + ); + } finally { + runningRef.current = false; + setRunning(false); + setPausing(false); + } + }; + + if (!loaded) + return ( +
+ +

Opening your importer…

+
+ ); + if (blockedTab) + return ( +
+ +

Your importer is already open

+

Use the other importer tab, or close it and reload this one.

+ {error ?

{error}

: null} + +
+ ); + + return ( +
+
+
+

+ LOOM → CAP +

+

+ Bring your videos with you. +

+

+ Review every row of your Loom export, even when links are missing. +
+ Choose what to bring into Cap, or download a prepared CSV. +

+
+
+ + Your review stays in this browser +
+
+ void openFile(event.target.files?.[0])} + /> + {error ? ( +
+ + {error} + +
+ ) : null} + {!draft ? ( + <> +
{ + event.preventDefault(); + setDragging(true); + }} + onDragLeave={() => setDragging(false)} + onDrop={(event) => { + event.preventDefault(); + setDragging(false); + void openFile(event.dataTransfer.files[0]); + }} + aria-label="Upload your Loom inventory" + > +
+ + + + +
+

+ {loadingFile + ? "Reading your inventory…" + : "Drop your export here"} +

+

Loom exports, Cap import templates, or your own inventory.

+ + + CSV, TSV or JSON · up to 10 MB · 50,000 records + +
+
+
+ 01 +

Export from Loom

+

+ Download your workspace’s Engagement Insights CSV from{" "} + + Loom settings + + . +

+
+
+ 02 +

Review your library

+

+ Review missing links, choose owners and Spaces, and select the + videos you want to keep. +

+
+
+ 03 +

Import or take it with you

+

+ Start your imports in Cap, or download a prepared CSV and a + complete inventory report. +

+
+
+
+ +

+ Loom can omit links for unshared videos, depending on your plan + and access. We keep those records visible; we never invent missing + links or change sharing settings. +

+
+ + ) : ( + <> +
+ +
+ {draft.fileName} + + {rows.length.toLocaleString()} source records · saved locally + + {savingDraft ? Saving changes… : null} +
+
+ {!hasResults ? ( + + ) : null} + +
+
+
+
+ Total records + {rows.length.toLocaleString()} + Your full source inventory +
+
+ + + Ready to review + + {summary.ready.toLocaleString()} + Valid link and owner +
+
+ + + Needs attention + + {summary.attention.toLocaleString()} + + {summary.missing.toLocaleString()} missing links ·{" "} + {summary.duplicates.toLocaleString()} duplicates + +
+
+ Cap owners + {summary.owners.toLocaleString()} + Based on your mapping below +
+ +
+ {summary.missing > 0 ? ( +
+ +

+ + {summary.missing.toLocaleString()}{" "} + {summary.missing === 1 ? "record has" : "records have"} no + Loom link. + {" "} + These stay in your report but cannot be imported. A missing link + does not mean a video is private. Ask the creator or Loom for a + URL-bearing export. +

+
+ ) : null} + {summary.review > 0 ? ( +
+ +

+ {summary.review.toLocaleString()}{" "} + {summary.review === 1 ? "record needs" : "records need"}{" "} + explicit review based on your file’s decision columns. These + aren’t selected automatically. +

+
+ ) : null} + +
+ + {selectedRows.length.toLocaleString()} selected + + + + +
+ + {hasResults ? ( +
+
+ {running ? ( + + ) : ( + + )} +

+ {running + ? pausing + ? "Pausing after the current request…" + : "Starting your imports…" + : "Your import progress"} +

+ {running ? ( + + ) : null} +
+

+ {totals.started} started · {totals.existing} already in Cap ·{" "} + {totals.failed} not started · {totals.uncertain} unconfirmed +

+

+ “Started” means Cap accepted the import, not that processing or + playback is complete.{" "} + {running + ? "Keep this tab open until all requests are sent." + : "Started imports continue processing in Cap."} +

+ {totals.existing > 0 ? ( +

+ Videos already in Cap keep their existing owner and Spaces. + They have not been imported again. +

+ ) : null} + {totals.uncertain > 0 ? ( +

+ Unconfirmed rows are locked to prevent accidental repeats. + Check them in your Cap dashboard; they are included in the + full report. +

+ ) : null} +
+ ) : null} +
+
+

Import your selection

+

+ Download a prepared CSV, or send your selection straight to Cap. +

+ {context ?

Signed in as {context.user.email}

: null} +
+
+ {context ? ( + + ) : ( +
+ + + {connecting + ? "Connecting to Cap…" + : "Sign in to import into Cap. Preview and downloads are free to use."} + +
+ )} +
+ + {!auth ? ( + + ) : ( + + )} +
+
+ {connectionError ? ( +
+ {connectionError} + + {auth ? ( + + ) : null} +
+ ) : null} + {context && !context.isPro ? ( +

+ Loom imports require Cap Pro. You can still review and download + your CSV. +

+ ) : null} + {context && !organization?.canImport ? ( +

+ Choose an organization where you’re an admin or owner to import. +

+ ) : null} + {!runMatchesConnection ? ( +

+ This run belongs to a different Cap connection. Reconnect to + that account to continue, or clear the local inventory. +

+ ) : null} + {pendingRows.length > maxRows ? ( +

+ Select up to {maxRows} videos per import run. You can export a + CSV of the full selection. +

+ ) : null} +

+ Only selected video links, owner emails and Space names are sent + to Cap when you confirm. The full report keeps all records and + neutralizes spreadsheet formulas; it is for review, not direct + import. + {hasResults + ? " Already submitted or unconfirmed rows are excluded from the import CSV." + : ""} +

+
+ + )} +
+ + + Saved on this Chrome profile until cleared. No changes to your Loom + workspace. + + Extension settings +
+ {confirming && organization && context ? ( + setConfirming(false)} + onConfirm={() => void startImport()} + /> + ) : null} +
+ ); +} + +mountPageNav("import"); +const root = document.getElementById("root"); +if (root) createRoot(root).render(); diff --git a/apps/chrome-extension/src/importer/mapping-panel.tsx b/apps/chrome-extension/src/importer/mapping-panel.tsx new file mode 100644 index 0000000000..0d03b34a15 --- /dev/null +++ b/apps/chrome-extension/src/importer/mapping-panel.tsx @@ -0,0 +1,157 @@ +import { SlidersHorizontalIcon } from "lucide-react"; +import type { ColumnMapping, InventoryOptions } from "./inventory"; + +const ColumnSelect = ({ + headers, + value, + onChange, + label, +}: { + headers: string[]; + value: number; + onChange: (value: number) => void; + label: string; +}) => ( + +); + +export const MappingPanel = ({ + headers, + mapping, + options, + disabled, + onChange, +}: { + headers: string[]; + mapping: ColumnMapping; + options: InventoryOptions; + disabled: boolean; + onChange: (mapping: ColumnMapping, options: InventoryOptions) => void; +}) => ( +
+ + + Review your mapping + Choose where each video belongs + +
+ onChange({ ...mapping, url }, options)} + /> +
+ + {options.ownerMode === "column" ? ( + onChange({ ...mapping, owner }, options)} + /> + ) : ( + + )} +
+
+ + {options.spaceMode === "column" ? ( + onChange({ ...mapping, space }, options)} + /> + ) : options.spaceMode === "override" ? ( + + ) : ( +

+ Loom folders aren’t mapped automatically. +

+ )} +
+ onChange({ ...mapping, title }, options)} + /> +
+

+ {disabled + ? "Mapping is locked after imports start. Clear this inventory to prepare a different mapping." + : "Named Spaces are reused or created as flat Spaces. Loom folders and sharing permissions are not copied."} +

+
+); diff --git a/apps/chrome-extension/src/importer/persistence.test.ts b/apps/chrome-extension/src/importer/persistence.test.ts new file mode 100644 index 0000000000..a927f0ed48 --- /dev/null +++ b/apps/chrome-extension/src/importer/persistence.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { detectColumns, parseInventory } from "./inventory"; +import { + type ImportDraft, + type ImportRun, + restoreImportInventory, +} from "./persistence"; + +const table = parseInventory( + "Video Link,Creator\nhttps://www.loom.com/share/0123456789abcdef0123456789abcdef,alex@example.test", + "inventory.csv", +); +const draft: ImportDraft = { + id: "fixture-inventory", + fileName: "inventory.csv", + table, + mapping: detectColumns(table.headers), + options: { + ownerMode: "column", + ownerEmail: "", + spaceMode: "none", + spaceName: "", + }, + selected: [1], + organizationId: "fixture-organization", +}; +const run: ImportRun = { + draftId: draft.id, + apiBaseUrl: "https://cap.example.test", + userId: "fixture-user", + organizationId: draft.organizationId, + outcomes: { 1: { sourceRecord: 1, state: "sending" } }, +}; + +describe("saved importer recovery", () => { + it("opens an empty store and restores a review without a run", () => { + expect(restoreImportInventory(undefined, undefined)).toEqual({ + draft: null, + run: null, + }); + expect(restoreImportInventory(draft, null)).toEqual({ draft, run: null }); + }); + + it("turns an interrupted request into uncertainty without changing the saved input", () => { + const restored = restoreImportInventory(draft, run); + expect(restored.run?.outcomes[1]).toMatchObject({ + sourceRecord: 1, + state: "uncertain", + message: expect.stringContaining("Check your dashboard"), + }); + expect(restored.draft?.selected).toEqual([1]); + expect(run.outcomes[1].state).toBe("sending"); + }); + + it.each(["started", "existing", "failed", "uncertain"] as const)( + "preserves the %s outcome and its account binding", + (state) => { + const saved = { + ...run, + outcomes: { + 1: { sourceRecord: 1, state, videoId: "fixture-video" }, + }, + }; + expect(restoreImportInventory(draft, saved).run).toEqual(saved); + }, + ); + + it.each([ + { ...draft, table: { ...table, records: [[42]] } }, + { ...draft, mapping: { ...draft.mapping, owner: 20 } }, + { ...draft, mapping: { ...draft.mapping, owner: 0.5 } }, + { ...draft, options: { ...draft.options, ownerMode: "unknown" } }, + { ...draft, selected: [2] }, + { ...draft, selected: [1, 1] }, + { ...draft, table: { ...table, headers: ["Creator", "creator"] } }, + ])( + "rejects malformed saved reviews instead of guessing mappings", + (saved) => { + expect(() => restoreImportInventory(saved, null)).toThrow( + /cannot be read safely/, + ); + }, + ); + + it.each([ + { ...run, draftId: "another-inventory" }, + { ...run, apiBaseUrl: "javascript:alert(1)" }, + { ...run, apiBaseUrl: "https://user:password@cap.example.test" }, + { ...run, outcomes: null }, + { ...run, outcomes: { 1: { sourceRecord: 2, state: "started" } } }, + { ...run, outcomes: { 1: { sourceRecord: 1, state: "unknown" } } }, + { ...run, outcomes: { 1: { sourceRecord: 1, state: "started" } } }, + { ...run, outcomes: { 1: { sourceRecord: 1, state: "existing" } } }, + ])("rejects unreadable progress rather than clearing the run", (saved) => { + expect(() => restoreImportInventory(draft, saved)).toThrow( + /Check your Cap dashboard/, + ); + }); + + it("does not discard progress when its inventory is missing", () => { + expect(() => restoreImportInventory(null, run)).toThrow( + /cannot be read safely/, + ); + }); +}); diff --git a/apps/chrome-extension/src/importer/persistence.ts b/apps/chrome-extension/src/importer/persistence.ts new file mode 100644 index 0000000000..b3a03f3a7c --- /dev/null +++ b/apps/chrome-extension/src/importer/persistence.ts @@ -0,0 +1,240 @@ +import { + type ColumnMapping, + type InventoryOptions, + type InventoryTable, + MAX_COLUMNS, + MAX_ROWS, +} from "./inventory"; +import type { ImportOutcome } from "./queue"; + +export type ImportDraft = { + id: string; + fileName: string; + table: InventoryTable; + mapping: ColumnMapping; + options: InventoryOptions; + selected: number[]; + organizationId: string; +}; + +export type ImportRun = { + draftId: string; + apiBaseUrl: string; + userId: string; + organizationId: string; + outcomes: Record; +}; + +const isObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const isStringArray = (value: unknown): value is string[] => + Array.isArray(value) && + value.every((item: unknown) => typeof item === "string"); + +const isDraft = (value: unknown): value is ImportDraft => { + if ( + !isObject(value) || + typeof value.id !== "string" || + !value.id || + typeof value.fileName !== "string" || + typeof value.organizationId !== "string" || + !isObject(value.table) || + !isStringArray(value.table.headers) || + !Array.isArray(value.table.records) || + !isObject(value.mapping) || + !isObject(value.options) || + !Array.isArray(value.selected) + ) + return false; + const headers = value.table.headers; + const records = value.table.records; + const { mapping, options, selected } = value; + if ( + headers.length === 0 || + headers.length > MAX_COLUMNS || + headers.some((header) => !header.trim()) || + new Set(headers.map((header) => header.trim().toLowerCase())).size !== + headers.length || + records.length === 0 || + records.length > MAX_ROWS || + !records.every( + (record) => isStringArray(record) && record.length === headers.length, + ) || + !["url", "title", "owner", "space", "createdAt", "duration"].every( + (key) => + typeof mapping[key] === "number" && + Number.isInteger(mapping[key]) && + mapping[key] >= -1 && + mapping[key] < headers.length, + ) || + typeof options.ownerEmail !== "string" || + typeof options.spaceName !== "string" || + (options.ownerMode !== "column" && options.ownerMode !== "override") || + (options.spaceMode !== "none" && + options.spaceMode !== "column" && + options.spaceMode !== "override") || + selected.some( + (record: unknown) => + typeof record !== "number" || + !Number.isInteger(record) || + record < 1 || + record > records.length, + ) || + new Set(selected).size !== selected.length + ) + return false; + return true; +}; + +const isRun = (value: unknown, draft: ImportDraft): value is ImportRun => { + if ( + !isObject(value) || + value.draftId !== draft.id || + typeof value.apiBaseUrl !== "string" || + typeof value.userId !== "string" || + !value.userId || + typeof value.organizationId !== "string" || + !value.organizationId || + !isObject(value.outcomes) + ) + return false; + try { + const url = new URL(value.apiBaseUrl); + if ( + !["http:", "https:"].includes(url.protocol) || + url.username || + url.password + ) + return false; + } catch { + return false; + } + return Object.entries(value.outcomes).every(([key, outcome]) => { + if (!isObject(outcome)) return false; + const record = outcome.sourceRecord; + return ( + typeof record === "number" && + Number.isInteger(record) && + record >= 1 && + record <= draft.table.records.length && + String(record) === key && + typeof outcome.state === "string" && + ["sending", "started", "existing", "failed", "uncertain"].includes( + outcome.state, + ) && + (outcome.videoId === undefined || typeof outcome.videoId === "string") && + (outcome.message === undefined || typeof outcome.message === "string") && + (!["started", "existing"].includes(outcome.state) || + Boolean(outcome.videoId)) + ); + }); +}; + +export const restoreImportInventory = ( + draft: unknown, + run: unknown, +): { draft: ImportDraft | null; run: ImportRun | null } => { + if (draft == null && run == null) return { draft: null, run: null }; + if (!isDraft(draft) || (run != null && !isRun(run, draft))) { + throw new Error( + "Saved import progress cannot be read safely. Check your Cap dashboard before opening another inventory.", + ); + } + return { + draft, + run: + run == null + ? null + : { + ...run, + outcomes: Object.fromEntries( + Object.entries(run.outcomes).map(([key, outcome]) => [ + key, + outcome.state === "sending" + ? { + ...outcome, + state: "uncertain" as const, + message: + "This tab closed before Cap confirmed the request. Check your dashboard before importing it again.", + } + : outcome, + ]), + ), + }, + }; +}; + +const openDatabase = () => + new Promise((resolve, reject) => { + const request = indexedDB.open("cap-loom-importer", 1); + request.onupgradeneeded = () => { + request.result.createObjectStore("inventory"); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + request.onblocked = () => + reject( + new Error("Close other importer tabs to unlock the saved inventory."), + ); + }); + +let pendingWrite: Promise = Promise.resolve(); + +const write = ( + values: { draft?: ImportDraft; run?: ImportRun }, + clear = false, +) => { + const operation = pendingWrite + .catch(() => undefined) + .then(async () => { + const database = await openDatabase(); + try { + await new Promise((resolve, reject) => { + const transaction = database.transaction("inventory", "readwrite"); + const store = transaction.objectStore("inventory"); + if (clear) store.clear(); + if (values.draft) store.put(values.draft, "draft"); + if (values.run) store.put(values.run, "run"); + transaction.oncomplete = () => resolve(); + transaction.onabort = () => reject(transaction.error); + transaction.onerror = () => reject(transaction.error); + }); + } finally { + database.close(); + } + }); + pendingWrite = operation; + return operation; +}; + +export const saveImportDraft = (draft: ImportDraft) => write({ draft }); +export const saveImportRun = (run: ImportRun) => write({ run }); +export const clearImportInventory = () => write({}, true); + +export const loadImportInventory = async () => { + await pendingWrite.catch(() => undefined); + const database = await openDatabase(); + try { + return await new Promise<{ + draft: ImportDraft | null; + run: ImportRun | null; + }>((resolve, reject) => { + const transaction = database.transaction("inventory", "readonly"); + const store = transaction.objectStore("inventory"); + const draft = store.get("draft"); + const run = store.get("run"); + transaction.oncomplete = () => { + try { + resolve(restoreImportInventory(draft.result, run.result)); + } catch (error) { + reject(error); + } + }; + transaction.onabort = () => reject(transaction.error); + transaction.onerror = () => reject(transaction.error); + }); + } finally { + database.close(); + } +}; diff --git a/apps/chrome-extension/src/importer/queue.test.ts b/apps/chrome-extension/src/importer/queue.test.ts new file mode 100644 index 0000000000..383e65a842 --- /dev/null +++ b/apps/chrome-extension/src/importer/queue.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it, vi } from "vitest"; +import { ApiRequestError } from "../shared/api"; +import type { ImportResponse } from "./api"; +import { buildInventory, detectColumns, parseInventory } from "./inventory"; +import { type ImportOutcome, runImportQueue } from "./queue"; + +const table = parseInventory( + [ + "loom_video_url,user_email", + "https://www.loom.com/share/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,owner@example.test", + "https://www.loom.com/share/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb,owner@example.test", + "https://www.loom.com/share/cccccccccccccccccccccccccccccccc,owner@example.test", + ].join("\n"), + "inventory.csv", +); +const rows = buildInventory(table, detectColumns(table.headers), { + ownerMode: "column", + ownerEmail: "", + spaceMode: "none", + spaceName: "", +}); + +const queueOptions = () => { + const updates: ImportOutcome[] = []; + return { + rows, + updates, + submit: vi.fn( + async (): Promise => ({ + success: true, + videoId: "cap-video", + }), + ), + onUpdate: vi.fn(async (outcome: ImportOutcome) => { + updates.push(outcome); + }), + shouldStop: () => false, + delay: vi.fn(async () => undefined), + }; +}; + +describe("Loom import queue", () => { + it("awaits durable sending state before each sequential request and never calls started complete", async () => { + const options = queueOptions(); + const events: string[] = []; + options.onUpdate.mockImplementation(async (outcome) => { + events.push(`${outcome.sourceRecord}:${outcome.state}`); + await Promise.resolve(); + }); + options.submit.mockImplementation(async () => { + events.push("submit"); + return { success: true, videoId: "cap-video" }; + }); + await runImportQueue(options); + expect(events).toEqual([ + "1:sending", + "submit", + "1:started", + "2:sending", + "submit", + "2:started", + "3:sending", + "submit", + "3:started", + ]); + expect(options.delay).toHaveBeenCalledTimes(2); + }); + + it("keeps existing, rejected and started results distinct", async () => { + const options = queueOptions(); + options.submit + .mockResolvedValueOnce({ + success: true, + videoId: "existing-video", + existing: true, + error: "Ownership unchanged.", + }) + .mockResolvedValueOnce({ + success: false, + error: "The video is unavailable.", + }) + .mockResolvedValueOnce({ success: true, videoId: "new-video" }); + await runImportQueue(options); + expect( + options.updates.filter((value) => value.state !== "sending"), + ).toEqual([ + { + sourceRecord: 1, + state: "existing", + videoId: "existing-video", + message: "Ownership unchanged.", + }, + { + sourceRecord: 2, + state: "failed", + videoId: undefined, + message: "The video is unavailable.", + }, + { + sourceRecord: 3, + state: "started", + videoId: "new-video", + message: undefined, + }, + ]); + }); + + it("waits for an active request before pausing, without canceling accepted work", async () => { + const options = queueOptions(); + let stop = false; + let release: ((value: ImportResponse) => void) | undefined; + options.submit.mockImplementation( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + const done = runImportQueue({ ...options, shouldStop: () => stop }); + await vi.waitFor(() => expect(release).toBeTypeOf("function")); + stop = true; + release?.({ success: true, videoId: "accepted-video" }); + await done; + expect(options.submit).toHaveBeenCalledTimes(1); + expect(options.updates.at(-1)?.state).toBe("started"); + expect(options.delay).not.toHaveBeenCalled(); + }); + + it.each([ + new TypeError("Network disconnected"), + new ApiRequestError(500, "Unavailable"), + new ApiRequestError(408, "Request timed out"), + ])("stops on an unknown outcome without retrying it: %s", async (error) => { + const options = queueOptions(); + options.submit.mockRejectedValue(error); + await runImportQueue(options); + expect(options.submit).toHaveBeenCalledTimes(1); + expect(options.updates.at(-1)).toMatchObject({ + state: "uncertain", + message: error.message, + }); + expect(options.delay).not.toHaveBeenCalled(); + }); + + it.each([400, 401, 403, 404, 413, 422, 429])( + "preserves a definite HTTP %s rejection for explicit retry and pauses the queue", + async (status) => { + const options = queueOptions(); + options.submit.mockRejectedValue(new ApiRequestError(status, "Rejected")); + await runImportQueue(options); + expect(options.submit).toHaveBeenCalledTimes(1); + expect(options.updates.at(-1)?.state).toBe("failed"); + }, + ); + + it.each([ + { success: false, uncertain: true, error: "Partially started." }, + { success: true }, + ])( + "locks server-reported or malformed uncertain success: %j", + async (response) => { + const options = queueOptions(); + options.submit.mockResolvedValue(response); + await runImportQueue(options); + expect(options.submit).toHaveBeenCalledTimes(1); + expect(options.updates.at(-1)?.state).toBe("uncertain"); + }, + ); + + it("does not submit without durable local progress", async () => { + const options = queueOptions(); + options.onUpdate.mockRejectedValue(new Error("Storage full")); + await expect(runImportQueue(options)).rejects.toThrow("Storage full"); + expect(options.submit).not.toHaveBeenCalled(); + }); + + it("stops after a confirmed response cannot be saved, retaining sending as the recovery marker", async () => { + const options = queueOptions(); + options.onUpdate.mockImplementation(async (outcome) => { + if (outcome.state !== "sending") throw new Error("Storage full"); + options.updates.push(outcome); + }); + await expect(runImportQueue(options)).rejects.toThrow("Storage full"); + expect(options.submit).toHaveBeenCalledTimes(1); + expect(options.updates).toEqual([{ sourceRecord: 1, state: "sending" }]); + }); + + it("refuses invalid source rows before any request", async () => { + const options = queueOptions(); + await expect( + runImportQueue({ + ...options, + rows: [{ ...rows[0], issue: "missing-link" }], + }), + ).rejects.toThrow("Only valid rows"); + expect(options.submit).not.toHaveBeenCalled(); + }); + + it("honors an already requested pause", async () => { + const options = queueOptions(); + await runImportQueue({ ...options, shouldStop: () => true }); + expect(options.onUpdate).not.toHaveBeenCalled(); + expect(options.submit).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/chrome-extension/src/importer/queue.ts b/apps/chrome-extension/src/importer/queue.ts new file mode 100644 index 0000000000..4c78d0cf87 --- /dev/null +++ b/apps/chrome-extension/src/importer/queue.ts @@ -0,0 +1,64 @@ +import { ApiRequestError } from "../shared/api"; +import type { ImportResponse } from "./api"; +import type { InventoryRow } from "./inventory"; + +export type ImportOutcome = { + sourceRecord: number; + state: "sending" | "started" | "existing" | "failed" | "uncertain"; + videoId?: string; + message?: string; +}; + +export const runImportQueue = async ({ + rows, + submit, + onUpdate, + shouldStop, + delay = () => new Promise((resolve) => setTimeout(resolve, 1500)), +}: { + rows: InventoryRow[]; + submit: (row: InventoryRow) => Promise; + onUpdate: (outcome: ImportOutcome) => Promise; + shouldStop: () => boolean; + delay?: () => Promise; +}) => { + for (const [index, row] of rows.entries()) { + if (shouldStop()) break; + if (row.issue) throw new Error("Only valid rows can be imported."); + await onUpdate({ sourceRecord: row.sourceRecord, state: "sending" }); + let outcome: ImportOutcome; + let stop = false; + try { + const response = await submit(row); + outcome = { + sourceRecord: row.sourceRecord, + state: + response.uncertain || (response.success && !response.videoId) + ? "uncertain" + : response.success && response.videoId + ? response.existing + ? "existing" + : "started" + : "failed", + videoId: response.videoId, + message: response.error, + }; + } catch (error) { + const rejected = + error instanceof ApiRequestError && + [400, 401, 403, 404, 413, 422, 429].includes(error.status); + outcome = { + sourceRecord: row.sourceRecord, + state: rejected ? "failed" : "uncertain", + message: + error instanceof Error + ? error.message + : "Could not confirm the import.", + }; + stop = true; + } + await onUpdate(outcome); + if (stop || outcome.state === "uncertain" || shouldStop()) break; + if (index < rows.length - 1) await delay(); + } +}; diff --git a/apps/chrome-extension/src/importer/styles.css b/apps/chrome-extension/src/importer/styles.css new file mode 100644 index 0000000000..b22b96b446 --- /dev/null +++ b/apps/chrome-extension/src/importer/styles.css @@ -0,0 +1,1247 @@ +:root { + --ink-soft: #667080; + --import-border: #e2e4e8; + --import-blue: #346de4; +} + +button, +input, +select { + font: inherit; +} + +button, +a, +input, +select, +summary { + -webkit-tap-highlight-color: transparent; +} + +button { + cursor: pointer; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +:focus-visible { + outline: 3px solid #4785ff; + outline-offset: 3px; +} + +a { + color: var(--import-blue); + text-underline-offset: 3px; +} + +.page-nav-inner { + width: min(1280px, calc(100vw - 80px)); +} + +.page-nav { + border-bottom: 1px solid var(--import-border); +} + +.page-nav-links { + border: 0; + background: transparent; +} + +.import-layout { + max-width: 1360px; + margin: 0 auto; + padding: 48px 40px 24px; +} + +.import-heading { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 24px; + margin-bottom: 36px; +} + +.eyebrow { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + font-weight: 700; + letter-spacing: 1.4px; + color: var(--ink-soft); +} + +.eyebrow span { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent); +} + +.import-heading h1 { + margin-top: 15px; + font-size: clamp(30px, 3.6vw, 43px); + font-weight: 500; + letter-spacing: -1.8px; + line-height: 1.15; +} + +.import-heading h1 span { + color: var(--import-blue); +} + +.intro { + margin-top: 15px; + font-size: 15px; + line-height: 1.6; + color: var(--ink-soft); +} + +.privacy-pill { + display: inline-flex; + align-items: center; + gap: 7px; + margin-top: 8px; + padding: 9px 13px; + border: 1px solid #dde6dd; + border-radius: 24px; + font-size: 12px; + color: #47704b; + background: #f2f6ef; + white-space: nowrap; +} + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 9px; + min-height: 42px; + padding: 11px 17px; + border: 1px solid transparent; + border-radius: 9px; + font-size: 13px; + font-weight: 500; + line-height: 1.2; + text-decoration: none; + transition: background 0.15s; +} + +.button.primary { + border-color: #2f65d7; + background: var(--import-blue); + color: #fff; + box-shadow: + 0 2px 3px #2457b81f, + inset 0 1px #ffffff26; +} + +.button.primary:hover:not(:disabled) { + background: #265aca; +} + +.button.secondary { + border-color: #d9dce1; + background: #fff; + color: var(--ink); + box-shadow: 0 1px 2px #20242c08; +} + +.button.secondary:hover:not(:disabled) { + background: #f4f6fa; +} + +.button.compact { + min-height: 33px; + padding: 7px 12px; + font-size: 12px; +} + +.drop-zone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 28px 38px; + border: 1.5px dashed #bfc8d7; + border-radius: 18px; + background: linear-gradient(145deg, #fff, #f4f7fc); + text-align: center; + transition: + background 0.2s, + border-color 0.2s; +} + +.drop-zone.dragging { + border-color: var(--import-blue); + background: #eaf1ff; +} + +.file-illustration { + position: relative; + width: 82px; + height: 86px; + margin-bottom: 20px; +} + +.back-file, +.front-file { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + width: 60px; + height: 72px; + border: 1px solid #cbd9ee; + border-radius: 10px; +} + +.back-file { + left: 3px; + top: 7px; + transform: rotate(-13deg); + background: #e2ebf8; +} + +.front-file { + left: 19px; + top: 0; + transform: rotate(8deg); + background: #fff; + color: #5c83bb; + box-shadow: 0 6px 12px #46689715; +} + +.drop-zone h2 { + font-size: 23px; + font-weight: 500; + letter-spacing: -0.5px; +} + +.drop-zone p { + margin: 10px 0 24px; + font-size: 14px; + color: var(--ink-soft); +} + +.file-formats { + margin-top: 15px; + font-size: 11px; + color: var(--ink-soft); +} + +.getting-started { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 40px; + margin: 36px 0 30px; +} + +.step-number { + font-size: 11px; + font-weight: 700; + color: #7293be; + letter-spacing: 0.7px; +} + +.getting-started h3 { + margin: 9px 0 7px; + font-size: 15px; + font-weight: 500; +} + +.getting-started p { + max-width: 330px; + font-size: 13px; + line-height: 1.65; + color: var(--ink-soft); +} + +.quiet-note { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 17px 20px; + border: 1px solid var(--import-border); + border-radius: 10px; + font-size: 12px; + line-height: 1.6; + color: var(--ink-soft); +} + +.quiet-note svg, +.notice > svg { + flex-shrink: 0; + margin-top: 2px; +} + +.file-strip { + display: flex; + align-items: center; + gap: 12px; + padding: 17px 19px; + border: 1px solid var(--import-border); + border-radius: 12px; + background: #fff; +} + +.file-strip > svg { + flex-shrink: 0; + color: #56846a; +} + +.file-strip strong { + display: block; + font-size: 13px; + font-weight: 500; + overflow-wrap: anywhere; +} + +.file-strip span, +.file-strip output { + display: block; + margin-top: 4px; + font-size: 11px; + color: var(--ink-soft); +} + +.file-actions { + display: flex; + gap: 15px; + margin-left: auto; + flex-shrink: 0; +} + +.text-button { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 0; + border: 0; + background: none; + color: var(--import-blue); + font-size: 12px; + font-weight: 500; +} + +.text-button:hover:not(:disabled) { + text-decoration: underline; + text-underline-offset: 3px; +} + +.inventory-overview { + position: relative; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 25px; + padding: 26px 0 27px; + margin-bottom: 22px; +} + +.stat { + padding-left: 20px; + border-left: 1px solid var(--import-border); +} + +.stat:first-child { + padding-left: 0; + border: 0; +} + +.stat > span { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: #566171; +} + +.stat strong { + display: block; + margin-top: 8px; + font-size: 31px; + font-weight: 500; + font-variant-numeric: tabular-nums; + letter-spacing: -0.8px; +} + +.stat small { + display: block; + margin-top: 4px; + font-size: 11px; + color: var(--ink-soft); +} + +.dot { + width: 6px; + height: 6px; + border-radius: 50%; +} + +.ready-dot { + background: #488a66; +} + +.attention-dot { + background: #d3953d; +} + +.coverage-bar { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 4px; + overflow: hidden; + border-radius: 4px; + background: #e8d9bc; +} + +.coverage-bar span { + display: block; + height: 100%; + background: #77a68a; + transition: width 0.2s; +} + +.notice { + display: flex; + align-items: flex-start; + gap: 11px; + margin-bottom: 16px; + padding: 14px 16px; + border: 1px solid transparent; + border-radius: 10px; + font-size: 12px; + line-height: 1.6; +} + +.notice p, +.notice > span { + flex: 1; +} + +.notice strong { + font-weight: 500; +} + +.notice-warning { + border-color: #ebdfc9; + background: #fffbf1; + color: #856023; +} + +.notice-error { + border-color: #f0cfcc; + background: #fff2f0; + color: #a93831; +} + +.notice-neutral { + border-color: #dae3f4; + background: #f1f5fd; + color: #466282; +} + +.mapping-panel { + margin-top: 22px; + border: 1px solid var(--import-border); + border-radius: 12px; + background: #fff; +} + +.mapping-panel summary { + display: flex; + align-items: center; + gap: 9px; + padding: 17px 19px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + list-style: none; +} + +.mapping-panel summary::after { + content: "+"; + margin-left: auto; + color: var(--ink-soft); + font-size: 16px; +} + +.mapping-panel[open] summary::after { + content: "−"; +} + +.mapping-panel summary::-webkit-details-marker { + display: none; +} + +.mapping-panel summary span { + margin-left: 6px; + font-size: 11px; + font-weight: 400; + color: var(--ink-soft); +} + +.mapping-fields { + display: grid; + grid-template-columns: 1fr 1.25fr 1.25fr 1fr; + align-items: flex-start; + gap: 20px; + margin: 0; + padding: 4px 19px 16px; + border: 0; + min-width: 0; +} + +.field { + display: flex; + flex-direction: column; + gap: 7px; + min-width: 0; +} + +.field > span { + font-size: 11px; + font-weight: 500; + color: #566171; +} + +.field select, +.field input { + width: 100%; + min-height: 37px; + padding: 8px 10px; + border: 1px solid #d9dde5; + border-radius: 7px; + background: #fff; + color: var(--ink); + font-size: 12px; +} + +.field input::placeholder { + color: var(--ink-soft); +} + +.field-stack { + display: grid; + gap: 12px; + min-width: 0; +} + +.field-hint { + padding-top: 4px; + font-size: 11px; + line-height: 1.5; + color: var(--ink-soft); +} + +.mapping-note { + padding: 12px 19px; + border-top: 1px solid #eceef1; + font-size: 11px; + line-height: 1.5; + color: var(--ink-soft); +} + +.selection-actions { + display: flex; + align-items: center; + gap: 17px; + margin: 25px 0 13px; + font-size: 12px; + color: var(--ink-soft); +} + +.selection-actions strong { + font-weight: 500; + color: var(--ink); +} + +.report-button { + margin-left: auto; +} + +.inventory-section { + overflow: hidden; + border: 1px solid var(--import-border); + border-radius: 12px; + background: #fff; +} + +.inventory-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + padding: 13px 15px; + border-bottom: 1px solid var(--import-border); +} + +.filter-tabs { + display: flex; + align-items: center; + gap: 3px; + margin: 0; + padding: 0; + border: 0; +} + +.filter-tabs button { + padding: 7px 10px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--ink-soft); + font-size: 12px; +} + +.filter-tabs button[aria-pressed="true"] { + background: #edf2fc; + color: #305daa; + font-weight: 500; +} + +.search-field { + display: flex; + align-items: center; + gap: 7px; + padding: 7px 10px; + border: 1px solid var(--import-border); + border-radius: 7px; + color: var(--ink-soft); +} + +.search-field input { + width: 180px; + padding: 0; + border: 0; + font-size: 12px; + color: var(--ink); + background: transparent; +} + +.table-scroll { + max-height: 550px; + overflow: auto; +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + text-align: left; +} + +th { + position: sticky; + top: 0; + z-index: 1; + padding: 12px 14px; + border-bottom: 1px solid var(--import-border); + background: #f9fafc; + color: #677180; + font-size: 11px; + font-weight: 500; + white-space: nowrap; +} + +td { + padding: 15px 14px; + border-bottom: 1px solid #edf0f3; + vertical-align: middle; + overflow-wrap: anywhere; + max-width: 240px; +} + +tbody tr:last-child td { + border-bottom: 0; +} + +.selected-row { + background: #fbfcff; +} + +.checkbox-cell { + width: 42px; + padding-left: 18px; + padding-right: 4px; +} + +input[type="checkbox"] { + width: 14px; + height: 14px; + margin: 0; + accent-color: var(--import-blue); + cursor: pointer; +} + +.video-cell { + min-width: 225px; + max-width: 340px; +} + +.video-title { + display: flex; + align-items: flex-start; + gap: 9px; + font-weight: 500; + line-height: 1.5; +} + +.video-title svg { + flex-shrink: 0; + margin-top: 1px; + color: #78889e; +} + +.video-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 13px; + margin: 5px 0 0 26px; + font-size: 10px; + color: var(--ink-soft); +} + +.video-meta a, +.cap-result-link { + display: inline-flex; + align-items: center; + gap: 4px; + text-decoration: none; +} + +.video-meta a:hover, +.cap-result-link:hover { + text-decoration: underline; +} + +.owner-cell { + min-width: 180px; +} + +.owner-cell small { + display: block; + margin-top: 5px; + font-size: 10px; + color: var(--ink-soft); +} + +.status-badge { + display: inline-flex; + align-items: center; + padding: 4px 7px; + border: 1px solid transparent; + border-radius: 5px; + font-size: 10px; + font-weight: 500; + white-space: nowrap; +} + +.status-ready, +.status-started, +.status-existing { + border-color: #d2e5d7; + background: #f1f8f3; + color: #396b4a; +} + +.status-attention, +.status-uncertain { + border-color: #efdfbd; + background: #fffaed; + color: #8b621d; +} + +.status-sending { + border-color: #d2dff5; + background: #f0f5ff; + color: #3768b4; +} + +.status-failed { + border-color: #efcfca; + background: #fff4f2; + color: #a64035; +} + +.cap-result-link { + margin-top: 6px; + font-size: 10px; +} + +.outcome-message { + max-width: 240px; + margin: 7px 0 0; + font-size: 11px; + line-height: 1.5; + color: var(--ink-soft); +} + +.icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 29px; + height: 29px; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--ink-soft); +} + +.icon-button:hover:not(:disabled) { + background: #e9edf3; +} + +.icon-button[aria-expanded="true"] svg { + transform: rotate(180deg); +} + +.source-row { + background: #f8f9fc; +} + +.source-details { + padding: 3px 15px 8px 30px; + font-size: 12px; +} + +.source-details > p { + margin-top: 9px; + color: #856023; +} + +.source-details dl { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 15px 24px; + margin-bottom: 0; +} + +.source-details dt { + margin-bottom: 5px; + font-size: 10px; + color: var(--ink-soft); +} + +.source-details dd { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.empty-table { + padding: 50px 20px; + text-align: center; + color: var(--ink-soft); +} + +.table-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 11px 17px; + border-top: 1px solid var(--import-border); + color: var(--ink-soft); + font-size: 11px; +} + +.table-footer > div { + display: flex; + align-items: center; + gap: 9px; +} + +.run-summary { + margin-top: 22px; + padding: 20px; + border: 1px solid #dce5f2; + border-radius: 12px; + background: #f3f7ff; +} + +.run-heading { + display: flex; + align-items: center; + gap: 10px; + color: #3564a8; +} + +.run-heading h2 { + font-size: 14px; + font-weight: 500; +} + +.run-heading .button { + margin-left: auto; +} + +.run-summary p { + margin-top: 12px; + font-size: 12px; + line-height: 1.6; +} + +.uncertain-note { + color: #93621f; +} + +.muted { + color: var(--ink-soft); +} + +.destination-panel { + margin-top: 26px; + padding: 23px; + border: 1px solid #d7deec; + border-radius: 12px; + background: #fff; + box-shadow: 0 3px 16px #223d6410; +} + +.destination-panel h2 { + font-size: 17px; + font-weight: 500; + letter-spacing: -0.3px; +} + +.destination-panel > div > p { + margin-top: 6px; + color: var(--ink-soft); + font-size: 12px; +} + +.destination-fields { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + margin-top: 20px; +} + +.destination-fields > .field { + width: 310px; +} + +.destination-buttons { + display: flex; + align-items: center; + gap: 10px; +} + +.connection-prompt { + display: flex; + align-items: center; + gap: 10px; + max-width: 360px; + margin-bottom: 5px; + font-size: 12px; + line-height: 1.5; + color: var(--ink-soft); +} + +.connection-prompt svg { + flex-shrink: 0; +} + +.connection-error { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; + margin-top: 15px; + color: #a93831; + font-size: 12px; +} + +.eligibility-note { + margin-top: 14px; + color: #856023; + font-size: 12px; +} + +.destination-note { + margin-top: 20px; + padding-top: 15px; + border-top: 1px solid #eceef1; + color: var(--ink-soft); + font-size: 11px; + line-height: 1.6; +} + +.import-footer { + display: flex; + align-items: center; + gap: 6px; + margin-top: 31px; + font-size: 11px; + color: var(--ink-soft); +} + +.import-footer a { + margin-left: auto; + color: var(--ink-soft); +} + +.import-dialog { + max-width: 520px; + width: calc(100vw - 40px); + padding: 28px; + border: 1px solid #d9dfe8; + border-radius: 17px; + box-shadow: 0 20px 80px #10254433; + color: var(--ink); +} + +.import-dialog::backdrop { + background: #1c273866; + backdrop-filter: blur(3px); +} + +.dialog-heading { + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.dialog-icon { + display: flex; + align-items: center; + justify-content: center; + width: 45px; + height: 45px; + border: 1px solid #dae5f9; + border-radius: 12px; + background: #eef4ff; + color: var(--import-blue); +} + +.import-dialog h2 { + margin: 21px 0 12px; + font-size: 23px; + font-weight: 500; + letter-spacing: -0.7px; +} + +.import-dialog p { + font-size: 13px; + line-height: 1.65; + color: var(--ink-soft); +} + +.confirmation-summary { + display: grid; + gap: 11px; + margin: 20px 0; + padding: 16px; + border: 1px solid #e6e9ef; + border-radius: 9px; + background: #f8faff; + font-size: 12px; +} + +.confirmation-summary div { + display: flex; + justify-content: space-between; + gap: 15px; +} + +.confirmation-summary dt { + color: var(--ink-soft); +} + +.confirmation-summary dd { + margin: 0; + text-align: right; +} + +.import-dialog .dialog-note { + margin-bottom: 11px; + font-size: 11px; +} + +.confirmation-check { + display: flex; + align-items: flex-start; + gap: 10px; + margin: 20px 0 24px; + font-size: 12px; + line-height: 1.55; +} + +.confirmation-check input { + flex-shrink: 0; + margin-top: 2px; +} + +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: 9px; +} + +.import-loading { + display: flex; + align-items: center; + flex-direction: column; + justify-content: center; + gap: 18px; + min-height: 65vh; + padding: 30px; + text-align: center; + color: var(--ink-soft); + font-size: 14px; +} + +.import-loading h1 { + font-size: 24px; + font-weight: 500; + color: var(--ink); +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.spin { + animation: import-spin 1.2s linear infinite; +} + +@keyframes import-spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 1050px) { + .privacy-pill { + font-size: 10px; + } + .mapping-fields { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .destination-fields { + align-items: stretch; + flex-direction: column; + gap: 16px; + } + .destination-fields > .field { + width: 100%; + max-width: 400px; + } +} + +@media (max-width: 760px) { + .page-nav-inner { + width: calc(100vw - 36px); + } + .page-nav-links { + overflow-x: auto; + max-width: calc(100vw - 140px); + } + .page-nav-link { + font-size: 12px; + padding: 5px 9px; + } + .import-layout { + padding: 30px 20px 22px; + } + .import-heading { + flex-direction: column; + gap: 14px; + } + .privacy-pill { + margin: 0; + } + .getting-started { + gap: 25px; + grid-template-columns: 1fr; + } + .getting-started p { + max-width: none; + } + .inventory-overview { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 22px; + } + .stat:nth-child(3) { + padding-left: 0; + border: 0; + } + .inventory-toolbar { + align-items: stretch; + flex-direction: column; + gap: 12px; + } + .search-field input { + width: 100%; + } + .file-strip { + flex-wrap: wrap; + } + .file-actions { + width: 100%; + padding-left: 34px; + } + .mapping-panel summary span { + display: none; + } + .mapping-fields { + grid-template-columns: 1fr; + } + .selection-actions { + flex-wrap: wrap; + gap: 14px; + } + .report-button { + margin-left: 0; + } + .source-details dl { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .table-footer { + padding: 9px 12px; + } + .destination-buttons { + flex-wrap: wrap; + } + .destination-buttons .button { + flex: 1; + white-space: nowrap; + } + .import-footer { + flex-wrap: wrap; + line-height: 1.6; + } + .import-footer a { + margin-left: 0; + width: 100%; + } +} + +@media (prefers-reduced-motion: reduce) { + .spin { + animation: none; + } + .button, + .drop-zone, + .coverage-bar span, + .page-nav-link { + transition: none; + } +} From fed298e5a87766da491903b78187cc9dac50a2ee Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:43:17 +0100 Subject: [PATCH 6/8] feat: guide Loom account imports in extension --- apps/chrome-extension/e2e/importer.spec.ts | 1981 +++++++++++++++++ apps/chrome-extension/migrate.html | 12 + .../src/importer/loom-capture.test.ts | 79 + .../src/importer/loom-capture.ts | 422 ++++ .../src/importer/migration-api.ts | 256 +++ .../src/importer/migration.css | 471 ++++ .../src/importer/migration.tsx | 860 +++++++ .../src/popup/components/import-button.tsx | 12 + apps/chrome-extension/src/popup/main.tsx | 29 +- apps/chrome-extension/src/shared/page-nav.ts | 1 + apps/chrome-extension/vite.config.ts | 2 + 11 files changed, 4119 insertions(+), 6 deletions(-) create mode 100644 apps/chrome-extension/e2e/importer.spec.ts create mode 100644 apps/chrome-extension/migrate.html create mode 100644 apps/chrome-extension/src/importer/loom-capture.test.ts create mode 100644 apps/chrome-extension/src/importer/loom-capture.ts create mode 100644 apps/chrome-extension/src/importer/migration-api.ts create mode 100644 apps/chrome-extension/src/importer/migration.css create mode 100644 apps/chrome-extension/src/importer/migration.tsx create mode 100644 apps/chrome-extension/src/popup/components/import-button.tsx diff --git a/apps/chrome-extension/e2e/importer.spec.ts b/apps/chrome-extension/e2e/importer.spec.ts new file mode 100644 index 0000000000..ffa4277cb8 --- /dev/null +++ b/apps/chrome-extension/e2e/importer.spec.ts @@ -0,0 +1,1981 @@ +import { randomUUID } from "node:crypto"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { createServer, type ServerResponse } from "node:http"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + type BrowserContext, + test as base, + chromium, + expect, + type Page, + type TestInfo, + type Worker, +} from "@playwright/test"; +import type { ImportContext } from "../src/importer/api"; +import { parseInventory } from "../src/importer/inventory"; + +const extensionPath = path.resolve( + process.env.CAP_EXTENSION_TEST_DIR ?? + path.join(path.dirname(fileURLToPath(import.meta.url)), "../dist"), +); +const authKey = "cap-extension-auth"; +const settingsKey = "cap-extension-settings"; +const userId = "11111111-1111-4111-8111-111111111111"; +const otherUserId = "22222222-2222-4222-8222-222222222222"; +const organizationId = "33333333-3333-4333-8333-333333333333"; +const otherOrganizationId = "44444444-4444-4444-8444-444444444444"; +const firstId = "0123456789abcdef0123456789abcdef"; +const secondId = "fedcba9876543210fedcba9876543210"; +const thirdId = "00112233445566778899aabbccddeeff"; +const firstUrl = `https://www.loom.com/share/${firstId}`; +const secondUrl = `https://www.loom.com/share/${secondId}`; +const thirdUrl = `https://www.loom.com/share/${thirdId}`; +const mixedCsv = [ + "Video Link,Video Name,Creator Email,Folder,review_decision,Duration", + `${firstUrl},Launch walkthrough,alex\\@example.test,Product / Guides,approved,02:10`, + ",Unshared walkthrough,casey@example.test,Product / Private,,01:35", + `https://loom.com/embed/${firstId},Duplicate launch,alex@example.test,Product / Guides,approved,02:10`, + `${secondUrl},Needs editorial review,writer@example.test,Product / Guides,pending,03:20`, + `${thirdUrl},Release overview,pat@example.test,Engineering,approved,00:45`, +].join("\r\n"); +const twoVideoCsv = [ + "Video Link,Video Name,Creator Email", + `${firstUrl},First walkthrough,alex@example.test`, + `${secondUrl},Second walkthrough,casey@example.test`, +].join("\r\n"); +const loomWorkspace = "Synthetic Loom workspace"; +const nativeLoomCsv = [ + "Video Link,Video Name,Creator Email,Workspace,Folder,Video Creation Date,Duration", + `${firstUrl},Native launch walkthrough,alex\\@example.test,Can View,Product / Guides,2026-07-15,02:10`, + ",Unshared archive,casey@example.test,No Access,Private / Archive,2026-08-01,01:00", + `${secondUrl},Native release overview,pat@example.test,Can View,Engineering / Releases,2026-08-12,03:10`, +].join("\r\n"); +const duplicateNativeLoomCsv = [ + "Video Link,Video Name,Creator Email,Workspace,Folder,Video Creation Date,Duration", + `${firstUrl},Native launch walkthrough,alex\\@example.test,Can View,Product / Guides,2026-07-15,02:10`, + `${secondUrl},Native release overview,casey@example.test,Can View,Engineering / Releases,2026-08-12,03:10`, + `https://loom.com/embed/${firstId},Duplicate native launch,alex@example.test,Can View,Product / Guides,2026-07-15,02:10`, +].join("\r\n"); +const cookieName = "cap-importer-fixture-session"; + +type ImportRequest = { + organizationId: string; + row: { + rowNumber: number; + loomUrl: string; + userEmail: string; + spaceName?: string; + }; +}; + +type MigrationRequest = { + requestId: string; + expectedUserId: string; + expectedDefaultPublic: boolean; + organizationId: string; + source: { + workspace: string; + from: string; + to: string; + totalRows: number; + omittedRows: number; + }; + rows: { rowNumber: number; loomUrl: string; userEmail: string }[]; +}; + +const initialContext = (): ImportContext => ({ + user: { id: userId, email: "alex@example.test" }, + organizations: [ + { id: organizationId, name: "Importer fixture team", canImport: true }, + ], + activeOrganizationId: organizationId, + isPro: true, + defaultPublic: false, + maxRows: 500, +}); + +const sendJson = (response: ServerResponse, status: number, body: unknown) => { + response.writeHead(status, { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "Authorization, Content-Type", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Content-Type": "application/json", + }); + response.end(status === 204 ? undefined : JSON.stringify(body)); +}; + +const sendBatchReceipt = ( + response: ServerResponse, + request: MigrationRequest, +) => { + sendJson(response, 200, { + operationId: "fixturebatch001", + dashboardPath: `/dashboard/import/loom/status?operationId=fixturebatch001&organizationId=${request.organizationId}`, + }); +}; + +const createFixtureServer = async () => { + const state = { + context: initialContext(), + requests: [] as ImportRequest[], + contextRequests: 0, + holdRequests: false, + peakRequests: 0, + invalidBearerHeaders: 0, + cookieHeaders: 0, + batchRequests: [] as MigrationRequest[], + holdBatchRequests: false, + cookieSessionRequests: 0, + invalidCookieSessions: 0, + unexpectedAuthorizationHeaders: 0, + }; + let authorizedCookie: string | null = null; + const authorizedTokens = new Set(); + const pending = new Map(); + const pendingBatches = new Map(); + const active = new Set(); + const acceptCookieSession = ( + headers: { authorization?: string; cookie?: string }, + response: ServerResponse, + ) => { + state.cookieSessionRequests++; + const authorized = + authorizedCookie !== null && + headers.cookie + ?.split(";") + .some((value) => value.trim() === authorizedCookie); + const hasAuthorization = headers.authorization !== undefined; + if (!authorized) state.invalidCookieSessions++; + if (hasAuthorization) state.unexpectedAuthorizationHeaders++; + if (!authorized || hasAuthorization) { + sendJson(response, 401, { + error: "The synthetic dashboard request requires its browser session.", + }); + return false; + } + return true; + }; + const server = createServer(async (request, response) => { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + if (request.method === "OPTIONS") { + sendJson(response, 204, null); + return; + } + if ( + url.pathname === "/api/extension/import-loom/batch" && + request.method === "POST" + ) { + if (!acceptCookieSession(request.headers, response)) return; + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const body = JSON.parse( + Buffer.concat(chunks).toString("utf8"), + ) as MigrationRequest; + const index = state.batchRequests.push(body) - 1; + response.once("close", () => pendingBatches.delete(index)); + if (state.holdBatchRequests) pendingBatches.set(index, response); + else sendBatchReceipt(response, body); + return; + } + if (url.pathname === "/api/extension/import-loom") { + if (request.method === "GET" || request.method === "POST") { + if (authorizedCookie !== null) { + if (!acceptCookieSession(request.headers, response)) return; + } else { + const authorized = + typeof request.headers.authorization === "string" && + authorizedTokens.has(request.headers.authorization); + const hasCookie = request.headers.cookie !== undefined; + if (!authorized) state.invalidBearerHeaders++; + if (hasCookie) state.cookieHeaders++; + if (!authorized || hasCookie) { + sendJson(response, 401, { + error: "The synthetic importer request has invalid credentials.", + }); + return; + } + } + } + if (request.method === "GET") { + state.contextRequests++; + sendJson(response, 200, state.context); + return; + } + if (request.method === "POST") { + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const body = JSON.parse( + Buffer.concat(chunks).toString("utf8"), + ) as ImportRequest; + const index = state.requests.push(body) - 1; + active.add(response); + state.peakRequests = Math.max(state.peakRequests, active.size); + response.once("close", () => { + active.delete(response); + pending.delete(index); + }); + if (state.holdRequests) pending.set(index, response); + else + sendJson(response, 200, { + success: true, + videoId: `fixture-video-${index + 1}`, + }); + return; + } + } + if (url.pathname === "/api/extension/bootstrap") { + sendJson(response, 200, { + user: state.context.user, + organization: state.context.organizations[0], + plan: { isPro: state.context.isPro, maxRecordingSeconds: 600 }, + }); + return; + } + if (url.pathname.startsWith("/dashboard")) { + response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + response.end( + "Cap fixture dashboard

Fixture Cap dashboard

", + ); + return; + } + sendJson(response, 404, { error: "Unknown synthetic fixture endpoint" }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("The importer fixture server did not get a local port."); + } + return { + origin: `http://127.0.0.1:${address.port}`, + state, + authorize: (token: string) => authorizedTokens.add(`Bearer ${token}`), + allowCookieSession: (value: string) => { + authorizedCookie = `${cookieName}=${value}`; + }, + assertHeaders: () => { + expect( + state.invalidBearerHeaders, + "Importer GET and POST requests must use a seeded synthetic bearer token", + ).toBe(0); + expect( + state.cookieHeaders, + "Importer GET and POST requests must omit Cookie headers", + ).toBe(0); + expect( + state.invalidCookieSessions, + "Dashboard importer requests must use the seeded browser session", + ).toBe(0); + expect( + state.unexpectedAuthorizationHeaders, + "Dashboard importer requests must not use extension bearer credentials", + ).toBe(0); + }, + respond: (index: number, body: unknown) => { + const response = pending.get(index); + if (!response) throw new Error(`No pending fixture request ${index}.`); + pending.delete(index); + sendJson(response, 200, body); + }, + releaseBatch: (index: number) => { + const response = pendingBatches.get(index); + const request = state.batchRequests[index]; + if (!response || !request) + throw new Error(`No pending fixture batch ${index}.`); + pendingBatches.delete(index); + sendBatchReceipt(response, request); + }, + disconnect: async (index: number, headersReceived: Promise) => { + const response = pending.get(index); + if (!response) throw new Error(`No pending fixture request ${index}.`); + pending.delete(index); + // Chromium retries a POST if its socket closes before response headers arrive. + response.writeHead(200, { + "Access-Control-Allow-Origin": "*", + "Content-Type": "application/json", + "Content-Length": "128", + }); + response.flushHeaders(); + response.write('{"success":true,"videoId":"'); + await headersReceived; + response.destroy(); + }, + close: () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + server.closeAllConnections(); + }), + }; +}; + +type FixtureServer = Awaited>; + +const setConnection = async ( + worker: Worker, + server: FixtureServer, + token: string, + signedIn = true, +) => { + if (signedIn) server.authorize(token); + await worker.evaluate( + async (values) => { + await chrome.storage.local.set({ + [values.settingsKey]: { + apiBaseUrl: values.apiBaseUrl, + capture: { + recordingMode: "fullscreen", + camera: null, + microphone: null, + }, + webcam: { + enabled: false, + deviceId: null, + position: "bottom-left", + size: 230, + shape: "round", + mirror: false, + }, + microphone: { enabled: false, deviceId: null }, + systemAudio: { enabled: false }, + sounds: { enabled: false }, + countdown: { enabled: false, seconds: 3 }, + microphoneWarning: { enabled: false }, + }, + [values.authKey]: values.signedIn + ? { authApiKey: values.token, userId: values.userId } + : null, + }); + }, + { + settingsKey, + authKey, + apiBaseUrl: server.origin, + token, + signedIn, + userId: server.state.context.user.id, + }, + ); +}; + +type Harness = { + context: BrowserContext; + page: Page; + worker: Worker; + server: FixtureServer; + token: string; + url: string; + open: (signedIn?: boolean) => Promise; +}; + +const test = base.extend<{ harness: Harness }>({ + harness: async ({ browserName }, use) => { + if (browserName !== "chromium") + throw new Error("The importer extension tests require Chromium."); + const profile = await mkdtemp(path.join(tmpdir(), "cap-importer-e2e-")); + const server = await createFixtureServer(); + let context: BrowserContext | undefined; + try { + context = await chromium.launchPersistentContext(profile, { + channel: "chromium", + headless: true, + acceptDownloads: true, + viewport: { width: 1440, height: 1100 }, + args: [ + "--no-proxy-server", + // Extension-created tabs can navigate before Playwright installs their route. + "--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1, EXCLUDE localhost", + `--disable-extensions-except=${extensionPath}`, + `--load-extension=${extensionPath}`, + ], + }); + await context.route(/^https?:\/\//, async (route) => { + if (new URL(route.request().url()).hostname === "127.0.0.1") + await route.continue(); + else await route.abort("blockedbyclient"); + }); + const worker = + context + .serviceWorkers() + .find((item) => item.url().includes("assets/service-worker.js")) ?? + (await context.waitForEvent("serviceworker", (item) => + item.url().includes("assets/service-worker.js"), + )); + await worker.evaluate(async () => chrome.storage.local.clear()); + const page = await context.newPage(); + const url = `chrome-extension://${new URL(worker.url()).host}/import.html`; + const token = randomUUID(); + await use({ + context, + page, + worker, + server, + token, + url, + open: async (signedIn = true) => { + await setConnection(worker, server, token, signedIn); + await page.goto(url); + await expect( + page.getByRole("heading", { name: "Drop your export here" }), + ).toBeVisible(); + if (signedIn) + await expect + .poll(() => server.state.contextRequests) + .toBeGreaterThan(0); + }, + }); + server.assertHeaders(); + } finally { + try { + await context?.close(); + } finally { + await server.close(); + await rm(profile, { recursive: true, force: true }); + } + } + }, +}); + +const uploadInventory = async ( + page: Page, + content = mixedCsv, + name = "synthetic-loom.csv", +) => { + await page.getByLabel("Choose inventory file").setInputFiles({ + name, + mimeType: name.endsWith(".json") + ? "application/json" + : name.endsWith(".tsv") + ? "text/tab-separated-values" + : "text/csv", + buffer: Buffer.from(content), + }); + await expect(page.getByText(name, { exact: true })).toBeVisible(); + await expect( + page.getByRole("region", { name: "Video inventory" }), + ).toBeVisible(); +}; + +const recordRow = (page: Page, record: number) => + page.getByRole("row").filter({ + has: page.getByRole("checkbox", { + name: `Select record ${record}`, + exact: true, + }), + }); + +const downloadCsv = async ( + page: Page, + testInfo: TestInfo, + label: string, + filename: string, +) => { + const pending = page.waitForEvent("download"); + await page.getByRole("button", { name: label, exact: true }).click(); + const download = await pending; + expect(download.suggestedFilename()).toBe(filename); + const destination = testInfo.outputPath(filename); + await download.saveAs(destination); + expect(await download.failure()).toBeNull(); + return readFile(destination, "utf8"); +}; + +const confirmImport = async (page: Page, count: number) => { + await page + .getByRole("button", { + name: `Import ${count} ${count === 1 ? "video" : "videos"}`, + exact: true, + }) + .click(); + const dialog = page.getByRole("dialog", { + name: "Ready to bring these over?", + }); + const start = dialog.getByRole("button", { + name: `Start ${count} ${count === 1 ? "import" : "imports"}`, + exact: true, + }); + await expect(start).toBeDisabled(); + await dialog + .getByRole("checkbox", { + name: "I’ve reviewed the selected videos, owners, Spaces and visibility.", + }) + .check(); + await start.click(); +}; + +const readSaved = (page: Page, key: "draft" | "run") => + page.evaluate( + (key) => + new Promise((resolve, reject) => { + const opened = indexedDB.open("cap-loom-importer", 1); + opened.onerror = () => reject(opened.error); + opened.onsuccess = () => { + const database = opened.result; + const transaction = database.transaction("inventory", "readonly"); + const request = transaction.objectStore("inventory").get(key); + transaction.oncomplete = () => { + database.close(); + resolve(request.result ?? null); + }; + transaction.onabort = () => { + database.close(); + reject(transaction.error); + }; + }; + }), + key, + ); + +const routeNativeLoom = async ( + context: BrowserContext, + options: { + csv?: string; + totalRows?: number; + workspaceAfterDownload?: string; + from?: string; + to?: string; + } = {}, +) => { + const state = { + requests: 0, + authorizationHeaders: 0, + cookieHeaders: 0, + }; + const csv = JSON.stringify(options.csv ?? nativeLoomCsv).replaceAll( + "<", + "\\u003c", + ); + const body = ` +Synthetic Loom workspace export +
+Product guides
+
+

Workspace Settings Data

+
+

Export engagement insights

+ + +

Export all ${options.totalRows ?? 3} videos created in this date range.

+ +
WorkspaceVideo Name
Can ViewNative launch walkthrough
+ +
+`; + await context.route("https://www.loom.com/**", async (route) => { + state.requests++; + const headers = await route.request().allHeaders(); + if (headers.authorization) state.authorizationHeaders++; + if (headers.cookie) state.cookieHeaders++; + await route.fulfill({ + status: 200, + contentType: "text/html; charset=utf-8", + body, + }); + }); + const page = await context.newPage(); + await page.goto("https://www.loom.com/settings/workspace#data"); + await expect( + page.getByRole("heading", { + name: "Export engagement insights", + exact: true, + }), + ).toBeVisible(); + return state; +}; + +const readNativeCaptureState = (page: Page) => + page.evaluate(() => { + const fixture = ( + window as Window & { + __nativeLoomFixture?: { + exports: number; + createObjectURL: typeof URL.createObjectURL; + anchorClick: typeof HTMLAnchorElement.prototype.click; + }; + } + ).__nativeLoomFixture; + if (!fixture) throw new Error("The synthetic Loom fixture did not load."); + return { + exports: fixture.exports, + createObjectURLRestored: URL.createObjectURL === fixture.createObjectURL, + anchorClickRestored: + HTMLAnchorElement.prototype.click === fixture.anchorClick, + }; + }); + +const openMigration = async (harness: Harness) => { + await setConnection(harness.worker, harness.server, harness.token, false); + await harness.page.goto(new URL("migrate.html", harness.url).toString()); + await expect( + harness.page.getByRole("heading", { + name: "Move your Loom library to Cap", + exact: true, + }), + ).toBeVisible(); +}; + +const connectNativeLoom = async (harness: Harness) => { + const loom = harness.context + .pages() + .find( + (page) => page.url() === "https://www.loom.com/settings/workspace#data", + ); + if (!loom) throw new Error("The routed synthetic Loom tab was not opened."); + await loom.reload(); + await harness.page + .getByRole("button", { name: "Connect Loom", exact: true }) + .click(); + await expect(loom).toHaveURL("https://www.loom.com/settings/workspace#data"); + await expect( + harness.page.getByText(loomWorkspace, { exact: true }), + ).toBeVisible(); + await expect( + harness.page.getByRole("button", { name: "Next", exact: true }), + ).toBeEnabled(); + return loom; +}; + +const prepareNativeLoom = async (harness: Harness) => { + const loom = await connectNativeLoom(harness); + await harness.page.getByRole("button", { name: "Next", exact: true }).click(); + await expect( + harness.page.getByRole("heading", { + name: "Your CSV is ready", + exact: true, + }), + ).toBeVisible(); + return loom; +}; + +const expandMigrationPreview = async (page: Page) => { + await page + .getByText("Preview videos and full report", { exact: true }) + .click(); + await expect( + page.getByRole("region", { name: "Video inventory" }), + ).toBeVisible(); +}; + +const seedCapCookieSession = async (harness: Harness) => { + const cookie = randomUUID(); + harness.server.allowCookieSession(cookie); + await harness.context.addCookies([ + { + name: cookieName, + value: cookie, + url: harness.server.origin, + httpOnly: true, + sameSite: "Lax", + }, + ]); +}; + +const connectCookieCap = async (harness: Harness) => { + await seedCapCookieSession(harness); + const existing = harness.context + .pages() + .find((page) => + page.url().startsWith(`${harness.server.origin}/dashboard`), + ); + const opened = existing + ? Promise.resolve(existing) + : harness.context.waitForEvent("page"); + await harness.page + .getByRole("button", { name: "Import to Cap", exact: true }) + .click(); + const cap = await opened; + await expect(cap).toHaveURL(`${harness.server.origin}/dashboard/caps`); + await expect( + harness.page.getByRole("heading", { name: "Import to Cap", exact: true }), + ).toBeVisible(); + await expect( + harness.page + .getByRole("region", { + name: "Confirm your Cap destination", + exact: true, + }) + .getByText("alex@example.test", { exact: true }), + ).toBeVisible(); + await expect( + harness.page.getByRole("combobox", { + name: "Cap organization", + exact: true, + }), + ).toHaveValue(organizationId); + return cap; +}; + +test("signed-out review retains attention records and downloads only the selected import rows", async ({ + harness, +}, testInfo) => { + const { page, server } = harness; + await harness.open(false); + await page.evaluate(() => window.scrollTo(0, 0)); + await page.screenshot({ + path: testInfo.outputPath("importer-empty.png"), + fullPage: true, + animations: "disabled", + }); + await uploadInventory(page); + await expect( + page.getByText("5 source records · saved locally"), + ).toBeVisible(); + await expect( + page.getByRole("checkbox", { name: "Select record 1", exact: true }), + ).toBeChecked(); + await expect( + page.getByRole("checkbox", { name: "Select record 5", exact: true }), + ).toBeChecked(); + for (const record of [2, 3, 4]) { + await expect( + page.getByRole("checkbox", { + name: `Select record ${record}`, + exact: true, + }), + ).not.toBeChecked(); + } + await expect( + page.getByRole("checkbox", { name: "Select record 2", exact: true }), + ).toBeDisabled(); + await expect( + page.getByRole("checkbox", { name: "Select record 3", exact: true }), + ).toBeDisabled(); + await expect(recordRow(page, 2)).toContainText("Missing link"); + await expect(recordRow(page, 3)).toContainText("Duplicate"); + await expect(recordRow(page, 4)).toContainText("Needs review"); + await page.getByRole("button", { name: "Select ready", exact: true }).click(); + await expect( + page.getByRole("checkbox", { name: "Select record 4", exact: true }), + ).not.toBeChecked(); + await expect( + page.getByRole("button", { name: "Sign in to Cap", exact: true }), + ).toBeVisible(); + await page.evaluate(() => window.scrollTo(0, 0)); + await page.screenshot({ + path: testInfo.outputPath("importer-inventory.png"), + fullPage: true, + animations: "disabled", + }); + + const selected = parseInventory( + await downloadCsv( + page, + testInfo, + "Download import CSV", + "cap-loom-import.csv", + ), + "selected.csv", + ); + expect(selected.headers).toEqual([ + "loom_video_url", + "user_email", + "space_name", + ]); + expect(selected.records).toEqual([ + [firstUrl, "alex@example.test", ""], + [thirdUrl, "pat@example.test", ""], + ]); + const report = parseInventory( + await downloadCsv( + page, + testInfo, + "Download full report", + "cap-loom-inventory-report.csv", + ), + "report.csv", + ); + expect(report.records).toHaveLength(5); + expect( + report.records.map( + (record) => record[report.headers.indexOf("source_record_number")], + ), + ).toEqual(["1", "2", "3", "4", "5"]); + expect( + report.records.map( + (record) => record[report.headers.indexOf("validation_status")], + ), + ).toEqual(["ready", "missing-link", "duplicate", "review-required", "ready"]); + expect(report.records[1].slice(0, 4)).toEqual([ + "", + "Unshared walkthrough", + "casey@example.test", + "Product / Private", + ]); + expect(server.state.requests).toEqual([]); + expect(server.state.contextRequests).toBe(0); + await page.setViewportSize({ width: 480, height: 960 }); + await page.evaluate(() => window.scrollTo(0, 0)); + await page.screenshot({ + path: testInfo.outputPath("importer-inventory-narrow.png"), + fullPage: true, + animations: "disabled", + }); +}); + +test("the signed-out popup opens account migration and keeps the manual CSV tool available", async ({ + harness, +}) => { + const { page, context, server, worker, token } = harness; + await setConnection(worker, server, token, false); + await page.goto(new URL("popup.html", harness.url).toString()); + const opened = context.waitForEvent("page"); + await page + .getByRole("button", { name: "Import from Loom", exact: true }) + .click(); + const importer = await opened; + await expect(importer).toHaveURL( + new URL("migrate.html", harness.url).toString(), + ); + await expect( + importer.getByRole("heading", { + name: "Move your Loom library to Cap", + exact: true, + }), + ).toBeVisible(); + await expect( + importer.getByRole("button", { name: "Connect Loom", exact: true }), + ).toBeVisible(); + await importer + .getByRole("link", { name: "Open the CSV file tool", exact: true }) + .click(); + await expect(importer).toHaveURL(harness.url); + await uploadInventory(importer, twoVideoCsv); + await expect( + importer.getByRole("button", { name: "Sign in to Cap", exact: true }), + ).toBeVisible(); + expect(server.state.requests).toEqual([]); + expect(server.state.contextRequests).toBe(0); +}); + +test("empty and malformed files show errors, then a valid JSON inventory can be reviewed", async ({ + harness, +}) => { + const { page, server } = harness; + await harness.open(false); + await page.getByLabel("Choose inventory file").setInputFiles({ + name: "empty.csv", + mimeType: "text/csv", + buffer: Buffer.alloc(0), + }); + await expect(page.getByRole("alert")).toContainText("This file is empty."); + await expect( + page.getByRole("heading", { name: "Drop your export here", exact: true }), + ).toBeVisible(); + await page.getByLabel("Choose inventory file").setInputFiles({ + name: "malformed.json", + mimeType: "application/json", + buffer: Buffer.from('{"videos":['), + }); + await expect(page.getByRole("alert")).toContainText("not valid JSON"); + await uploadInventory( + page, + JSON.stringify({ + videos: [ + { + loom_video_url: firstUrl, + title: "JSON walkthrough", + user_email: "alex@example.test", + review_decision: "", + }, + ], + }), + "synthetic-loom.json", + ); + await expect(page.getByRole("alert")).toHaveCount(0); + await expect(recordRow(page, 1)).toContainText("JSON walkthrough"); + await expect(recordRow(page, 1)).toContainText("Needs review"); + await page.getByRole("button", { name: "Select ready", exact: true }).click(); + await expect( + page.getByRole("checkbox", { name: "Select record 1", exact: true }), + ).not.toBeChecked(); + await expect( + page.getByRole("button", { name: "Download import CSV", exact: true }), + ).toBeDisabled(); + await page + .getByRole("checkbox", { name: "Select record 1", exact: true }) + .check(); + await expect( + page.getByRole("checkbox", { name: "Select record 1", exact: true }), + ).toBeChecked(); + expect(server.state.requests).toEqual([]); +}); + +test("owner overrides keep provenance and folder paths map only to an explicitly chosen flat Space", async ({ + harness, +}, testInfo) => { + const { page } = harness; + await harness.open(false); + await uploadInventory( + page, + `Video Link\tVideo Name\tCreator\tFolder\n${firstUrl}\tTraining walkthrough\talex\\@example.test\tTeams / Enablement\n${secondUrl}\tSupport walkthrough\tcasey@example.test\tTeams / Support`, + "synthetic-loom.tsv", + ); + await expect( + page.getByRole("combobox", { name: "Destination Space", exact: true }), + ).toHaveValue("none"); + await expect(recordRow(page, 1)).toContainText("No Space"); + await page + .getByRole("combobox", { name: "Cap video owner", exact: true }) + .selectOption("override"); + await page + .getByLabel("Owner email", { exact: true }) + .fill("import-owner@example.test"); + await expect(recordRow(page, 1)).toContainText("From alex@example.test"); + await page + .getByRole("combobox", { name: "Destination Space", exact: true }) + .selectOption("column"); + await expect( + page.getByRole("combobox", { name: "Space column", exact: true }), + ).toHaveValue("-1"); + await page + .getByRole("combobox", { name: "Space column", exact: true }) + .selectOption({ label: "Folder" }); + await expect(recordRow(page, 1).locator("td").nth(3)).toHaveText( + "Teams / Enablement", + ); + await expect( + page.getByText("Named Spaces are reused or created as flat Spaces.", { + exact: false, + }), + ).toBeVisible(); + await page + .getByRole("button", { name: "Source details for record 1", exact: true }) + .click(); + await expect( + page.getByText("alex\\@example.test", { exact: true }), + ).toBeVisible(); + + const csv = parseInventory( + await downloadCsv( + page, + testInfo, + "Download import CSV", + "cap-loom-import.csv", + ), + "selected.csv", + ); + expect(csv.records).toEqual([ + [firstUrl, "import-owner@example.test", "Teams / Enablement"], + [secondUrl, "import-owner@example.test", "Teams / Support"], + ]); +}); + +test("Pro and organization-role gates disable submission without blocking local review", async ({ + harness, +}) => { + const { page, server } = harness; + server.state.context.isPro = false; + server.state.context.organizations = [ + { id: organizationId, name: "Read-only fixture team", canImport: false }, + { id: otherOrganizationId, name: "Managed fixture team", canImport: true }, + ]; + await harness.open(); + await uploadInventory(page, twoVideoCsv); + await expect( + page.getByRole("button", { name: "Import 2 videos", exact: true }), + ).toBeDisabled(); + await expect( + page.getByText("Loom imports require Cap Pro.", { exact: false }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Download import CSV", exact: true }), + ).toBeEnabled(); + + server.state.context.isPro = true; + await page.reload(); + await expect( + page.getByText( + "Choose an organization where you’re an admin or owner to import.", + ), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Import 2 videos", exact: true }), + ).toBeDisabled(); + await page + .getByRole("combobox", { name: "Cap organization", exact: true }) + .selectOption(otherOrganizationId); + await expect( + page.getByRole("button", { name: "Import 2 videos", exact: true }), + ).toBeEnabled(); + expect(server.state.requests).toEqual([]); +}); + +test("confirmation starts only selected valid videos, one request at a time, without claiming completion", async ({ + harness, +}, testInfo) => { + const { page, server } = harness; + server.state.holdRequests = true; + await harness.open(); + await uploadInventory(page); + await page + .getByRole("checkbox", { name: "Select record 5", exact: true }) + .uncheck(); + await page + .getByRole("checkbox", { name: "Select record 4", exact: true }) + .check(); + await page + .getByRole("button", { name: "Import 2 videos", exact: true }) + .click(); + const dialog = page.getByRole("dialog", { + name: "Ready to bring these over?", + }); + await expect(dialog).toContainText("Importer fixture team"); + await expect( + dialog.getByText("alex@example.test", { exact: true }), + ).toBeVisible(); + await expect(dialog).toContainText("Private"); + await expect( + dialog.getByRole("button", { name: "Start 2 imports", exact: true }), + ).toBeDisabled(); + expect(server.state.requests).toEqual([]); + await dialog + .getByRole("checkbox", { + name: "I’ve reviewed the selected videos, owners, Spaces and visibility.", + }) + .check(); + await dialog + .getByRole("button", { name: "Start 2 imports", exact: true }) + .click(); + await expect.poll(() => server.state.requests.length).toBe(1); + await expect(recordRow(page, 1)).toContainText("Starting…"); + server.respond(0, { success: true, videoId: "fixture-started-one" }); + await expect.poll(() => server.state.requests.length).toBe(2); + server.respond(1, { + success: true, + videoId: "fixture-existing-review", + existing: true, + }); + await expect( + page.getByRole("heading", { name: "Your import progress", exact: true }), + ).toBeVisible(); + await expect(recordRow(page, 1)).toContainText("Started in Cap"); + await expect(recordRow(page, 4)).toContainText("Already in Cap"); + await expect( + page.getByText( + "“Started” means Cap accepted the import, not that processing or playback is complete.", + { exact: false }, + ), + ).toBeVisible(); + await expect( + page.getByRole("combobox", { name: "Cap video owner", exact: true }), + ).toBeDisabled(); + expect(server.state.peakRequests).toBe(1); + expect(server.state.requests).toEqual([ + { + organizationId, + row: { rowNumber: 1, loomUrl: firstUrl, userEmail: "alex@example.test" }, + }, + { + organizationId, + row: { + rowNumber: 4, + loomUrl: secondUrl, + userEmail: "writer@example.test", + }, + }, + ]); + await expect( + page.getByRole("button", { name: "Download import CSV", exact: true }), + ).toBeDisabled(); + await page + .getByRole("checkbox", { name: "Select record 5", exact: true }) + .check(); + const remaining = parseInventory( + await downloadCsv( + page, + testInfo, + "Download import CSV", + "cap-loom-import.csv", + ), + "remaining.csv", + ); + expect(remaining.records).toEqual([[thirdUrl, "pat@example.test", ""]]); + const report = parseInventory( + await downloadCsv( + page, + testInfo, + "Download full report", + "cap-loom-inventory-report.csv", + ), + "report.csv", + ); + expect( + report.records.map( + (record) => record[report.headers.indexOf("cap_import_status")], + ), + ).toEqual([ + "started", + "not-submitted", + "not-submitted", + "existing", + "not-submitted", + ]); +}); + +test("an uncertain network result stops the queue and locks that record against replay", async ({ + harness, +}, testInfo) => { + const { page, server } = harness; + server.state.holdRequests = true; + await harness.open(); + await uploadInventory(page, twoVideoCsv); + await confirmImport(page, 2); + await expect.poll(() => server.state.requests.length).toBe(1); + const headersReceived = page.waitForResponse( + (response) => + response.url() === `${server.origin}/api/extension/import-loom` && + response.request().method() === "POST", + ); + await server.disconnect(0, headersReceived); + await expect + .poll(async () => ({ + requestCount: server.state.requests.length, + saved: await readSaved(page, "run"), + })) + .toMatchObject({ + requestCount: 1, + saved: { outcomes: { 1: { state: "uncertain" } } }, + }); + await expect( + page.getByRole("heading", { name: "Your import progress", exact: true }), + ).toBeVisible(); + await expect(recordRow(page, 1)).toContainText("Check in Cap"); + await expect( + page.getByRole("checkbox", { name: "Select record 1", exact: true }), + ).toBeDisabled(); + await expect( + page.getByText( + "Unconfirmed rows are locked to prevent accidental repeats.", + { exact: false }, + ), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Import 1 video", exact: true }), + ).toBeEnabled(); + expect(server.state.requests.map((request) => request.row.rowNumber)).toEqual( + [1], + ); + const remaining = parseInventory( + await downloadCsv( + page, + testInfo, + "Download import CSV", + "cap-loom-import.csv", + ), + "remaining.csv", + ); + expect(remaining.records).toEqual([[secondUrl, "casey@example.test", ""]]); +}); + +test("a deselection is retained when the saved inventory is immediately reloaded", async ({ + harness, +}) => { + const { page } = harness; + await harness.open(false); + await uploadInventory(page, twoVideoCsv); + await page + .getByRole("checkbox", { name: "Select record 2", exact: true }) + .uncheck(); + await expect + .poll(() => readSaved(page, "draft")) + .toMatchObject({ selected: [1] }); + page.once("dialog", async (dialog) => dialog.accept()); + await page.reload(); + await expect( + page.getByRole("checkbox", { name: "Select record 1", exact: true }), + ).toBeChecked(); + await expect( + page.getByRole("checkbox", { name: "Select record 2", exact: true }), + ).not.toBeChecked(); +}); + +test("corrupt saved progress is reported without discarding it or submitting videos", async ({ + harness, +}) => { + const { page, server } = harness; + await harness.open(false); + await uploadInventory(page, twoVideoCsv); + const corruptedRun = { + draftId: "corrupted-fixture", + outcomes: { 1: { sourceRecord: 1, state: "future-unknown-state" } }, + }; + await page.evaluate( + (run) => + new Promise((resolve, reject) => { + const opened = indexedDB.open("cap-loom-importer", 1); + opened.onerror = () => reject(opened.error); + opened.onsuccess = () => { + const database = opened.result; + const transaction = database.transaction("inventory", "readwrite"); + transaction.objectStore("inventory").put(run, "run"); + transaction.oncomplete = () => { + database.close(); + resolve(); + }; + transaction.onabort = () => { + database.close(); + reject(transaction.error); + }; + }; + }), + corruptedRun, + ); + await page.reload(); + await expect(page.getByRole("alert")).toContainText( + "Saved import progress cannot be read safely.", + ); + expect(await readSaved(page, "run")).toEqual(corruptedRun); + await expect(page.getByRole("button", { name: /^Import \d/ })).toHaveCount(0); + expect(server.state.requests).toEqual([]); +}); + +test("reload restores the inventory and turns an interrupted sending record into a locked uncertainty", async ({ + harness, +}) => { + const { page, server } = harness; + server.state.holdRequests = true; + await harness.open(); + await uploadInventory(page, twoVideoCsv); + await confirmImport(page, 2); + await expect.poll(() => server.state.requests.length).toBe(1); + await expect + .poll(() => readSaved(page, "run")) + .toMatchObject({ outcomes: { 1: { state: "sending" } } }); + page.once("dialog", async (dialog) => dialog.accept()); + await page.reload(); + await expect( + page.getByText("2 source records · saved locally"), + ).toBeVisible(); + await expect(recordRow(page, 1)).toContainText("Check in Cap"); + await expect( + page.getByRole("checkbox", { name: "Select record 1", exact: true }), + ).toBeDisabled(); + await expect( + page.getByRole("checkbox", { name: "Select record 2", exact: true }), + ).toBeChecked(); + await expect( + page.getByRole("button", { name: "Import 1 video", exact: true }), + ).toBeEnabled(); + await expect + .poll(() => readSaved(page, "run")) + .toMatchObject({ outcomes: { 1: { state: "uncertain" } } }); + expect(server.state.requests.map((request) => request.row.rowNumber)).toEqual( + [1], + ); +}); + +test("one tab owns the inventory and a saved run cannot move to another Cap server or account", async ({ + harness, +}) => { + const { page, server, worker, token } = harness; + const alternateServer = await createFixtureServer(); + try { + await harness.open(); + await uploadInventory(page, twoVideoCsv); + const otherTab = await harness.context.newPage(); + await otherTab.goto(harness.url); + await expect( + otherTab.getByRole("heading", { + name: "Your importer is already open", + exact: true, + }), + ).toBeVisible(); + await expect(otherTab.getByLabel("Choose inventory file")).toHaveCount(0); + await page + .getByRole("checkbox", { name: "Select record 2", exact: true }) + .uncheck(); + await confirmImport(page, 1); + await expect(recordRow(page, 1)).toContainText("Started in Cap"); + await expect( + page.getByRole("heading", { name: "Your import progress", exact: true }), + ).toBeVisible(); + await page + .getByRole("checkbox", { name: "Select record 2", exact: true }) + .check(); + await expect( + page.getByRole("button", { name: "Import 1 video", exact: true }), + ).toBeEnabled(); + + const alternateContext = page.waitForResponse( + (response) => + response.url() === + `${alternateServer.origin}/api/extension/import-loom` && + response.request().method() === "GET", + ); + await setConnection(worker, alternateServer, token); + await alternateContext; + await expect( + page.getByRole("combobox", { name: "Cap organization", exact: true }), + ).toBeVisible(); + await expect( + page.getByText("Connecting to Cap…", { exact: true }), + ).toHaveCount(0); + await expect( + page.getByText("This run belongs to a different Cap connection.", { + exact: false, + }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Import 1 video", exact: true }), + ).toBeDisabled(); + await expect( + recordRow(page, 1).getByRole("link", { + name: "Open in Cap", + exact: true, + }), + ).toHaveAttribute("href", `${server.origin}/s/fixture-video-1`); + + server.state.context.user = { + id: otherUserId, + email: "other@example.test", + }; + const otherAccountContext = page.waitForResponse( + (response) => + response.url() === `${server.origin}/api/extension/import-loom` && + response.request().method() === "GET", + ); + await setConnection(worker, server, randomUUID()); + await otherAccountContext; + await expect( + page.getByRole("combobox", { name: "Cap organization", exact: true }), + ).toBeVisible(); + await expect( + page.getByText("Connecting to Cap…", { exact: true }), + ).toHaveCount(0); + await expect( + page.getByText("This run belongs to a different Cap connection.", { + exact: false, + }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Import 1 video", exact: true }), + ).toBeDisabled(); + server.state.context.user = initialContext().user; + await setConnection(worker, server, token); + await expect( + page.getByRole("button", { name: "Import 1 video", exact: true }), + ).toBeEnabled(); + await expect + .poll(() => readSaved(page, "draft")) + .toMatchObject({ selected: [1, 2] }); + expect(server.state.requests).toHaveLength(1); + expect(alternateServer.state.requests).toEqual([]); + + await page.close(); + await otherTab.reload(); + await expect( + otherTab.getByText("2 source records · saved locally"), + ).toBeVisible(); + await expect(recordRow(otherTab, 1)).toContainText("Started in Cap"); + alternateServer.assertHeaders(); + } finally { + await alternateServer.close(); + } +}); + +test("native Loom CSV-only capture preserves omitted records without contacting Cap and restores the download hooks", async ({ + harness, +}, testInfo) => { + const { page, context, server } = harness; + const loomRequests = await routeNativeLoom(context); + await page.emulateMedia({ reducedMotion: "reduce" }); + await openMigration(harness); + const from = page.getByLabel("From", { exact: true }); + const through = page.getByLabel("Through", { exact: true }); + await expect(from).toBeHidden(); + await expect(through).toBeHidden(); + for (const name of ["Next", "Connect Cap", "Import to Cap", "Start import"]) { + await expect(page.getByRole("button", { name, exact: true })).toBeHidden(); + } + const exportOptions = page.getByText("Export options", { exact: true }); + await exportOptions.click(); + await expect(from).toBeVisible(); + await expect(through).toBeVisible(); + await expect(from).toHaveValue("1970-01-01"); + const today = await page.evaluate(() => { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; + }); + await expect(through).toHaveValue(today); + await exportOptions.click(); + await page.evaluate(() => window.scrollTo(0, 0)); + await page.screenshot({ + path: testInfo.outputPath("migration-empty.png"), + fullPage: true, + animations: "disabled", + }); + const reference = await context.newPage(); + await reference.goto(new URL("how-it-works.html", harness.url).toString()); + await expect( + reference.getByRole("heading", { name: "How Cap works", exact: true }), + ).toBeVisible(); + await reference.screenshot({ + path: testInfo.outputPath("theme-how-it-works.png"), + fullPage: true, + animations: "disabled", + }); + await reference.close(); + + const loom = await connectNativeLoom(harness); + await expect(loom.getByLabel("Start date", { exact: true })).toHaveValue( + "1970-01-01", + ); + await expect(loom.getByLabel("End date", { exact: true })).toHaveValue(today); + expect(server.state.contextRequests).toBe(0); + expect(server.state.batchRequests).toEqual([]); + await page.evaluate(() => window.scrollTo(0, 0)); + await page.screenshot({ + path: testInfo.outputPath("migration-loom-connected.png"), + fullPage: true, + animations: "disabled", + }); + const nativeDownload = loom.waitForEvent("download"); + await page.getByRole("button", { name: "Next", exact: true }).click(); + await expect( + page.getByRole("heading", { name: "Your CSV is ready", exact: true }), + ).toBeVisible(); + const sourceDownload = await nativeDownload; + expect(sourceDownload.suggestedFilename()).toBe("synthetic-native-loom.csv"); + expect(await sourceDownload.failure()).toBeNull(); + expect(await readNativeCaptureState(loom)).toEqual({ + exports: 1, + createObjectURLRestored: true, + anchorClickRestored: true, + }); + const preview = page.getByRole("region", { name: "Video inventory" }); + await expect(preview).toBeHidden(); + await expect( + page.getByRole("button", { name: "Download full report", exact: true }), + ).toBeHidden(); + await expect( + page.getByRole("button", { name: "Import to Cap", exact: true }), + ).toBeEnabled(); + for (const name of ["Connect Cap", "Start import"]) { + await expect(page.getByRole("button", { name, exact: true })).toBeHidden(); + } + await expect( + page.getByRole("checkbox", { name: /^I understand/ }), + ).toBeHidden(); + await page.evaluate(() => window.scrollTo(0, 0)); + await page.screenshot({ + path: testInfo.outputPath("migration-csv-ready.png"), + fullPage: true, + animations: "disabled", + }); + await expandMigrationPreview(page); + for (const [record, status] of [ + [1, "Ready"], + [2, "Missing link"], + [3, "Ready"], + ] as const) { + const row = preview.getByRole("row").filter({ + has: page.getByRole("button", { + name: `Source details for record ${record}`, + exact: true, + }), + }); + await expect(row).toContainText(status); + await expect(row).toContainText("No Space"); + } + await expect(preview.getByRole("checkbox")).toHaveCount(0); + const csv = await downloadCsv( + page, + testInfo, + "Download CSV", + "cap-loom-import.csv", + ); + expect(parseInventory(csv, "prepared.csv")).toEqual({ + headers: ["loom_video_url", "user_email", "space_name"], + records: [ + [firstUrl, "alex@example.test", ""], + [secondUrl, "pat@example.test", ""], + ], + }); + const report = parseInventory( + await downloadCsv( + page, + testInfo, + "Download full report", + "cap-loom-inventory-report.csv", + ), + "report.csv", + ); + const source = parseInventory(nativeLoomCsv, "native.csv"); + expect(report.headers.slice(0, source.headers.length)).toEqual( + source.headers, + ); + expect( + report.records.map((row) => row.slice(0, source.headers.length)), + ).toEqual(source.records); + expect( + report.records.map( + (row) => row[report.headers.indexOf("validation_status")], + ), + ).toEqual(["ready", "missing-link", "ready"]); + expect( + report.records.map( + (row) => row[report.headers.indexOf("source_record_number")], + ), + ).toEqual(["1", "2", "3"]); + await page.evaluate(() => window.scrollTo(0, 0)); + await page.screenshot({ + path: testInfo.outputPath("migration-csv-preview.png"), + fullPage: true, + animations: "disabled", + }); + await page + .getByText("Preview videos and full report", { exact: true }) + .click(); + await page.setViewportSize({ width: 480, height: 960 }); + await page.evaluate(() => window.scrollTo(0, 0)); + await page.screenshot({ + path: testInfo.outputPath("migration-csv-ready-narrow.png"), + fullPage: true, + animations: "disabled", + }); + expect(loomRequests.requests).toBeGreaterThan(0); + expect(loomRequests.authorizationHeaders).toBe(0); + expect(loomRequests.cookieHeaders).toBe(0); + expect(server.state.contextRequests).toBe(0); + expect(server.state.requests).toEqual([]); + expect(server.state.batchRequests).toEqual([]); +}); + +test("account migration uses the Cap browser session for one acknowledged batch and opens its dashboard receipt", async ({ + harness, +}, testInfo) => { + const { page, context, server, worker } = harness; + const loomRequests = await routeNativeLoom(context, { + csv: duplicateNativeLoomCsv, + }); + await openMigration(harness); + await seedCapCookieSession(harness); + const loom = await prepareNativeLoom(harness); + const from = await loom + .getByLabel("Start date", { exact: true }) + .inputValue(); + const to = await loom.getByLabel("End date", { exact: true }).inputValue(); + expect(server.state.contextRequests).toBe(0); + const cap = await connectCookieCap(harness); + expect(server.state.batchRequests).toEqual([]); + expect(server.state.requests).toEqual([]); + const start = page.getByRole("button", { + name: "Start import", + exact: true, + }); + await expect(start).toBeDisabled(); + const acknowledge = page.getByRole("checkbox", { name: /^I understand/ }); + await expect(acknowledge).not.toBeChecked(); + await acknowledge.check(); + await expect(start).toBeEnabled(); + await start.click(); + await expect.poll(() => server.state.batchRequests.length).toBe(1); + await expect(cap).toHaveURL( + `${server.origin}/dashboard/import/loom/status?operationId=fixturebatch001&organizationId=${organizationId}`, + ); + await expect( + page.getByRole("link", { name: "View import in dashboard", exact: true }), + ).toHaveAttribute("href", cap.url()); + await expect(start).toBeHidden(); + expect(server.state.batchRequests).toEqual([ + { + requestId: expect.stringMatching( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ), + expectedUserId: userId, + expectedDefaultPublic: false, + organizationId, + source: { + workspace: loomWorkspace, + from, + to, + totalRows: 3, + omittedRows: 1, + }, + rows: [ + { rowNumber: 1, loomUrl: firstUrl, userEmail: "alex@example.test" }, + { rowNumber: 2, loomUrl: secondUrl, userEmail: "casey@example.test" }, + ], + }, + ]); + await expandMigrationPreview(page); + const report = parseInventory( + await downloadCsv( + page, + testInfo, + "Download full report", + "cap-loom-inventory-report.csv", + ), + "report.csv", + ); + expect(report.records).toHaveLength(3); + expect( + report.records.map( + (row) => row[report.headers.indexOf("validation_status")], + ), + ).toEqual(["ready", "ready", "duplicate"]); + expect(await readNativeCaptureState(loom)).toEqual({ + exports: 1, + createObjectURLRestored: true, + anchorClickRestored: true, + }); + expect( + await worker.evaluate(async (key) => { + const values = await chrome.storage.local.get(key); + return values[key]; + }, authKey), + ).toBeNull(); + expect(loomRequests.requests).toBeGreaterThan(0); + expect(loomRequests.authorizationHeaders).toBe(0); + expect(loomRequests.cookieHeaders).toBe(0); + expect(server.state.contextRequests).toBe(2); + expect(server.state.cookieSessionRequests).toBe(3); + expect(server.state.requests).toEqual([]); +}); + +test("a native Loom count mismatch stops preparation before contacting Cap", async ({ + harness, +}) => { + const { page, server, context } = harness; + await routeNativeLoom(context, { totalRows: 4 }); + await openMigration(harness); + const loom = await connectNativeLoom(harness); + await page.getByRole("button", { name: "Next", exact: true }).click(); + await expect(page.getByRole("alert")).toContainText( + "Loom reported 4 videos but returned 3 records.", + ); + await expect( + page.getByRole("heading", { name: "Your CSV is ready", exact: true }), + ).toHaveCount(0); + expect(server.state.batchRequests).toEqual([]); + expect(server.state.requests).toEqual([]); + expect(server.state.contextRequests).toBe(0); + expect(await readNativeCaptureState(loom)).toEqual({ + exports: 1, + createObjectURLRestored: true, + anchorClickRestored: true, + }); +}); + +test("a Loom workspace change during native capture cannot be submitted to Cap", async ({ + harness, +}) => { + const { page, server, context } = harness; + await routeNativeLoom(context, { + workspaceAfterDownload: "Another synthetic workspace", + }); + await openMigration(harness); + const loom = await connectNativeLoom(harness); + await page.getByRole("button", { name: "Next", exact: true }).click(); + await expect(page.getByRole("alert")).toContainText( + "Loom’s workspace or dates changed during capture. Nothing was imported.", + ); + await expect( + page.getByRole("heading", { name: "Your CSV is ready", exact: true }), + ).toHaveCount(0); + expect(server.state.batchRequests).toEqual([]); + expect(server.state.requests).toEqual([]); + expect(server.state.contextRequests).toBe(0); + expect(await readNativeCaptureState(loom)).toEqual({ + exports: 1, + createObjectURLRestored: true, + anchorClickRestored: true, + }); +}); + +test("changing the Cap dashboard account after connecting blocks the migration before its batch POST", async ({ + harness, +}) => { + const { page, server, context } = harness; + await routeNativeLoom(context); + await openMigration(harness); + const loom = await prepareNativeLoom(harness); + const cap = await connectCookieCap(harness); + server.state.context.user = { + id: otherUserId, + email: "changed-account@example.test", + }; + await page.getByRole("checkbox", { name: /^I understand/ }).check(); + await page.getByRole("button", { name: "Start import", exact: true }).click(); + await expect(page.getByRole("alert")).toHaveText( + "The Cap account changed. Reconnect before starting an import.", + ); + await expect( + page.getByRole("link", { name: "View import in dashboard", exact: true }), + ).toHaveCount(0); + await expect(cap).toHaveURL(`${server.origin}/dashboard/caps`); + expect(server.state.contextRequests).toBe(2); + expect(server.state.batchRequests).toEqual([]); + expect(server.state.requests).toEqual([]); + expect(await readNativeCaptureState(loom)).toEqual({ + exports: 1, + createObjectURLRestored: true, + anchorClickRestored: true, + }); +}); + +test("Cap completes the batch handoff after the importer closes before the response arrives", async ({ + harness, +}) => { + const { page, context, server, worker } = harness; + await routeNativeLoom(context); + await openMigration(harness); + await prepareNativeLoom(harness); + const cap = await connectCookieCap(harness); + server.state.holdBatchRequests = true; + await page.getByRole("checkbox", { name: /^I understand/ }).check(); + await page.getByRole("button", { name: "Start import", exact: true }).click(); + await expect.poll(() => server.state.batchRequests.length).toBe(1); + await expect(cap).toHaveURL(`${server.origin}/dashboard/caps`); + await expect + .poll(() => + worker.evaluate(async () => { + const [active] = await chrome.tabs.query({ + active: true, + currentWindow: true, + }); + return active?.url; + }), + ) + .toBe(`${server.origin}/dashboard/caps`); + await page.close(); + server.releaseBatch(0); + await expect(cap).toHaveURL( + `${server.origin}/dashboard/import/loom/status?operationId=fixturebatch001&organizationId=${organizationId}`, + ); + expect(server.state.batchRequests).toHaveLength(1); + expect(server.state.requests).toEqual([]); + expect(server.state.contextRequests).toBe(2); + expect(server.state.cookieSessionRequests).toBe(3); +}); + +test("reloading an identical Loom workspace requires reconnection before capturing or importing", async ({ + harness, +}) => { + const { page, context, server } = harness; + await openMigration(harness); + const from = await page.getByLabel("From", { exact: true }).inputValue(); + const to = await page.getByLabel("Through", { exact: true }).inputValue(); + await routeNativeLoom(context, { from, to }); + const loom = await connectNativeLoom(harness); + const next = page.getByRole("button", { + name: "Next", + exact: true, + }); + await expect(next).toBeEnabled(); + await loom.reload(); + await expect(loom.getByLabel("Start date", { exact: true })).toHaveValue( + from, + ); + await expect(loom.getByLabel("End date", { exact: true })).toHaveValue(to); + await expect( + loom.getByRole("button", { name: loomWorkspace, exact: true }), + ).toBeVisible(); + await expect( + loom.getByText("Export all 3 videos created in this date range.", { + exact: true, + }), + ).toBeVisible(); + await expect(page.getByRole("alert")).toHaveText( + "Loom navigated after connecting. Reconnect Loom before continuing.", + ); + await expect(next).toBeHidden(); + await expect( + page.getByRole("button", { name: "Connect Loom", exact: true }), + ).toBeEnabled(); + await expect( + page.getByRole("button", { name: "Import to Cap", exact: true }), + ).toBeHidden(); + expect(await readNativeCaptureState(loom)).toEqual({ + exports: 0, + createObjectURLRestored: true, + anchorClickRestored: true, + }); + await connectNativeLoom(harness); + await expect(next).toBeEnabled(); + await next.click(); + await expect( + page.getByRole("heading", { name: "Your CSV is ready", exact: true }), + ).toBeVisible(); + expect(await readNativeCaptureState(loom)).toEqual({ + exports: 1, + createObjectURLRestored: true, + anchorClickRestored: true, + }); + expect(server.state.batchRequests).toEqual([]); + expect(server.state.requests).toEqual([]); + expect(server.state.contextRequests).toBe(0); +}); + +test("a changed visible Space link blocks capture while the Loom workspace label stays the same", async ({ + harness, +}) => { + const { page, context, server } = harness; + await routeNativeLoom(context); + await openMigration(harness); + const loom = await connectNativeLoom(harness); + await loom + .getByRole("link", { name: "Product guides", exact: true }) + .evaluate((anchor) => + anchor.setAttribute("href", "/spaces/fixture-other-guides"), + ); + await expect( + loom.getByRole("button", { name: loomWorkspace, exact: true }), + ).toBeVisible(); + await page.getByRole("button", { name: "Next", exact: true }).click(); + await expect(page.getByRole("alert")).toContainText( + "Loom’s workspace, visible Space links, dates or report count changed.", + ); + await expect( + page.getByRole("heading", { name: "Your CSV is ready", exact: true }), + ).toHaveCount(0); + expect(await readNativeCaptureState(loom)).toEqual({ + exports: 0, + createObjectURLRestored: true, + anchorClickRestored: true, + }); + expect(server.state.batchRequests).toEqual([]); + expect(server.state.requests).toEqual([]); + expect(server.state.contextRequests).toBe(0); +}); + +test("an active Loom-origin export lock prevents nested capture hooks and allows a later retry", async ({ + harness, +}) => { + const { page, context, server } = harness; + await routeNativeLoom(context); + await openMigration(harness); + const loom = await connectNativeLoom(harness); + await loom.evaluate( + () => + new Promise((ready, reject) => { + void navigator.locks + .request( + "cap-loom-native-export", + () => + new Promise((release) => { + window.addEventListener( + "fixture-release-export", + () => release(), + { once: true }, + ); + ready(); + }), + ) + .catch(reject); + }), + ); + const next = page.getByRole("button", { + name: "Next", + exact: true, + }); + await next.click(); + await expect(page.getByRole("alert")).toHaveText( + "Loom is already building a CSV for Cap. Wait for that export to finish before trying again.", + ); + expect(await readNativeCaptureState(loom)).toEqual({ + exports: 0, + createObjectURLRestored: true, + anchorClickRestored: true, + }); + await loom.evaluate(() => + window.dispatchEvent(new Event("fixture-release-export")), + ); + await expect + .poll(() => + loom.evaluate(async () => { + const state = await navigator.locks.query(); + return state.held?.some( + (lock) => lock.name === "cap-loom-native-export", + ); + }), + ) + .toBe(false); + await next.click(); + await expect( + page.getByRole("heading", { name: "Your CSV is ready", exact: true }), + ).toBeVisible(); + expect(await readNativeCaptureState(loom)).toEqual({ + exports: 1, + createObjectURLRestored: true, + anchorClickRestored: true, + }); + expect(server.state.contextRequests).toBe(0); + expect(server.state.batchRequests).toEqual([]); + expect(server.state.requests).toEqual([]); +}); + +test("guided backward navigation preserves the prepared CSV and never submits without confirmation", async ({ + harness, +}, testInfo) => { + const { page, context, server } = harness; + await routeNativeLoom(context); + await openMigration(harness); + const loom = await prepareNativeLoom(harness); + expect(server.state.contextRequests).toBe(0); + const cap = await connectCookieCap(harness); + const start = page.getByRole("button", { name: "Start import", exact: true }); + await expect(start).toBeDisabled(); + expect(server.state.batchRequests).toEqual([]); + await page.evaluate(() => window.scrollTo(0, 0)); + await page.screenshot({ + path: testInfo.outputPath("migration-destination-review.png"), + fullPage: true, + animations: "disabled", + }); + await page.getByRole("button", { name: "Back", exact: true }).click(); + await expect( + page.getByRole("heading", { name: "Your CSV is ready", exact: true }), + ).toBeVisible(); + await expect(start).toBeHidden(); + await expect( + page.getByRole("button", { name: "Download CSV", exact: true }), + ).toBeEnabled(); + await page.getByRole("button", { name: "Back to Loom", exact: true }).click(); + await expect(page.getByText(loomWorkspace, { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Next", exact: true }).click(); + await expect( + page.getByRole("heading", { name: "Your CSV is ready", exact: true }), + ).toBeVisible(); + expect(await readNativeCaptureState(loom)).toEqual({ + exports: 1, + createObjectURLRestored: true, + anchorClickRestored: true, + }); + const sameCap = await connectCookieCap(harness); + expect(sameCap).toBe(cap); + await expect(start).toBeDisabled(); + await expect( + page.getByRole("checkbox", { name: /^I understand/ }), + ).not.toBeChecked(); + expect(server.state.batchRequests).toEqual([]); + expect(server.state.requests).toEqual([]); +}); diff --git a/apps/chrome-extension/migrate.html b/apps/chrome-extension/migrate.html new file mode 100644 index 0000000000..940f870608 --- /dev/null +++ b/apps/chrome-extension/migrate.html @@ -0,0 +1,12 @@ + + + + + + Move from Loom to Cap + + +
+ + + diff --git a/apps/chrome-extension/src/importer/loom-capture.test.ts b/apps/chrome-extension/src/importer/loom-capture.test.ts new file mode 100644 index 0000000000..cf9291c0eb --- /dev/null +++ b/apps/chrome-extension/src/importer/loom-capture.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { parseInventory } from "./inventory"; +import { type LoomExportSource, prepareLoomCapture } from "./loom-capture"; +import { capOrigin } from "./migration-api"; + +const link = "https://www.loom.com/share/0123456789abcdef0123456789abcdef"; +const headers = + "Video Link,Video Name,Creator Email,Workspace,Folder,Video Creation Date"; +const source: LoomExportSource = { + workspace: "Example team", + from: "1970-01-01", + to: "2026-09-02", + totalRows: 3, +}; +const csv = `${headers}\n${link},Demo,owner\\@example.test,Can Edit,Private / Guides,09/01/2026\n,,other@example.test,No Access,,09/02/2026\n${link},Duplicate,owner@example.test,Can View,Private / Guides,09/01/2026`; + +describe("Loom account capture", () => { + it("builds a canonical CSV while preserving omitted and duplicate source records", () => { + const result = prepareLoomCapture(csv, source); + expect(result.eligible).toHaveLength(1); + expect(result.omittedRows).toBe(2); + expect(result.rows.map((row) => row.issue)).toEqual([ + null, + "missing-link", + "duplicate", + ]); + expect(parseInventory(result.importCsv, "import.csv").records).toEqual([ + [link, "owner@example.test", ""], + ]); + expect(parseInventory(result.reportCsv, "report.csv").records).toHaveLength( + 3, + ); + expect(result.rows[0]).toMatchObject({ + spaceName: "", + createdAt: "09/01/2026", + }); + expect(result.table.records[0]?.[4]).toBe("Private / Guides"); + expect(result.table.records.map((row) => row[3])).toEqual([ + "Can Edit", + "No Access", + "Can View", + ]); + expect(result.source.workspace).toBe("Example team"); + }); + it("rejects truncated reports before any handoff", () => { + expect(() => prepareLoomCapture(csv, { ...source, totalRows: 4 })).toThrow( + "returned 3 records", + ); + }); + it("requires the native engagement inventory schema", () => { + expect(() => + prepareLoomCapture( + `loom_video_url,user_email\n${link},owner@example.test`, + { ...source, totalRows: 1 }, + ), + ).toThrow("CSV format has changed"); + }); + it("keeps an entirely inaccessible report available without importable rows", () => { + const result = prepareLoomCapture( + `${headers}\n,,owner@example.test,No Access,,09/02/2026`, + { ...source, totalRows: 1 }, + ); + expect(result.eligible).toEqual([]); + expect(result.omittedRows).toBe(1); + expect(result.reportCsv).toContain("missing-link"); + }); + it("accepts secure self-hosted Cap and local development but never credentials or insecure remote origins", () => { + expect(capOrigin("https://cap.example.test/base")).toBe( + "https://cap.example.test", + ); + expect(capOrigin("http://localhost:3000")).toBe("http://localhost:3000"); + expect(() => capOrigin("https://user:password@cap.example.test")).toThrow( + "secure Cap URL", + ); + expect(() => capOrigin("http://cap.example.test")).toThrow( + "secure Cap URL", + ); + }); +}); diff --git a/apps/chrome-extension/src/importer/loom-capture.ts b/apps/chrome-extension/src/importer/loom-capture.ts new file mode 100644 index 0000000000..063342c251 --- /dev/null +++ b/apps/chrome-extension/src/importer/loom-capture.ts @@ -0,0 +1,422 @@ +import { + buildInventory, + detectColumns, + exportImportCsv, + exportInventoryCsv, + parseInventory, +} from "./inventory"; + +export type LoomExportSource = { + workspace: string; + from: string; + to: string; + totalRows: number; +}; + +export type LoomExportState = + | { status: "ready"; source: LoomExportSource; visibleSpaceLinks: string[] } + | { status: "unavailable"; message: string }; + +type LoomExportCommand = + | { type: "inspect" } + | { type: "range"; from: string; to: string } + | { + type: "capture"; + expected: LoomExportSource; + visibleSpaceLinks: string[]; + }; + +type LoomExportResult = LoomExportState | { status: "captured"; csv: string }; + +export async function loomExportBridge( + command: LoomExportCommand, +): Promise { + const unavailable = (message: string): LoomExportResult => ({ + status: "unavailable", + message, + }); + if ( + location.origin !== "https://www.loom.com" || + location.pathname !== "/settings/workspace" || + location.hash !== "#data" + ) { + return unavailable( + "Open Loom’s Workspace settings → Data while signed in.", + ); + } + const visible = (element: Element): element is HTMLElement => + element instanceof HTMLElement && + element.getClientRects().length > 0 && + getComputedStyle(element).visibility !== "hidden"; + const text = (element: Element) => + (element.textContent ?? "").trim().replace(/\s+/g, " "); + const buttons = (root: Element) => + [...root.querySelectorAll("button, [role=button]")].filter( + (element) => visible(element) && text(element) === "Download CSV", + ); + const headings = [...document.querySelectorAll("h1, h2, h3, [role=heading]")]; + const readWorkspace = () => { + const settingsHeading = [ + ...document.querySelectorAll("h1, [role=heading]"), + ].find( + (element) => + visible(element) && /^Workspace Settings\b/.test(text(element)), + ); + if (!settingsHeading) return null; + const walker = document.createTreeWalker( + document.body, + NodeFilter.SHOW_TEXT, + ); + let previous = ""; + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + if (settingsHeading.contains(node)) break; + const value = node.textContent?.trim().replace(/\s+/g, " ") ?? ""; + if ( + node.parentElement && + visible(node.parentElement) && + value && + !/^[\s/>›·]+$/.test(value) + ) + previous = value; + } + if (!previous || previous.length > 255) return null; + return [...document.querySelectorAll("button, [role=button]")].some( + (element) => visible(element) && text(element) === previous, + ) + ? previous + : null; + }; + const heading = headings.find( + (element) => + visible(element) && text(element) === "Export engagement insights", + ); + if (!heading) { + return unavailable( + "Loom’s engagement export is not available yet. Wait for the page to load. This export requires a Loom workspace admin on Business, Business + AI or Enterprise.", + ); + } + const readVisibleSpaceLinks = () => + [ + ...new Set( + [...document.querySelectorAll("a[href]")] + .filter( + (anchor) => + visible(anchor) && + Boolean( + anchor.compareDocumentPosition(heading) & + Node.DOCUMENT_POSITION_FOLLOWING, + ), + ) + .flatMap((anchor) => { + const url = new URL(anchor.href); + return url.origin === "https://www.loom.com" && + url.pathname.startsWith("/spaces/") + ? [`${url.origin}${url.pathname}`] + : []; + }), + ), + ].sort(); + let section: HTMLElement | null = heading.parentElement; + while ( + section && + (buttons(section).length !== 1 || + section.querySelectorAll('input[type="date"]').length !== 2) + ) { + section = section.parentElement; + } + if ( + !section || + section === document.body || + section === document.documentElement + ) { + return unavailable( + "Loom’s export controls have changed. No export was started.", + ); + } + const dates = [ + ...section.querySelectorAll('input[type="date"]'), + ]; + const dateNamed = (name: string) => + dates.find( + (input) => + visible(input) && + (input.getAttribute("aria-label") === name || + [...(input.labels ?? [])].some((label) => text(label) === name)), + ); + const from = dateNamed("Start date"); + const to = dateNamed("End date"); + const button = buttons(section)[0]; + if (!from || !to || !(button instanceof HTMLButtonElement)) { + return unavailable( + "Could not identify Loom’s date filters and export button safely.", + ); + } + if (command.type === "range") { + const validDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value); + if ( + !validDate(command.from) || + !validDate(command.to) || + command.from > command.to + ) { + return unavailable("Choose a valid export date range."); + } + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )?.set; + if (!setter) + return unavailable("Could not set Loom’s export date filters."); + for (const [input, value] of [ + [from, command.from], + [to, command.to], + ] as const) { + setter.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + } + return unavailable("Updating Loom’s report date range…"); + } + const count = text(section).match(/(?:all\s+)?([\d,]+)\s+videos?\s+created/i); + const totalRows = count ? Number(count[1]?.replaceAll(",", "")) : Number.NaN; + // Loom's "Workspace" CSV column is an access level, not the workspace name. + const workspace = readWorkspace(); + if ( + !Number.isSafeInteger(totalRows) || + totalRows < 0 || + totalRows > 50_000 || + !/^\d{4}-\d{2}-\d{2}$/.test(from.value) || + !/^\d{4}-\d{2}-\d{2}$/.test(to.value) + ) { + return unavailable( + "Loom’s report count or dates could not be verified. Nothing was imported.", + ); + } + if (totalRows === 0) + return unavailable( + "Loom reports no videos in this workspace for these dates.", + ); + if (!workspace) { + return unavailable( + "Could not verify the workspace name against Loom’s visible breadcrumb and workspace selector.", + ); + } + if (button.disabled || button.getAttribute("aria-disabled") === "true") { + return unavailable( + "Loom is still preparing the report. Try again in a moment.", + ); + } + const source: LoomExportSource = { + workspace, + from: from.value, + to: to.value, + totalRows, + }; + const visibleSpaceLinks = readVisibleSpaceLinks(); + if (command.type === "inspect") + return { status: "ready", source, visibleSpaceLinks }; + if ( + source.workspace !== command.expected.workspace || + source.from !== command.expected.from || + source.to !== command.expected.to || + source.totalRows !== command.expected.totalRows || + JSON.stringify(visibleSpaceLinks) !== + JSON.stringify(command.visibleSpaceLinks) + ) { + return unavailable( + "Loom’s workspace, visible Space links, dates or report count changed. Reconnect Loom before continuing.", + ); + } + return navigator.locks.request( + "cap-loom-native-export", + { ifAvailable: true }, + async (lock) => { + if (!lock) + return unavailable( + "Loom is already building a CSV for Cap. Wait for that export to finish before trying again.", + ); + const originalCreate = URL.createObjectURL; + const originalClick = HTMLAnchorElement.prototype.click; + let finish: (result: LoomExportResult) => void = () => {}; + let settled = false; + let directDownload = false; + let timer = 0; + const result = new Promise((resolve) => { + finish = (value) => { + if (settled) return; + settled = true; + resolve(value); + }; + }); + const inspectBlob = async (blob: Blob) => { + if (settled || blob.size === 0 || blob.size > 10 * 1024 * 1024) return; + if ( + blob.type && + !/^(text\/|application\/(csv|octet-stream))/.test(blob.type) + ) + return; + try { + const csv = await blob.text(); + const header = csv.replace(/^\uFEFF/, "").split(/\r?\n/, 1)[0] ?? ""; + if ( + ["Video Link", "Video Name", "Creator Email", "Workspace"].every( + (name) => header.includes(name), + ) + ) { + if ( + readWorkspace() !== source.workspace || + !document.contains(heading) || + JSON.stringify(readVisibleSpaceLinks()) !== + JSON.stringify(command.visibleSpaceLinks) || + !document.contains(from) || + !document.contains(to) || + from.value !== source.from || + to.value !== source.to || + location.origin !== "https://www.loom.com" || + location.pathname !== "/settings/workspace" || + location.hash !== "#data" + ) { + finish( + unavailable( + "Loom’s workspace or dates changed during capture. Nothing was imported. Reconnect Loom before continuing.", + ), + ); + } else { + finish({ status: "captured", csv }); + } + } + } catch { + finish( + unavailable( + "Could not read the CSV produced by Loom. Nothing was imported.", + ), + ); + } + }; + const createObjectURL: typeof URL.createObjectURL = (object) => { + const url = originalCreate.call(URL, object); + if (object instanceof Blob) void inspectBlob(object); + return url; + }; + const click = function (this: HTMLAnchorElement) { + if ( + this.download && + this.href.startsWith("blob:https://www.loom.com/") + ) { + void fetch(this.href) + .then((response) => response.blob()) + .then(inspectBlob) + .catch(() => {}); + } else if (this.download && this.href.startsWith("https:")) { + directDownload = true; + } + originalClick.call(this); + }; + const leaving = () => + finish( + unavailable("The Loom page navigated away before capture finished."), + ); + try { + URL.createObjectURL = createObjectURL; + HTMLAnchorElement.prototype.click = click; + window.addEventListener("pagehide", leaving, { once: true }); + timer = window.setTimeout( + () => + finish( + unavailable( + directDownload + ? "Loom downloaded a file but did not expose CSV bytes to this capture. Nothing was imported. You can use the CSV file tool instead." + : "Loom did not produce a readable CSV within 90 seconds. Nothing was imported.", + ), + ), + 90_000, + ); + button.click(); + return await result; + } finally { + window.clearTimeout(timer); + window.removeEventListener("pagehide", leaving); + if (URL.createObjectURL === createObjectURL) + URL.createObjectURL = originalCreate; + if (HTMLAnchorElement.prototype.click === click) + HTMLAnchorElement.prototype.click = originalClick; + } + }, + ); +} + +export async function openLoomExport(): Promise { + const tabs = await chrome.tabs.query({ + url: "https://www.loom.com/settings/workspace*", + }); + const existing = tabs.find( + (tab) => tab.url === "https://www.loom.com/settings/workspace#data", + ); + const tab = existing?.id + ? await chrome.tabs.update(existing.id, { active: true }) + : await chrome.tabs.create({ + url: "https://www.loom.com/settings/workspace#data", + active: true, + }); + if (tab?.id === undefined) + throw new Error("Could not open Loom’s export page."); + return tab.id; +} + +export async function runLoomExport( + tabId: number, + command: LoomExportCommand, + documentId?: string, +) { + const results = await chrome.scripting.executeScript({ + target: { tabId, ...(documentId ? { documentIds: [documentId] } : {}) }, + world: "MAIN", + func: loomExportBridge, + args: [command], + }); + const frame = results.find((frame) => frame.frameId === 0); + if (!frame?.result || !frame.documentId) + throw new Error( + "Could not read Loom. Keep its signed-in Data page open and try again.", + ); + return { ...frame.result, documentId: frame.documentId }; +} + +export function prepareLoomCapture(csv: string, source: LoomExportSource) { + const table = parseInventory(csv, "loom-account.csv"); + for (const name of [ + "Video Link", + "Video Name", + "Creator Email", + "Workspace", + "Folder", + "Video Creation Date", + ]) { + if (!table.headers.includes(name)) + throw new Error("Loom’s CSV format has changed. No import was started."); + } + if (table.records.length !== source.totalRows) { + throw new Error( + `Loom reported ${source.totalRows} videos but returned ${table.records.length} records. Reconnect and try again; no import was started.`, + ); + } + const mapping = detectColumns(table.headers); + mapping.createdAt = table.headers.indexOf("Video Creation Date"); + const rows = buildInventory(table, mapping, { + ownerMode: "column", + ownerEmail: "", + spaceMode: "none", + spaceName: "", + }); + const eligible = rows.filter((row) => !row.issue && !row.reviewRequired); + return { + source, + table, + rows, + eligible, + omittedRows: rows.length - eligible.length, + importCsv: exportImportCsv(eligible), + reportCsv: exportInventoryCsv(table, rows), + }; +} + +export type PreparedLoomCapture = ReturnType; diff --git a/apps/chrome-extension/src/importer/migration-api.ts b/apps/chrome-extension/src/importer/migration-api.ts new file mode 100644 index 0000000000..7bfdee04aa --- /dev/null +++ b/apps/chrome-extension/src/importer/migration-api.ts @@ -0,0 +1,256 @@ +import type { ImportContext } from "./api"; +import type { PreparedLoomCapture } from "./loom-capture"; + +export type CapMigrationConnection = { + tabId: number; + origin: string; + context: ImportContext; +}; + +type CapPageRequest = { + origin: string; + method: "GET" | "POST"; + path: "/api/extension/import-loom" | "/api/extension/import-loom/batch"; + body?: unknown; + handoffOrganizationId?: string; +}; + +export async function capPageRequest(request: CapPageRequest) { + if ( + location.origin !== request.origin || + !location.pathname.startsWith("/dashboard") || + ![ + "/api/extension/import-loom", + "/api/extension/import-loom/batch", + ].includes(request.path) + ) { + return { status: 401, body: null }; + } + try { + const response = await fetch(request.path, { + method: request.method, + credentials: "same-origin", + redirect: "error", + cache: "no-store", + headers: { "Content-Type": "application/json" }, + body: + request.body === undefined ? undefined : JSON.stringify(request.body), + signal: AbortSignal.timeout(request.method === "POST" ? 60_000 : 15_000), + }); + const body: unknown = await response.json().catch(() => null); + if (response.ok && request.handoffOrganizationId !== undefined) { + if ( + request.method !== "POST" || + request.path !== "/api/extension/import-loom/batch" || + !body || + typeof body !== "object" || + !("operationId" in body) || + typeof body.operationId !== "string" || + !("dashboardPath" in body) || + typeof body.dashboardPath !== "string" + ) + return { status: 502, body: null }; + const dashboard = new URL(body.dashboardPath, request.origin); + if ( + dashboard.origin !== request.origin || + dashboard.pathname !== "/dashboard/import/loom/status" || + dashboard.searchParams.get("operationId") !== body.operationId || + dashboard.searchParams.get("organizationId") !== + request.handoffOrganizationId + ) + return { status: 502, body: null }; + // Cap owns the redirect even if the extension closes after submitting. + window.setTimeout(() => location.assign(dashboard.toString()), 250); + } + return { status: response.status, body }; + } catch { + return { status: 0, body: null }; + } +} + +const isObject = (value: unknown): value is Record => + Boolean(value) && typeof value === "object" && !Array.isArray(value); + +async function inCapTab(tabId: number, request: CapPageRequest) { + const results = await chrome.scripting.executeScript({ + target: { tabId }, + world: "MAIN", + func: capPageRequest, + args: [request], + }); + const result = results.find((frame) => frame.frameId === 0)?.result; + if (!result || result.status === 0) { + throw new Error( + "Cap did not confirm the request. Keep the dashboard tab open. Retrying this capture uses the same request ID.", + ); + } + if (result.status < 200 || result.status >= 300) { + const messages: Record = { + 400: "Cap rejected this batch or the signed-in account changed. Reconnect Cap before trying again.", + 401: "Sign in to Cap in its dashboard tab, then click Connect Cap again.", + 403: "Importing requires Cap Pro and an organization admin or owner role.", + 404: "This Cap server does not have the automatic Loom importer yet. The web changes need to be deployed before importing; CSV-only still works.", + 409: "Another Loom batch is already active for this organization. Check the Cap dashboard before trying again.", + }; + throw new Error( + messages[result.status] ?? + "Cap could not queue this batch. Check the dashboard before retrying.", + ); + } + return result.body; +} + +export async function readCapContext( + tabId: number, + origin: string, +): Promise { + const data = await inCapTab(tabId, { + origin, + method: "GET", + path: "/api/extension/import-loom", + }); + if ( + !isObject(data) || + !isObject(data.user) || + typeof data.user.id !== "string" || + typeof data.user.email !== "string" || + !Array.isArray(data.organizations) || + !data.organizations.every( + (org: unknown) => + isObject(org) && + typeof org.id === "string" && + typeof org.name === "string" && + typeof org.canImport === "boolean", + ) || + typeof data.isPro !== "boolean" || + typeof data.defaultPublic !== "boolean" || + typeof data.activeOrganizationId !== "string" || + typeof data.maxRows !== "number" + ) { + throw new Error("Cap returned an unexpected importer configuration."); + } + return data as ImportContext; +} + +export function capOrigin(apiBaseUrl: string) { + const url = new URL(apiBaseUrl); + if ( + url.username || + url.password || + (url.protocol !== "https:" && + !( + url.protocol === "http:" && + ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) + )) + ) + throw new Error( + "Set a secure Cap URL in the extension Options before connecting.", + ); + return url.origin; +} + +export async function openCapDashboard(origin: string) { + const tabs = await chrome.tabs.query({ url: `${origin}/*` }); + const existing = tabs.find((tab) => { + try { + return new URL(tab.url ?? "").pathname.startsWith("/dashboard"); + } catch { + return false; + } + }); + const tab = existing?.id + ? await chrome.tabs.update(existing.id, { active: true }) + : await chrome.tabs.create({ + url: `${origin}/dashboard/caps`, + active: true, + }); + if (tab?.id === undefined) + throw new Error("Could not open the Cap dashboard."); + return tab.id; +} + +export async function queueLoomCapture( + connection: CapMigrationConnection, + organizationId: string, + capture: PreparedLoomCapture, +) { + if (!capture.eligible.length) + throw new Error( + "Loom did not expose any importable links. Download the full report to review the omissions.", + ); + if (capture.eligible.length > 5_000) + throw new Error( + "Automatic imports are limited to 5,000 available videos per batch. Narrow the date range and reconnect Loom.", + ); + const current = await readCapContext(connection.tabId, connection.origin); + if (current.user.id !== connection.context.user.id) + throw new Error( + "The Cap account changed. Reconnect before starting an import.", + ); + if ( + !current.isPro || + !current.organizations.some( + (org) => org.id === organizationId && org.canImport, + ) + ) { + throw new Error( + "You no longer have permission to import into this organization.", + ); + } + if (current.defaultPublic !== connection.context.defaultPublic) { + throw new Error( + "Cap’s default video visibility changed. Reconnect and review it before importing.", + ); + } + const payload = { + expectedUserId: current.user.id, + expectedDefaultPublic: current.defaultPublic, + organizationId, + rows: capture.eligible.map((row) => ({ + rowNumber: row.sourceRecord, + loomUrl: row.url, + userEmail: row.ownerEmail, + })), + source: { ...capture.source, omittedRows: capture.omittedRows }, + }; + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode( + JSON.stringify({ origin: connection.origin, ...payload }), + ), + ); + const key = `cap-loom-batch:${[...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`; + let requestId = localStorage.getItem(key); + if (!requestId || !/^[0-9a-f-]{36}$/i.test(requestId)) { + requestId = crypto.randomUUID(); + localStorage.setItem(key, requestId); + } + await chrome.tabs.update(connection.tabId, { active: true }); + const data = await inCapTab(connection.tabId, { + origin: connection.origin, + method: "POST", + path: "/api/extension/import-loom/batch", + body: { requestId, ...payload }, + handoffOrganizationId: organizationId, + }); + if ( + !isObject(data) || + typeof data.operationId !== "string" || + typeof data.dashboardPath !== "string" + ) { + throw new Error( + "Cap did not return a batch receipt. Retrying uses the same request ID.", + ); + } + const dashboard = new URL(data.dashboardPath, connection.origin); + if ( + dashboard.origin !== connection.origin || + dashboard.pathname !== "/dashboard/import/loom/status" || + dashboard.searchParams.get("operationId") !== data.operationId || + dashboard.searchParams.get("organizationId") !== organizationId + ) + throw new Error( + "Cap returned an invalid dashboard link. No redirect was followed.", + ); + return { operationId: data.operationId, dashboardUrl: dashboard.toString() }; +} diff --git a/apps/chrome-extension/src/importer/migration.css b/apps/chrome-extension/src/importer/migration.css new file mode 100644 index 0000000000..0d74aa7a84 --- /dev/null +++ b/apps/chrome-extension/src/importer/migration.css @@ -0,0 +1,471 @@ +@import "./styles.css" layer(inventory); +@import "../shared/paper.css"; + +:root { + --import-border: var(--track); + --import-blue: var(--accent); +} + +#root { + flex: 1; + display: flex; + flex-direction: column; +} + +.migration-stage { + padding-bottom: 40px; +} + +.migration-screen { + display: flex; + flex-direction: column; + align-items: center; + width: min(560px, 100%); + animation: fade-up 0.3s ease both; +} + +.migration-screen h1:focus { + outline: none; +} + +.migration-screen .cta { + gap: 8px; + text-decoration: none; +} + +.migration-draw { + stroke-dasharray: 1; + stroke-dashoffset: 1; + animation: draw 0.7s ease 0.15s forwards; +} + +.migration-draw-later { + animation-delay: 0.4s; +} + +.migration-accent { + stroke: var(--accent); +} + +.migration-link-doodle .doodle-stroke { + stroke-width: 2.05; +} + +.migration-link-doodle .migration-accent { + animation-delay: 0.65s; +} + +.migration-spark-first { + animation-delay: 0.85s; +} + +.migration-spark-last { + animation-delay: 1s; +} + +.migration-screen[data-busy="true"] .migration-cloud-arrow { + animation: bob 1.6s ease-in-out infinite; +} + +.migration-steps { + display: flex; + align-items: center; + justify-content: center; + gap: 28px; + margin: 28px 0 22px; + padding: 0; + list-style: none; + color: var(--ink-soft); + font-size: 12.5px; +} + +.migration-steps li { + display: flex; + align-items: center; + gap: 8px; +} + +.migration-steps li[aria-current="step"] { + color: var(--ink); +} + +.migration-steps li > span { + display: grid; + place-items: center; + width: 23px; + height: 23px; + border: 1.5px solid var(--track); + border-radius: 50%; + font-size: 11px; + font-weight: 500; +} + +.migration-steps .migration-step-current { + border-color: var(--ink); + color: var(--ink); +} + +.migration-steps .migration-step-done { + border-color: transparent; + color: var(--success); +} + +.migration-panel { + width: 100%; + gap: 20px; + animation-delay: 0.2s; +} + +.migration-connection { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-width: 0; +} + +.migration-connection strong { + font-size: 15px; + font-weight: 500; + overflow-wrap: anywhere; +} + +.migration-connection p { + margin-top: 5px; + font-size: 13px; + line-height: 1.5; + color: var(--ink-soft); + overflow-wrap: anywhere; +} + +.migration-connected { + flex-shrink: 0; + color: var(--success); +} + +.migration-actions { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 12px; +} + +.migration-choices { + gap: 10px; +} + +.migration-choices .cta { + flex: 1; + white-space: nowrap; +} + +.migration-text-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 6px; + border: 0; + background: transparent; + color: var(--ink-soft); + font-family: inherit; + font-size: 13px; + cursor: pointer; + text-underline-offset: 3px; + transition: color 0.18s ease; +} + +.migration-text-button:hover:not(:disabled) { + color: var(--ink); + text-decoration: underline; +} + +.migration-options { + padding-top: 16px; + border-top: 1.5px solid var(--track); + color: var(--ink-soft); + font-size: 12.5px; + line-height: 1.5; +} + +.migration-options > summary, +.migration-preview > summary { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + list-style: none; + cursor: pointer; +} + +.migration-options > summary::-webkit-details-marker, +.migration-preview > summary::-webkit-details-marker { + display: none; +} + +.migration-options > summary > svg, +.migration-preview > summary > svg { + transition: transform 0.18s ease; +} + +.migration-options[open] > summary > svg, +.migration-preview[open] > summary > svg { + transform: rotate(180deg); +} + +.migration-options > p { + margin-top: 16px; +} + +.migration-dates { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-top: 14px; +} + +.migration-dates input, +.migration-panel select { + width: 100%; + min-width: 0; +} + +.migration-library h2 { + text-align: center; + overflow-wrap: anywhere; +} + +.migration-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-top: 20px; + text-align: center; +} + +.migration-stats > div { + display: grid; + gap: 5px; +} + +.migration-stats > div + div { + border-left: 1.5px solid var(--track); +} + +.migration-stats strong { + font-size: 28px; + font-weight: 500; + letter-spacing: -0.02em; +} + +.migration-stats span { + font-size: 12px; + color: var(--ink-soft); +} + +.migration-note { + color: var(--ink-soft); + font-size: 12.5px; + line-height: 1.6; +} + +.migration-consent { + display: flex; + align-items: flex-start; + gap: 10px; + font-size: 13px; + line-height: 1.6; + cursor: pointer; +} + +.migration-consent input { + width: 16px; + height: 16px; + margin: 2px 0 0; + flex-shrink: 0; + accent-color: var(--ink); +} + +.migration-consent strong { + font-weight: 500; +} + +.migration-connect-cap { + display: grid; + justify-items: center; + gap: 20px; + font-size: 14px; + line-height: 1.5; + text-align: center; +} + +.migration-warning { + color: var(--error); + font-size: 13px; + line-height: 1.5; +} + +.migration-status { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + max-width: 440px; + margin-top: 18px; + color: var(--ink-soft); + font-size: 13px; + line-height: 1.5; + text-align: center; +} + +.migration-progress { + width: 180px; + height: 20px; + fill: none; + stroke-width: 2.5; + stroke-linecap: round; +} + +.migration-progress-track { + stroke: var(--track); +} + +.migration-progress-ink { + stroke: var(--accent); + stroke-dasharray: 2 4.2; + animation: march 1.1s linear infinite; +} + +.migration-error { + width: 100%; + margin-top: 18px; + font-size: 13px; + line-height: 1.6; +} + +.migration-finished { + justify-items: center; + text-align: center; + font-size: 14px; + line-height: 1.5; +} + +.migration-preview { + width: 100%; + margin-top: 22px; + text-align: left; + font-size: 13px; +} + +.migration-preview > summary { + min-height: 32px; + color: var(--ink-soft); +} + +.migration-preview[open] { + width: min(1040px, calc(100vw - 48px)); +} + +.migration-preview-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin: 20px 0 16px; +} + +.migration-preview-heading p { + color: var(--ink-soft); + line-height: 1.5; +} + +.migration-preview-heading .cta { + flex-shrink: 0; + min-height: 36px; + padding: 0 16px; + font-size: 13px; +} + +.migration-readonly-table .checkbox-cell { + display: none; +} + +.migration-readonly-table .inventory-section { + border: 1.5px solid var(--track); + border-radius: 16px; + background: rgba(255, 255, 255, 0.65); +} + +.migration-readonly-table .filter-tabs button, +.migration-readonly-table .search-field, +.migration-readonly-table .button { + border-radius: 999px; +} + +.migration-readonly-table .selected-row { + background: transparent; +} + +.migration-back { + margin-top: 18px; +} + +.migration-reassurance { + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + margin-top: 22px; + font-size: 12.5px; + line-height: 1.5; + color: var(--ink-soft); +} + +.migration-reassurance svg { + flex-shrink: 0; +} + +.migration-footer a { + color: inherit; + text-underline-offset: 3px; +} + +@media (max-width: 540px) { + .migration-preview-heading { + flex-direction: column; + align-items: flex-start; + } + + .migration-choices .cta { + padding: 0 16px; + } + + .migration-stats span { + font-size: 11px; + } +} + +@media (max-width: 360px) { + .migration-choices { + flex-direction: column; + align-items: stretch; + } +} + +@media (prefers-reduced-motion: reduce) { + .migration-screen, + .migration-draw { + animation-duration: 0.01ms; + animation-delay: 0s; + } + + .migration-screen[data-busy="true"] .migration-cloud-arrow, + .migration-progress-ink { + animation: none; + } + + .migration-options > summary > svg, + .migration-preview > summary > svg { + transition: none; + } +} diff --git a/apps/chrome-extension/src/importer/migration.tsx b/apps/chrome-extension/src/importer/migration.tsx new file mode 100644 index 0000000000..9c029df92b --- /dev/null +++ b/apps/chrome-extension/src/importer/migration.tsx @@ -0,0 +1,860 @@ +import { + ArrowLeftIcon, + ArrowRightIcon, + CheckCircle2Icon, + ChevronDownIcon, + DownloadIcon, + ShieldCheckIcon, +} from "lucide-react"; +import { useEffect, useId, useMemo, useRef, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { CapBrand, DoodleBoilFilter } from "../shared/cap-brand"; +import { mountPageNav } from "../shared/page-nav"; +import { defaultSettings, loadSettings, SETTINGS_KEY } from "../shared/storage"; +import { InventoryTable } from "./inventory-table"; +import { + type LoomExportSource, + openLoomExport, + type PreparedLoomCapture, + prepareLoomCapture, + runLoomExport, +} from "./loom-capture"; +import { + type CapMigrationConnection, + capOrigin, + openCapDashboard, + queueLoomCapture, + readCapContext, +} from "./migration-api"; +import "./migration.css"; + +const pause = (milliseconds: number) => + new Promise((resolve) => window.setTimeout(resolve, milliseconds)); +const errorMessage = (error: unknown) => + error instanceof Error + ? error.message + : "Could not complete this step. Please try again."; +const ignoreSelection = () => {}; +const emptyOutcomes = {}; +const focusHeading = (node: HTMLHeadingElement | null) => + node?.focus({ preventScroll: true }); +type MigrationView = "connect" | "ready" | "destination"; + +function MigrationDoodle({ mode }: { mode: MigrationView | "complete" }) { + return ( + + ); +} + +function downloadCsv(filename: string, content: string) { + const url = URL.createObjectURL( + new Blob([content], { type: "text/csv;charset=utf-8" }), + ); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + window.setTimeout(() => URL.revokeObjectURL(url), 10_000); +} + +function Migration() { + const headingId = useId(); + const [view, setView] = useState("connect"); + const [settings, setSettings] = useState(defaultSettings); + const [settingsReady, setSettingsReady] = useState(false); + const [from, setFrom] = useState("1970-01-01"); + const [to, setTo] = useState(() => { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; + }); + const [source, setSource] = useState(null); + const [loomTabId, setLoomTabId] = useState(null); + const [loomDocumentId, setLoomDocumentId] = useState(null); + const [loomSpaceLinks, setLoomSpaceLinks] = useState(null); + const [connection, setConnection] = useState( + null, + ); + const [organizationId, setOrganizationId] = useState(""); + const [capture, setCapture] = useState(null); + const [accepted, setAccepted] = useState(false); + const [busy, setBusy] = useState<"loom" | "cap" | "csv" | "import" | null>( + null, + ); + const [status, setStatus] = useState(""); + const [error, setError] = useState(""); + const [receipt, setReceipt] = useState<{ + operationId: string; + dashboardUrl: string; + } | null>(null); + const currentTab = useRef(null); + const settingsVersion = useRef(0); + const busyRef = useRef(false); + const mounted = useRef(true); + const selected = useMemo( + () => new Set(capture?.eligible.map((row) => row.sourceRecord) ?? []), + [capture], + ); + const destination = connection?.context.organizations.find( + (org) => org.id === organizationId, + ); + const canImport = Boolean( + connection?.context.isPro && + destination?.canImport && + capture?.eligible.length && + accepted && + !receipt, + ); + const mode = receipt ? "complete" : view; + const title = receipt + ? "Your videos are on their way" + : view === "connect" + ? "Move your Loom library to Cap" + : view === "ready" + ? "Your CSV is ready" + : "Import to Cap"; + + useEffect(() => { + mounted.current = true; + void chrome.tabs.getCurrent().then((tab) => { + currentTab.current = tab?.id ?? null; + }); + const refresh = async () => { + try { + const next = await loadSettings(); + if (mounted.current) { + setSettings(next); + setSettingsReady(true); + } + } catch (caught) { + if (mounted.current) setError(errorMessage(caught)); + } + }; + void refresh(); + const changed = ( + changes: Record, + area: string, + ) => { + if (area !== "local" || !changes[SETTINGS_KEY]) return; + settingsVersion.current += 1; + setConnection(null); + setAccepted(false); + void refresh(); + }; + chrome.storage.onChanged.addListener(changed); + return () => { + mounted.current = false; + chrome.storage.onChanged.removeListener(changed); + }; + }, []); + useEffect(() => { + if (loomTabId === null || !loomDocumentId || capture) return; + const navigated = (tabId: number, change: chrome.tabs.TabChangeInfo) => { + if ( + tabId !== loomTabId || + (change.status !== "loading" && change.url === undefined) + ) + return; + setSource(null); + setLoomDocumentId(null); + setLoomSpaceLinks(null); + setAccepted(false); + setError( + "Loom navigated after connecting. Reconnect Loom before continuing.", + ); + }; + chrome.tabs.onUpdated.addListener(navigated); + return () => chrome.tabs.onUpdated.removeListener(navigated); + }, [loomTabId, loomDocumentId, capture]); + + async function focusImporter() { + if (currentTab.current !== null && mounted.current) { + await chrome.tabs + .update(currentTab.current, { active: true }) + .catch(() => {}); + } + } + + async function exclusive( + kind: NonNullable, + action: () => Promise, + ) { + if (busyRef.current) return; + busyRef.current = true; + setBusy(kind); + setError(""); + try { + await navigator.locks.request( + "cap-loom-account-migration", + { ifAvailable: true }, + async (lock) => { + if (!lock) + throw new Error( + "Another Cap migration tab is working. Wait for it to finish before continuing.", + ); + await action(); + }, + ); + } catch (caught) { + setStatus(""); + setError(errorMessage(caught)); + await focusImporter(); + } finally { + busyRef.current = false; + if (mounted.current) setBusy(null); + } + } + + const connectLoom = () => + exclusive("loom", async () => { + setSource(null); + setLoomDocumentId(null); + setLoomSpaceLinks(null); + setCapture(null); + setAccepted(false); + setStatus("Opening Loom’s workspace export…"); + const tabId = await openLoomExport(); + setLoomTabId(tabId); + try { + let lastMessage = + "Sign in to Loom, then return here and click Connect Loom again."; + let rangeSet = false; + for (let attempt = 0; attempt < 30 && mounted.current; attempt++) { + await pause(500); + try { + if (!rangeSet) { + const result = await runLoomExport(tabId, { + type: "range", + from, + to, + }); + if ( + result.status === "unavailable" && + result.message === "Updating Loom’s report date range…" + ) { + rangeSet = true; + await pause(1_500); + } else if (result.status === "unavailable") + lastMessage = result.message; + } + if (!rangeSet) continue; + const result = await runLoomExport(tabId, { type: "inspect" }); + if ( + result.status === "ready" && + result.source.from === from && + result.source.to === to + ) { + setSource(result.source); + setLoomDocumentId(result.documentId); + setLoomSpaceLinks(result.visibleSpaceLinks); + setStatus( + `Connected to ${result.source.workspace}. Ready to build the CSV.`, + ); + return; + } + if (result.status === "unavailable") lastMessage = result.message; + } catch (caught) { + lastMessage = errorMessage(caught); + } + } + throw new Error(lastMessage); + } finally { + await focusImporter(); + } + }); + + const connectCap = () => + exclusive("cap", async () => { + setConnection(null); + setAccepted(false); + setStatus("Checking your Cap dashboard session…"); + const version = settingsVersion.current; + const origin = capOrigin(settings.apiBaseUrl); + const tabId = await openCapDashboard(origin); + try { + let failure = + "Sign in to Cap in the dashboard tab, then connect again."; + for (let attempt = 0; attempt < 16 && mounted.current; attempt++) { + await pause(500); + try { + const context = await readCapContext(tabId, origin); + if (version !== settingsVersion.current) + throw new Error("The Cap URL changed. Connect again."); + setConnection({ tabId, origin, context }); + setOrganizationId(context.activeOrganizationId); + setStatus(`Connected to Cap as ${context.user.email}.`); + return; + } catch (caught) { + failure = errorMessage(caught); + if ( + failure.includes("deployed") || + version !== settingsVersion.current + ) + break; + } + } + throw new Error(failure); + } finally { + await focusImporter(); + } + }); + + const prepareCsv = () => + exclusive("csv", async () => { + if (capture) { + setView("ready"); + setStatus("CSV ready. Nothing has been imported into Cap."); + return; + } + if (!source || loomTabId === null || !loomDocumentId || !loomSpaceLinks) + throw new Error("Connect your Loom workspace first."); + setStatus("Preparing your videos. This can take up to 90 seconds…"); + const result = await runLoomExport( + loomTabId, + { + type: "capture", + expected: source, + visibleSpaceLinks: loomSpaceLinks, + }, + loomDocumentId, + ); + if (result.status !== "captured") + throw new Error( + result.status === "unavailable" + ? result.message + : "Loom did not return a CSV.", + ); + setCapture(prepareLoomCapture(result.csv, source)); + setView("ready"); + setStatus("CSV ready. Nothing has been imported into Cap."); + await focusImporter(); + }); + + const startImport = () => + exclusive("import", async () => { + if (!canImport || !connection || !capture || !mounted.current) + throw new Error( + "Connect Cap and review the destination before importing.", + ); + setStatus( + `Queueing ${capture.eligible.length.toLocaleString()} available videos in Cap…`, + ); + const queued = await queueLoomCapture( + connection, + organizationId, + capture, + ); + setReceipt(queued); + setStatus("Your import is running on Cap. You can close this tab."); + await chrome.tabs.update(connection.tabId, { active: true }).catch(() => { + setError( + "The import was queued, but the dashboard tab could not be opened. Use the link below; do not start another import.", + ); + }); + }); + + const goBack = (next: MigrationView) => { + setView(next); + setAccepted(false); + setError(""); + setStatus( + next === "ready" ? "CSV ready. Nothing has been imported into Cap." : "", + ); + }; + + const clearSource = () => { + setSource(null); + setLoomDocumentId(null); + setLoomSpaceLinks(null); + setCapture(null); + setAccepted(false); + setError(""); + setStatus(""); + }; + + return ( + <> +
+
+ +
+
+ +

+ {title} +

+

+ {receipt + ? "The import will keep running in Cap. Follow its progress from your dashboard." + : view === "connect" + ? "Connect Loom, then download your CSV or bring your videos into Cap." + : view === "ready" + ? "Your videos, ready for their next home. Download a copy or let Cap take it from here." + : "Confirm where your videos should go. We’ll start the import and take you to your dashboard."} +

+
    +
  1. + + {source ? : "1"} + + Connect Loom +
  2. +
  3. + 2 + Your videos +
  4. +
+ + {receipt ? ( +
+

Your import is running on Cap. You can close this tab.

+ + View import in dashboard{" "} + + +
+ ) : view === "connect" ? ( +
+
+
+ {source?.workspace ?? "Your Loom account"} +

+ {source + ? `${source.totalRows.toLocaleString()} source records · ready to prepare` + : "Use the account signed in to this browser."} +

+
+ {source && ( + + )} +
+
+ {source ? ( + <> + + + + ) : ( + + )} +
+
+ + Export options + +

+ All dates are included by default. Choose a shorter range if + you prefer. +

+
+ + +
+

+ Loom workspace exports require an admin on a paid Loom plan. +

+ {loomTabId !== null && ( + + )} +
+
+ ) : view === "ready" && capture ? ( +
+
+

{capture.source.workspace}

+
+
+ {capture.rows.length.toLocaleString()} + Source records +
+
+ {capture.eligible.length.toLocaleString()} + Ready to import +
+
+ {capture.omittedRows.toLocaleString()} + Skipped +
+
+
+
+ + +
+ {capture.omittedRows > 0 && ( +

+ {capture.omittedRows.toLocaleString()}{" "} + {capture.omittedRows === 1 ? "record was" : "records were"}{" "} + skipped because of a missing link, invalid owner or duplicate. + You can review {capture.omittedRows === 1 ? "it" : "them"} in + the full report. +

+ )} +
+ ) : view === "destination" && capture ? ( +
+ {connection ? ( + <> +
+
+ {connection.context.user.email} +

{connection.origin}

+
+ +
+ + {!connection.context.isPro && ( +

+ Loom imports require Cap Pro. +

+ )} + {destination && !destination.canImport && ( +

+ Choose an organization where you’re an admin or owner. +

+ )} + +

+ Original creators are kept. Missing Cap members may be + added. Loom folders and access settings are not copied. + Inaccessible videos will be reported as failed. +

+
+ + +
+ + ) : ( +
+

+ Use your Cap dashboard account to choose a destination. + Nothing is imported until you confirm. +

+ {!busy && ( + + )} +
+ )} +
+ ) : null} + + {status && !receipt && ( + + {busy && ( + + )} + {status} + + )} + {error && ( +

+ {error} +

+ )} + + {capture && view !== "connect" && ( +
+ + Preview videos and full report{" "} + + +
+

+ Every source record is kept here, including anything skipped. +

+ +
+
+ +
+
+ )} + {!receipt && view !== "connect" && ( + + )} + {view === "connect" && ( +

+ Read-only. Nothing in + your Loom workspace is changed. +

+ )} +
+
+ + + ); +} + +mountPageNav("import"); +const root = document.getElementById("root"); +if (!root) throw new Error("Missing migration root."); +createRoot(root).render(); diff --git a/apps/chrome-extension/src/popup/components/import-button.tsx b/apps/chrome-extension/src/popup/components/import-button.tsx new file mode 100644 index 0000000000..32867a5566 --- /dev/null +++ b/apps/chrome-extension/src/popup/components/import-button.tsx @@ -0,0 +1,12 @@ +import { ImportIcon } from "lucide-react"; + +export const ImportButton = ({ onClick }: { onClick: () => void }) => ( + +); diff --git a/apps/chrome-extension/src/popup/main.tsx b/apps/chrome-extension/src/popup/main.tsx index be8fc9c046..7f6ecdd1a4 100644 --- a/apps/chrome-extension/src/popup/main.tsx +++ b/apps/chrome-extension/src/popup/main.tsx @@ -37,6 +37,7 @@ import { DEFAULT_MICROPHONE_DEVICE_ID } from "../shared/types"; import { CameraSelector } from "./components/camera-selector"; import { DashboardButton } from "./components/dashboard-button"; import { HowItWorksButton } from "./components/how-it-works-button"; +import { ImportButton } from "./components/import-button"; import { MicrophoneSelector } from "./components/microphone-selector"; import { RecorderHeader } from "./components/recorder-header"; import { RecordingBar } from "./components/recording-bar"; @@ -704,7 +705,14 @@ function App() { {status.message}
)} -
+
+ { + void chrome.tabs + .create({ url: chrome.runtime.getURL("migrate.html") }) + .catch(() => setError("Could not open the importer.")); + }} + /> void openHowItWorks()} />
{failedRecordingsCount > 0 && ( @@ -721,11 +729,20 @@ function App() { )} ) : ( - void signIn()} - /> + <> + void signIn()} + /> + { + void chrome.tabs + .create({ url: chrome.runtime.getURL("migrate.html") }) + .catch(() => setError("Could not open the importer.")); + }} + /> + )} {error && ( diff --git a/apps/chrome-extension/src/shared/page-nav.ts b/apps/chrome-extension/src/shared/page-nav.ts index 261bf5840a..95a1d06bb4 100644 --- a/apps/chrome-extension/src/shared/page-nav.ts +++ b/apps/chrome-extension/src/shared/page-nav.ts @@ -3,6 +3,7 @@ import { loadSettings } from "./storage"; export const PAGE_NAV_LINKS = [ { id: "welcome", label: "Welcome", href: "welcome.html" }, { id: "how-it-works", label: "How it works", href: "how-it-works.html" }, + { id: "import", label: "Import", href: "migrate.html" }, { id: "camera", label: "Camera access", href: "camera-permission.html" }, { id: "options", label: "Options", href: "options.html" }, ] as const; diff --git a/apps/chrome-extension/vite.config.ts b/apps/chrome-extension/vite.config.ts index bbf6f130b3..b081b40b5a 100644 --- a/apps/chrome-extension/vite.config.ts +++ b/apps/chrome-extension/vite.config.ts @@ -12,6 +12,8 @@ export default defineConfig({ popup: resolve(__dirname, "popup.html"), "popup-window": resolve(__dirname, "popup-window.html"), options: resolve(__dirname, "options.html"), + import: resolve(__dirname, "import.html"), + migrate: resolve(__dirname, "migrate.html"), welcome: resolve(__dirname, "welcome.html"), "how-it-works": resolve(__dirname, "how-it-works.html"), uploading: resolve(__dirname, "uploading.html"), From bb538f3271176105aa8b027624c54b600dc2fae9 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:49:29 +0100 Subject: [PATCH 7/8] fix: validate Loom URLs before metadata requests --- apps/web/__tests__/unit/loom-import.test.ts | 126 ++++++++++++-------- apps/web/lib/loom-import.ts | 13 +- 2 files changed, 86 insertions(+), 53 deletions(-) diff --git a/apps/web/__tests__/unit/loom-import.test.ts b/apps/web/__tests__/unit/loom-import.test.ts index aa846d1a88..216b9d7a28 100644 --- a/apps/web/__tests__/unit/loom-import.test.ts +++ b/apps/web/__tests__/unit/loom-import.test.ts @@ -236,56 +236,86 @@ describe("importFromLoom", () => { vi.stubGlobal("fetch", vi.fn()); }); - it("returns direct MP4 URLs for public Loom downloads", async () => { - const fetchMock = vi.mocked(fetch); - fetchMock.mockImplementation(async (input) => { - const url = typeof input === "string" ? input : input.toString(); - - if (url.includes("/transcoded-url")) { - return { - ok: true, - status: 200, - text: async () => - JSON.stringify({ url: "https://cdn.loom.com/video.mp4" }), - } as Response; - } - - if (url === "https://www.loom.com/graphql") { - return { - ok: true, - json: async () => ({ - data: { getVideo: { name: "Public download" } }, - }), - } as Response; - } - - if (url.includes("/v1/oembed")) { - return { - ok: true, - json: async () => ({ duration: 42, width: 1920, height: 1080 }), - } as Response; - } - - throw new Error(`Unexpected fetch: ${url}`); - }); - - const { downloadLoomVideo } = await import("@/actions/loom"); - - const result = await downloadLoomVideo( - "https://www.loom.com/share/loom-abc1234567", + it.each([ + "https://www.loom.com/share/loom-abc1234567", + "https://loom.com/share/loom-abc1234567?sid=example#details", + "https://www.loom.com/embed/loom-abc1234567/", + "http://loom.com/share/loom-abc1234567", + ])( + "returns direct MP4 URLs for public Loom downloads from %s", + async (loomUrl) => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockImplementation(async (input) => { + const url = typeof input === "string" ? input : input.toString(); + + if (url.includes("/transcoded-url")) { + return { + ok: true, + status: 200, + text: async () => + JSON.stringify({ url: "https://cdn.loom.com/video.mp4" }), + } as Response; + } + + if (url === "https://www.loom.com/graphql") { + return { + ok: true, + json: async () => ({ + data: { getVideo: { name: "Public download" } }, + }), + } as Response; + } + + if (url.includes("/v1/oembed")) { + return { + ok: true, + json: async () => ({ duration: 42, width: 1920, height: 1080 }), + } as Response; + } + + throw new Error(`Unexpected fetch: ${url}`); + }); + + const { downloadLoomVideo } = await import("@/actions/loom"); + + const result = await downloadLoomVideo(loomUrl); + + expect(result).toEqual({ + success: true, + videoId: "loom-abc1234567", + videoName: "Public download", + downloadUrl: "https://cdn.loom.com/video.mp4", + downloadMode: "direct-download", + durationSeconds: 42, + width: 1920, + height: 1080, + requiresProxy: false, + }); + }, + ); + + it.each([ + "https://notloom.com/share/loom-abc1234567", + "https://loom.com.example.test/share/loom-abc1234567", + "https://www.loom.com.example.test/share/loom-abc1234567", + "https://loom.com@example.test/share/loom-abc1234567", + "ftp://www.loom.com/share/loom-abc1234567", + "https://www.loom.com/share/loom-abc1234567%2F..%2F..", + "https://www.loom.com/share/loom-abc1234567%3Fredirect=example.test", + "https://www.loom.com/share/loom-abc1234567%23fragment", + "https://www.loom.com/share/loom-abc1234567%5C..", + ])("rejects invalid Loom URLs before side effects: %s", async (loomUrl) => { + const { downloadLoomVideo, importFromLoom } = await import( + "@/actions/loom" ); - expect(result).toEqual({ - success: true, - videoId: "loom-abc1234567", - videoName: "Public download", - downloadUrl: "https://cdn.loom.com/video.mp4", - downloadMode: "direct-download", - durationSeconds: 42, - width: 1920, - height: 1080, - requiresProxy: false, - }); + expect(await downloadLoomVideo(loomUrl)).toMatchObject({ success: false }); + expect( + await importFromLoom({ loomUrl, orgId: "org-1" as never }), + ).toMatchObject({ success: false }); + expect(fetch).not.toHaveBeenCalled(); + expect(mockDb.select).not.toHaveBeenCalled(); + expect(valuesMock).not.toHaveBeenCalled(); }); it("returns streaming Loom URLs for browser conversion instead of proxying", async () => { diff --git a/apps/web/lib/loom-import.ts b/apps/web/lib/loom-import.ts index 95093e3845..b30103a211 100644 --- a/apps/web/lib/loom-import.ts +++ b/apps/web/lib/loom-import.ts @@ -87,18 +87,21 @@ const LOOM_CSV_LIMIT_ERROR = `CSV imports are limited to ${MAX_LOOM_CSV_ROWS} ro function extractLoomVideoId(url: string): string | null { try { const parsed = new URL(url); - if (!parsed.hostname.includes("loom.com")) { + if ( + (parsed.protocol !== "https:" && parsed.protocol !== "http:") || + (parsed.hostname !== "loom.com" && parsed.hostname !== "www.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 || !/^[a-zA-Z0-9_-]{10,}$/.test(id)) { return null; } - return id.split("?")[0] ?? null; + return id; } catch { return null; } @@ -125,7 +128,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, ); @@ -213,7 +216,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=https://www.loom.com/share/${encodeURIComponent(loomVideoId)}`, { headers: { Accept: "application/json" } }, ); if (!response.ok) return null; From c95672148db29499148e2279cb35b157795a01a4 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:49:38 +0100 Subject: [PATCH 8/8] fix: structure Loom storage error logging --- apps/web/lib/loom-import.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/web/lib/loom-import.ts b/apps/web/lib/loom-import.ts index b30103a211..9c985cb081 100644 --- a/apps/web/lib/loom-import.ts +++ b/apps/web/lib/loom-import.ts @@ -366,10 +366,11 @@ export async function importLoomVideoForOwner({ ); if (!writableResult.ok) { - console.error( - `Loom import: failed to resolve storage access for user ${ownerId} in org ${orgId}:`, - writableResult.error, - ); + console.error("Loom import: failed to resolve storage access", { + ownerId, + orgId, + error: writableResult.error, + }); return { success: false, error: