diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b500ac245b1..35101a957e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,6 +119,7 @@ jobs: __tests__/unit/media-format-converter.test.ts __tests__/unit/media-player-store-refs.test.ts __tests__/unit/mobile-caps-list.test.ts + __tests__/unit/mobile-loom-import.test.ts __tests__/unit/mobile-organization-analytics.test.ts __tests__/unit/notification-read-state.test.ts __tests__/unit/playback-store.test.ts diff --git a/apps/web/__tests__/unit/mobile-caps-list.test.ts b/apps/web/__tests__/unit/mobile-caps-list.test.ts index 55823a413c9..17d287aae03 100644 --- a/apps/web/__tests__/unit/mobile-caps-list.test.ts +++ b/apps/web/__tests__/unit/mobile-caps-list.test.ts @@ -23,7 +23,6 @@ vi.mock("@cap/env", () => ({ })); vi.mock("@cap/database/auth/auth-options", () => ({ authOptions: () => ({}) })); vi.mock("@cap/database/emails/config", () => ({ sendEmail: vi.fn() })); -vi.mock("@/actions/loom", () => ({})); vi.mock("@/app/(org)/dashboard/analytics/data", () => ({})); vi.mock("@/lib/account-deletion-request", () => ({})); vi.mock("@/lib/desktop-segments-finalization", () => ({})); diff --git a/apps/web/__tests__/unit/mobile-comment-parent.test.ts b/apps/web/__tests__/unit/mobile-comment-parent.test.ts index 721e3ad66c0..8d879dc3f72 100644 --- a/apps/web/__tests__/unit/mobile-comment-parent.test.ts +++ b/apps/web/__tests__/unit/mobile-comment-parent.test.ts @@ -21,7 +21,6 @@ const mocks = vi.hoisted(() => ({ notify: vi.fn(async () => undefined) })); vi.mock("server-only", () => ({})); vi.mock("@cap/database/auth/auth-options", () => ({ authOptions: () => ({}) })); vi.mock("@cap/database/emails/config", () => ({ sendEmail: vi.fn() })); -vi.mock("@/actions/loom", () => ({ downloadLoomVideo: vi.fn() })); vi.mock("@/app/(org)/dashboard/analytics/data", () => ({ getOrgAnalyticsData: vi.fn(), })); diff --git a/apps/web/__tests__/unit/mobile-loom-import.test.ts b/apps/web/__tests__/unit/mobile-loom-import.test.ts new file mode 100644 index 00000000000..c69d556b5d0 --- /dev/null +++ b/apps/web/__tests__/unit/mobile-loom-import.test.ts @@ -0,0 +1,300 @@ +import * as Db from "@cap/database/schema"; +import { + CurrentUser, + DatabaseError, + HttpAuthMiddleware, + Organisation, + User, +} from "@cap/web-domain"; +import { type HttpApi, HttpApiBuilder, HttpServer } from "@effect/platform"; +import type { MySqlTable } from "drizzle-orm/mysql-core"; +import { Effect, Layer, Option } from "effect"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + Database, + type DbClient, +} from "../../../../packages/web-backend/src/Database"; + +const mocks = vi.hoisted(() => ({ + getLoomDownloadUrl: vi.fn(), + getWritableAccessForUser: vi.fn(), + LoomDownloadTemporaryError: class extends Error {}, + start: vi.fn(), +})); + +vi.mock("server-only", () => ({})); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("@cap/env", () => ({ + buildEnv: { NEXT_PUBLIC_IS_CAP: "false" }, + NODE_ENV: "test", + serverEnv: () => ({ WEB_URL: "https://cap.test" }), +})); +vi.mock("@cap/database", () => ({ db: () => database })); +vi.mock("@cap/database/auth/auth-options", () => ({ authOptions: () => ({}) })); +vi.mock("@cap/database/emails/config", () => ({ sendEmail: vi.fn() })); +vi.mock("@cap/database/video-sharing-default", () => ({ + getNewVideoPublic: vi.fn(async () => true), +})); +vi.mock("@/app/(org)/dashboard/analytics/data", () => ({})); +vi.mock("@/lib/account-deletion-request", () => ({})); +vi.mock("@/lib/desktop-segments-finalization", () => ({})); +vi.mock("@/lib/loom-concierge", () => ({})); +vi.mock("@/lib/loom-download-url", () => ({ + getLoomDownloadUrl: mocks.getLoomDownloadUrl, + LoomDownloadTemporaryError: mocks.LoomDownloadTemporaryError, +})); +vi.mock("@/lib/Notification", () => ({})); +vi.mock("@/lib/organization-provisioning", () => ({})); +vi.mock("@/lib/rate-limit", () => ({ + isRateLimited: vi.fn(async () => false), + RATE_LIMIT_IDS: {}, +})); +vi.mock("@/lib/sync-video-storage-names", () => ({})); +vi.mock("@/lib/video-processing", () => ({})); +vi.mock("@/workflows/import-loom-video", () => ({ + importLoomVideoWorkflow: "importLoomVideoWorkflow", +})); +vi.mock("workflow/api", () => ({ getRun: vi.fn(), start: mocks.start })); +vi.mock("workflow/runtime", () => ({ getWorld: () => ({}) })); +vi.mock("@cap/web-backend", async () => { + const { Context } = await import("effect"); + const { Database } = await import( + "../../../../packages/web-backend/src/Database" + ); + return { + Database, + Storage: Object.assign(Context.GenericTag("mobile-loom-test/Storage"), { + getWritableAccessForUser: mocks.getWritableAccessForUser, + }), + Tinybird: Context.GenericTag("mobile-loom-test/Tinybird"), + VideosPolicy: Context.GenericTag("mobile-loom-test/VideosPolicy"), + Videos: Context.GenericTag("mobile-loom-test/Videos"), + ImageUploads: Context.GenericTag("mobile-loom-test/ImageUploads"), + }; +}); + +const user = { + id: User.UserId.make("importer"), + email: "importer@example.com", + activeOrganizationId: Organisation.OrganisationId.make("organization"), + iconUrlOrKey: Option.none(), +}; +const loomUrl = "https://www.loom.com/share/0123456789abcdef0123456789abcdef"; +type Row = Record; +let video: Row | null; +let upload: Row | null; + +function result(table: MySqlTable) { + if (table === Db.users) return [{ stripeSubscriptionStatus: "active" }]; + if (table === Db.organizations) return [{ id: user.activeOrganizationId }]; + if (table !== Db.importedVideos) throw new Error("Unexpected table"); + if (!video) return []; + return [ + { + importId: video.id, + videoId: video.id, + ownerId: video.ownerId, + bucketId: null, + metadata: null, + phase: upload?.phase ?? null, + rawFileKey: upload?.rawFileKey ?? null, + uploadUpdatedAt: upload ? new Date(1_000) : null, + }, + ]; +} + +const database = { + select: () => ({ + from: (table: MySqlTable) => { + const query = { + leftJoin: () => query, + innerJoin: () => query, + where: () => Object.assign(Promise.resolve(result(table)), query), + limit: async () => result(table), + }; + return query; + }, + }), + insert: (table: MySqlTable) => ({ + values: async (row: Row) => { + if (table === Db.videos) video = row; + if (table === Db.videoUploads) upload = row; + }, + }), + update: (table: MySqlTable) => ({ + set: (values: Row) => ({ + where: async () => { + if (table === Db.videoUploads && upload) Object.assign(upload, values); + return [{ affectedRows: 1 }]; + }, + }), + }), + transaction: (callback: (tx: unknown) => Promise) => callback(database), +}; + +let dispose: () => Promise; +vi.mock("@/lib/server", async () => { + const { Videos, ImageUploads } = await import("@cap/web-backend"); + return { + runPromise: Effect.runPromise, + apiToHandler: ( + api: Layer.Layer, + ) => { + const web = HttpApiBuilder.toWebHandler( + api.pipe( + Layer.provide( + Layer.succeed( + Database, + Database.make({ + use: (callback) => + Effect.tryPromise({ + try: () => callback(database as unknown as DbClient), + catch: (cause) => new DatabaseError({ cause }), + }), + }), + ), + ), + Layer.provide(Layer.mock(Videos, { _tag: "Videos" })), + Layer.provide(Layer.mock(ImageUploads, { _tag: "ImageUploads" })), + Layer.provide( + Layer.succeed( + HttpAuthMiddleware, + Effect.succeed(CurrentUser.of(user)), + ), + ), + Layer.merge(HttpServer.layerContext), + ), + ); + dispose = web.dispose; + return web.handler; + }, + }; +}); + +const { POST } = await import("@/app/api/mobile/[...route]/route"); + +function importLoom() { + return POST( + new Request("http://localhost/api/mobile/imports/loom", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ loomUrl }), + }), + ); +} + +beforeEach(() => { + video = null; + upload = null; + mocks.start.mockReset().mockResolvedValue(undefined); + mocks.getWritableAccessForUser.mockReset().mockReturnValue( + Effect.succeed({ + bucketId: Option.none(), + storageIntegrationId: Option.none(), + }), + ); + mocks.getLoomDownloadUrl + .mockReset() + .mockResolvedValue("https://cdn.loom.com/sessions/video.mp4"); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(null, { status: 404 })), + ); +}); + +afterAll(async () => { + vi.unstubAllGlobals(); + await dispose(); +}); + +describe("POST /api/mobile/imports/loom", () => { + it("starts a new import and returns its share link", async () => { + const response = await importLoom(); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.id).toBe(video?.id); + expect(body.shareUrl).toMatch(new RegExp(`/s/${video?.id}$`)); + expect(mocks.start).toHaveBeenCalledTimes(1); + }); + + it("recovers when the import workflow could not start", async () => { + mocks.start.mockRejectedValueOnce(new Error("queue unavailable")); + + const failed = await importLoom(); + + expect(failed.status).toBe(500); + expect(upload).toMatchObject({ + phase: "error", + processingError: "Loom import could not start. Please try again.", + }); + + const failedVideoId = video?.id; + const retried = await importLoom(); + + expect(retried.status).toBe(200); + expect((await retried.json()).id).toBe(failedVideoId); + expect(video?.id).toBe(failedVideoId); + expect(mocks.start).toHaveBeenCalledTimes(2); + expect(mocks.start.mock.calls[1]?.[1]).toEqual([ + expect.objectContaining({ + videoId: failedVideoId, + reuseExistingRawUpload: true, + }), + ]); + }); + + it("reports a failed restart as a server error", async () => { + mocks.start.mockRejectedValue(new Error("queue unavailable")); + + await importLoom(); + const retried = await importLoom(); + + expect(retried.status).toBe(500); + expect(mocks.start).toHaveBeenCalledTimes(2); + expect(upload).toMatchObject({ + phase: "error", + processingError: "Loom import could not start. Please try again.", + }); + }); + + it("reports a storage failure as a server error", async () => { + mocks.getWritableAccessForUser.mockReturnValue( + Effect.fail(new Error("storage unavailable")), + ); + + const response = await importLoom(); + + expect(response.status).toBe(500); + expect(mocks.start).not.toHaveBeenCalled(); + }); + + it("reports a Loom outage as a server error", async () => { + mocks.getLoomDownloadUrl.mockRejectedValue( + new mocks.LoomDownloadTemporaryError("Loom is unavailable"), + ); + + const response = await importLoom(); + + expect(response.status).toBe(500); + expect(mocks.start).not.toHaveBeenCalled(); + }); + + it("rejects a Loom video it cannot download", async () => { + mocks.getLoomDownloadUrl.mockResolvedValue(null); + + const response = await importLoom(); + + expect(response.status).toBe(400); + expect(mocks.start).not.toHaveBeenCalled(); + }); + + it("rejects a Loom video that is already in the organization", async () => { + video = { id: "imported-video", ownerId: user.id }; + + const response = await importLoom(); + + expect(response.status).toBe(400); + expect(mocks.start).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/api/mobile/[...route]/route.ts b/apps/web/app/api/mobile/[...route]/route.ts index 6cdbe00c1ad..d4201e98d31 100644 --- a/apps/web/app/api/mobile/[...route]/route.ts +++ b/apps/web/app/api/mobile/[...route]/route.ts @@ -58,8 +58,6 @@ import { } from "drizzle-orm"; import { Effect, Layer, Option } from "effect"; import { revalidatePath } from "next/cache"; -import { start } from "workflow/api"; -import { downloadLoomVideo } from "@/actions/loom"; import { getOrgAnalyticsData } from "@/app/(org)/dashboard/analytics/data"; import { createAccountDeletionRequest, @@ -71,6 +69,7 @@ import { isEmailCodeSendLimited, isEmailCodeVerifyLimited, } from "@/lib/email-code-rate-limit"; +import { importLoomVideoForOwner } from "@/lib/loom-csv-import"; import { resolveMobileRequestOrigin, resolveMobileWebResourceUrl, @@ -84,7 +83,6 @@ import { canViewSpaceAnalytics } from "@/lib/space-analytics-access"; import { enqueueVideoStorageNameSync } from "@/lib/sync-video-storage-names"; import { canUserDownloadVideo } from "@/lib/video-download-permissions"; import { startVideoProcessingWorkflow } from "@/lib/video-processing"; -import { importLoomVideoWorkflow } from "@/workflows/import-loom-video"; export const dynamic = "force-dynamic"; @@ -2885,110 +2883,25 @@ const importLoom = Effect.fn("Mobile.importLoom")(function* ( ); if (limited) return yield* Effect.fail(new HttpApiError.BadRequest()); - const download = yield* Effect.tryPromise({ - try: () => downloadLoomVideo(loomUrl), - catch: () => new HttpApiError.InternalServerError(), - }); - if (!download.success || !download.videoId || !download.downloadUrl) { - return yield* Effect.fail(new HttpApiError.BadRequest()); - } - const loomVideoId = download.videoId; - - const storage = yield* Storage; - const writable = yield* storage.getWritableAccessForUser( - user.id, - user.activeOrganizationId, - ); - const videoId = Video.VideoId.make(nanoId()); - const now = new Date(); - const importState = yield* database.use((db) => - db.transaction(async (tx) => { - const [existing] = await tx - .select({ - importId: Db.importedVideos.id, - videoId: Db.videos.id, - }) - .from(Db.importedVideos) - .leftJoin( - Db.videos, - and( - eq(Db.videos.id, Db.importedVideos.id), - eq(Db.videos.orgId, Db.importedVideos.orgId), - ), - ) - .where( - and( - eq(Db.importedVideos.orgId, user.activeOrganizationId), - eq(Db.importedVideos.source, "loom"), - eq(Db.importedVideos.sourceId, loomVideoId), - ), - ) - .limit(1); - if (existing?.videoId) return "conflict" as const; - if (existing) { - await tx - .delete(Db.importedVideos) - .where( - and( - eq(Db.importedVideos.orgId, user.activeOrganizationId), - eq(Db.importedVideos.source, "loom"), - eq(Db.importedVideos.sourceId, loomVideoId), - ), - ); - } - - await tx.insert(Db.videos).values({ - id: videoId, - name: - download.videoName?.slice(0, 255) ?? - `Loom Import - ${now.toISOString().slice(0, 10)}`, - ownerId: user.id, - orgId: user.activeOrganizationId, - source: { type: "webMP4" }, - bucket: Option.getOrNull(writable.bucketId), - storageIntegrationId: Option.getOrNull(writable.storageIntegrationId), - public: await getNewVideoPublic(user.activeOrganizationId), - 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: user.activeOrganizationId, - source: "loom", - sourceId: loomVideoId, - }); - return "ready" as const; - }), - ); - if (importState === "conflict") { - return yield* Effect.fail(new HttpApiError.BadRequest()); - } - - const rawFileKey = `${user.id}/${videoId}/raw-upload.mp4`; - yield* Effect.tryPromise({ + const result = yield* Effect.tryPromise({ try: () => - start(importLoomVideoWorkflow, [ - { - videoId, - userId: user.id, - rawFileKey, - bucketId: Option.getOrNull(writable.bucketId), - loomVideoId, - }, - ]), + importLoomVideoForOwner({ + loomUrl, + orgId: user.activeOrganizationId, + ownerId: user.id, + }), catch: () => new HttpApiError.InternalServerError(), }); - yield* Effect.sync(() => revalidatePath("/dashboard/caps")); + if (!result.success || !result.videoId) { + return yield* Effect.fail( + result.errorCode + ? new HttpApiError.InternalServerError() + : new HttpApiError.BadRequest(), + ); + } return { - id: videoId, - shareUrl: `${getMobilePublicOrigin(request)}/s/${videoId}`, + id: result.videoId, + shareUrl: `${getMobilePublicOrigin(request)}/s/${result.videoId}`, }; }); diff --git a/apps/web/lib/loom-csv-import.ts b/apps/web/lib/loom-csv-import.ts index 5cb9836473c..b8da8dc808d 100644 --- a/apps/web/lib/loom-csv-import.ts +++ b/apps/web/lib/loom-csv-import.ts @@ -65,7 +65,9 @@ export interface LoomDownloadResult { retryAfterMs?: number; } -export type LoomImportErrorCode = "loom_temporarily_unavailable"; +export type LoomImportErrorCode = + | "loom_temporarily_unavailable" + | "loom_import_start_failed"; export interface LoomImportResult { success: boolean; @@ -393,6 +395,7 @@ export async function importLoomVideoForOwner({ error instanceof Error ? error.message : "Loom import could not restart.", + errorCode: "loom_import_start_failed", }; } revalidatePath("/dashboard/caps"); @@ -458,6 +461,7 @@ export async function importLoomVideoForOwner({ success: false, error: "Could not prepare storage for this import. Please try again or contact support.", + errorCode: "loom_import_start_failed", }; } @@ -564,6 +568,7 @@ export async function importLoomVideoForOwner({ videoId, error: error instanceof Error ? error.message : "Loom import could not start.", + errorCode: "loom_import_start_failed", }; }