diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ebeaf0d13..cfc69739b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -5,7 +5,7 @@ "dev": "pnpm -w cap-setup && dotenv -e ../../.env -- pnpm run preparescript && dotenv -e ../../.env -- pnpm tauri dev", "build:tauri": "dotenv -e ../../.env -- pnpm run preparescript && dotenv -e ../../.env -- pnpm tauri build", "preparescript": "node scripts/prepare.js", - "localdev": "dotenv -e ../../.env -- vinxi dev --port 3001", + "localdev": "dotenv -e ../../.env -- vinxi dev --port 3002", "build": "vinxi build", "tauri": "tauri" }, @@ -54,7 +54,7 @@ "@ts-rest/core": "^3.52.1", "@types/react-tooltip": "^4.2.4", "cva": "npm:class-variance-authority@^0.7.0", - "effect": "^3.17.7", + "effect": "^3.17.13", "mp4box": "^0.5.2", "posthog-js": "^1.215.3", "solid-js": "^1.9.3", diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 214c68a74..5f2a676eb 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -5,7 +5,7 @@ "mainBinaryName": "Cap - Development", "build": { "beforeDevCommand": "pnpm localdev", - "devUrl": "http://localhost:3001", + "devUrl": "http://localhost:3002", "beforeBuildCommand": "pnpm turbo build --filter @cap/desktop", "frontendDist": "../.output/public" }, diff --git a/apps/discord-bot/package.json b/apps/discord-bot/package.json index 96ddf0e7b..f2b68587c 100644 --- a/apps/discord-bot/package.json +++ b/apps/discord-bot/package.json @@ -3,7 +3,7 @@ "private": true, "scripts": { "deploy": "wrangler deploy", - "dev": "wrangler dev", + "bot-dev": "wrangler dev", "start": "wrangler dev", "test": "vitest", "cf-typegen": "wrangler types" diff --git a/apps/tasks/package.json b/apps/tasks/package.json index 30ee20f4d..2010d3489 100644 --- a/apps/tasks/package.json +++ b/apps/tasks/package.json @@ -5,7 +5,6 @@ "main": "src/index.ts", "scripts": { "start": "node dist/src/index.js", - "dev": "ts-node src/index.ts", "build": "tsc", "start:dist": "node dist/src/index.js", "test": "jest", diff --git a/apps/web/actions/caps/share.ts b/apps/web/actions/caps/share.ts index c85d0dedf..de025712d 100644 --- a/apps/web/actions/caps/share.ts +++ b/apps/web/actions/caps/share.ts @@ -11,11 +11,12 @@ import { spaceVideos, videos, } from "@cap/database/schema"; +import type { Video } from "@cap/web-domain"; import { and, eq, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; interface ShareCapParams { - capId: string; + capId: Video.VideoId; spaceIds: string[]; public?: boolean; } diff --git a/apps/web/actions/folders/moveVideoToFolder.ts b/apps/web/actions/folders/moveVideoToFolder.ts index 469dc43e0..86a88fbbd 100644 --- a/apps/web/actions/folders/moveVideoToFolder.ts +++ b/apps/web/actions/folders/moveVideoToFolder.ts @@ -3,6 +3,7 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { folders, spaceVideos, videos } from "@cap/database/schema"; +import type { Folder, Video } from "@cap/web-domain"; import { and, eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; @@ -11,8 +12,8 @@ export async function moveVideoToFolder({ folderId, spaceId, }: { - videoId: string; - folderId: string | null; + videoId: Video.VideoId; + folderId: Folder.FolderId | null; spaceId?: string | null; }) { const user = await getCurrentUser(); @@ -23,7 +24,7 @@ export async function moveVideoToFolder({ // Get the current video to know its original folder const [currentVideo] = await db() - .select({ folderId: videos.folderId }) + .select({ folderId: videos.folderId, id: videos.id }) .from(videos) .where(eq(videos.id, videoId)); diff --git a/apps/web/actions/folders/updateFolder.ts b/apps/web/actions/folders/updateFolder.ts index c5ad958fb..ff1548f57 100644 --- a/apps/web/actions/folders/updateFolder.ts +++ b/apps/web/actions/folders/updateFolder.ts @@ -3,6 +3,7 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { folders } from "@cap/database/schema"; +import type { Folder } from "@cap/web-domain"; import { and, eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; @@ -12,10 +13,10 @@ export async function updateFolder({ color, parentId, }: { - folderId: string; + folderId: Folder.FolderId; name?: string; color?: "normal" | "blue" | "red" | "yellow"; - parentId?: string | null; + parentId?: Folder.FolderId | null; }) { const user = await getCurrentUser(); if (!user || !user.activeOrganizationId) diff --git a/apps/web/actions/organizations/add-videos.ts b/apps/web/actions/organizations/add-videos.ts index 080a4ea1e..357123808 100644 --- a/apps/web/actions/organizations/add-videos.ts +++ b/apps/web/actions/organizations/add-videos.ts @@ -9,12 +9,13 @@ import { sharedVideos, videos, } from "@cap/database/schema"; +import type { Video } from "@cap/web-domain"; import { and, eq, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; export async function addVideosToOrganization( organizationId: string, - videoIds: string[], + videoIds: Video.VideoId[], ) { try { const user = await getCurrentUser(); diff --git a/apps/web/actions/organizations/remove-videos.ts b/apps/web/actions/organizations/remove-videos.ts index bfebb3e9c..307384fc3 100644 --- a/apps/web/actions/organizations/remove-videos.ts +++ b/apps/web/actions/organizations/remove-videos.ts @@ -9,12 +9,13 @@ import { sharedVideos, videos, } from "@cap/database/schema"; +import type { Video } from "@cap/web-domain"; import { and, eq, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; export async function removeVideosFromOrganization( organizationId: string, - videoIds: string[], + videoIds: Video.VideoId[], ) { try { const user = await getCurrentUser(); diff --git a/apps/web/actions/screenshots/get-screenshot.ts b/apps/web/actions/screenshots/get-screenshot.ts deleted file mode 100644 index b6bc3f345..000000000 --- a/apps/web/actions/screenshots/get-screenshot.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { db } from "@cap/database"; -import { getCurrentUser } from "@cap/database/auth/session"; -import { s3Buckets, videos } from "@cap/database/schema"; -import { buildEnv, serverEnv } from "@cap/env"; -import { S3_BUCKET_URL } from "@cap/utils"; -import { eq } from "drizzle-orm"; -import { createBucketProvider } from "@/utils/s3"; - -export async function getScreenshot(userId: string, screenshotId: string) { - if (!userId || !screenshotId) { - throw new Error("userId or screenshotId not supplied"); - } - - const query = await db() - .select({ video: videos, bucket: s3Buckets }) - .from(videos) - .leftJoin(s3Buckets, eq(videos.bucket, s3Buckets.id)) - .where(eq(videos.id, screenshotId)); - - if (query.length === 0) { - throw new Error("Video does not exist"); - } - - const result = query[0]; - if (!result?.video) { - throw new Error("Video not found"); - } - - const { video, bucket } = result; - - if (video.public === false) { - const user = await getCurrentUser(); - - if (!user || user.id !== video.ownerId) { - throw new Error("Video is not public"); - } - } - - const bucketProvider = await createBucketProvider(bucket); - const screenshotPrefix = `${userId}/${screenshotId}/`; - - try { - const objects = await bucketProvider.listObjects({ - prefix: screenshotPrefix, - }); - - const screenshot = objects.Contents?.find((object) => - object.Key?.endsWith(".png"), - ); - - if (!screenshot) { - throw new Error("Screenshot not found"); - } - - let screenshotUrl: string; - - if (video.awsBucket !== serverEnv().CAP_AWS_BUCKET) { - screenshotUrl = await bucketProvider.getSignedObjectUrl(screenshot.Key!); - } else { - screenshotUrl = `${S3_BUCKET_URL}/${screenshot.Key}`; - } - - return { url: screenshotUrl }; - } catch (error) { - throw new Error(`Error generating screenshot URL: ${error}`); - } -} diff --git a/apps/web/actions/spaces/add-videos.ts b/apps/web/actions/spaces/add-videos.ts index 651d88cdb..0e5e77aec 100644 --- a/apps/web/actions/spaces/add-videos.ts +++ b/apps/web/actions/spaces/add-videos.ts @@ -4,10 +4,14 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { nanoId } from "@cap/database/helpers"; import { spaces, spaceVideos, videos } from "@cap/database/schema"; +import type { Video } from "@cap/web-domain"; import { and, eq, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; -export async function addVideosToSpace(spaceId: string, videoIds: string[]) { +export async function addVideosToSpace( + spaceId: string, + videoIds: Video.VideoId[], +) { try { const user = await getCurrentUser(); diff --git a/apps/web/actions/spaces/remove-videos.ts b/apps/web/actions/spaces/remove-videos.ts index 27454f3b8..0f252bfc9 100644 --- a/apps/web/actions/spaces/remove-videos.ts +++ b/apps/web/actions/spaces/remove-videos.ts @@ -3,12 +3,13 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { folders, spaceVideos, videos } from "@cap/database/schema"; +import type { Video } from "@cap/web-domain"; import { and, eq, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; export async function removeVideosFromSpace( spaceId: string, - videoIds: string[], + videoIds: Video.VideoId[], ) { try { const user = await getCurrentUser(); diff --git a/apps/web/actions/video/upload.ts b/apps/web/actions/video/upload.ts index c77820242..6ab1a3c11 100644 --- a/apps/web/actions/video/upload.ts +++ b/apps/web/actions/video/upload.ts @@ -10,6 +10,7 @@ import { nanoId } from "@cap/database/helpers"; import { s3Buckets, videos, videoUploads } from "@cap/database/schema"; import { buildEnv, NODE_ENV, serverEnv } from "@cap/env"; import { userIsPro } from "@cap/utils"; +import { type Folder, Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { dub } from "@/utils/dub"; @@ -156,14 +157,14 @@ export async function createVideoAndGetUploadUrl({ isUpload = false, folderId, }: { - videoId?: string; + videoId?: Video.VideoId; duration?: number; resolution?: string; videoCodec?: string; audioCodec?: string; isScreenshot?: boolean; isUpload?: boolean; - folderId?: string; + folderId?: Folder.FolderId; }) { const user = await getCurrentUser(); @@ -211,7 +212,7 @@ export async function createVideoAndGetUploadUrl({ } } - const idToUse = videoId || nanoId(); + const idToUse = Video.VideoId.make(videoId || nanoId()); const bucket = await createBucketProvider(customBucket); diff --git a/apps/web/actions/videos/download.ts b/apps/web/actions/videos/download.ts index a32ae5afd..173c8a222 100644 --- a/apps/web/actions/videos/download.ts +++ b/apps/web/actions/videos/download.ts @@ -3,10 +3,11 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { videos } from "@cap/database/schema"; +import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { createBucketProvider } from "@/utils/s3"; -export async function downloadVideo(videoId: string) { +export async function downloadVideo(videoId: Video.VideoId) { const user = await getCurrentUser(); if (!user || !videoId) { diff --git a/apps/web/actions/videos/edit-date.ts b/apps/web/actions/videos/edit-date.ts index d0ebbfae7..1d3565cb6 100644 --- a/apps/web/actions/videos/edit-date.ts +++ b/apps/web/actions/videos/edit-date.ts @@ -4,10 +4,11 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { videos } from "@cap/database/schema"; import type { VideoMetadata } from "@cap/database/types"; +import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; -export async function editDate(videoId: string, date: string) { +export async function editDate(videoId: Video.VideoId, date: string) { const user = await getCurrentUser(); if (!user || !date || !videoId) { diff --git a/apps/web/actions/videos/edit-title.ts b/apps/web/actions/videos/edit-title.ts index 7760ebac3..d88a267d5 100644 --- a/apps/web/actions/videos/edit-title.ts +++ b/apps/web/actions/videos/edit-title.ts @@ -3,10 +3,11 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { videos } from "@cap/database/schema"; +import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; -export async function editTitle(videoId: string, title: string) { +export async function editTitle(videoId: Video.VideoId, title: string) { const user = await getCurrentUser(); if (!user || !title || !videoId) { diff --git a/apps/web/actions/videos/edit-transcript.ts b/apps/web/actions/videos/edit-transcript.ts index 038363824..48e2d857b 100644 --- a/apps/web/actions/videos/edit-transcript.ts +++ b/apps/web/actions/videos/edit-transcript.ts @@ -1,15 +1,15 @@ "use server"; -import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"; import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { s3Buckets, videos } from "@cap/database/schema"; +import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { createBucketProvider } from "@/utils/s3"; export async function editTranscriptEntry( - videoId: string, + videoId: Video.VideoId, entryId: number, newText: string, ): Promise<{ success: boolean; message: string }> { diff --git a/apps/web/actions/videos/get-og-image.tsx b/apps/web/actions/videos/get-og-image.tsx index 0eafb0aa8..9f6b7c391 100644 --- a/apps/web/actions/videos/get-og-image.tsx +++ b/apps/web/actions/videos/get-og-image.tsx @@ -1,10 +1,11 @@ import { db } from "@cap/database"; import { s3Buckets, videos } from "@cap/database/schema"; +import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { ImageResponse } from "next/og"; import { createBucketProvider } from "@/utils/s3"; -export async function generateVideoOgImage(videoId: string) { +export async function generateVideoOgImage(videoId: Video.VideoId) { const videoData = await getData(videoId); if (!videoData) { @@ -145,7 +146,7 @@ export async function generateVideoOgImage(videoId: string) { ); } -async function getData(videoId: string) { +async function getData(videoId: Video.VideoId) { const query = await db() .select({ video: videos, diff --git a/apps/web/actions/videos/get-transcript.ts b/apps/web/actions/videos/get-transcript.ts index ca94aea1a..3726bb3ad 100644 --- a/apps/web/actions/videos/get-transcript.ts +++ b/apps/web/actions/videos/get-transcript.ts @@ -3,11 +3,12 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { s3Buckets, videos } from "@cap/database/schema"; +import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { createBucketProvider } from "@/utils/s3"; export async function getTranscript( - videoId: string, + videoId: Video.VideoId, ): Promise<{ success: boolean; content?: string; message: string }> { const user = await getCurrentUser(); diff --git a/apps/web/actions/videos/new-comment.ts b/apps/web/actions/videos/new-comment.ts index ea0e0b63a..b20d6128c 100644 --- a/apps/web/actions/videos/new-comment.ts +++ b/apps/web/actions/videos/new-comment.ts @@ -4,12 +4,13 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { nanoId } from "@cap/database/helpers"; import { comments } from "@cap/database/schema"; +import type { Video } from "@cap/web-domain"; import { revalidatePath } from "next/cache"; import { createNotification } from "@/lib/Notification"; export async function newComment(data: { content: string; - videoId: string; + videoId: Video.VideoId; type: "text" | "emoji"; parentCommentId: string; }) { diff --git a/apps/web/actions/videos/password.ts b/apps/web/actions/videos/password.ts index 3396a242c..d50d8ea01 100644 --- a/apps/web/actions/videos/password.ts +++ b/apps/web/actions/videos/password.ts @@ -4,11 +4,15 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { encrypt, hashPassword, verifyPassword } from "@cap/database/crypto"; import { videos } from "@cap/database/schema"; +import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { cookies } from "next/headers"; -export async function setVideoPassword(videoId: string, password: string) { +export async function setVideoPassword( + videoId: Video.VideoId, + password: string, +) { try { const user = await getCurrentUser(); @@ -42,7 +46,7 @@ export async function setVideoPassword(videoId: string, password: string) { } } -export async function removeVideoPassword(videoId: string) { +export async function removeVideoPassword(videoId: Video.VideoId) { try { const user = await getCurrentUser(); @@ -75,7 +79,10 @@ export async function removeVideoPassword(videoId: string) { } } -export async function verifyVideoPassword(videoId: string, password: string) { +export async function verifyVideoPassword( + videoId: Video.VideoId, + password: string, +) { try { if (!videoId || typeof password !== "string") throw new Error("Missing data"); diff --git a/apps/web/app/(org)/dashboard/admin/AdminDashboardClient.tsx b/apps/web/app/(org)/dashboard/admin/AdminDashboardClient.tsx deleted file mode 100644 index 1cbc88253..000000000 --- a/apps/web/app/(org)/dashboard/admin/AdminDashboardClient.tsx +++ /dev/null @@ -1,110 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { getPaidUsersStatsInRange, getUsersCreatedInRange } from "./actions"; -import type { DateRange } from "./dateRangeUtils"; -import UserLookup from "./UserLookup"; - -type Stats = { - newUsers: number | null; - paidUsersStats: { - totalPaidUsers: number; - usersWhoCreatedVideoFirst: number; - percentage: number; - } | null; -}; - -export default function AdminDashboardClient() { - const [dateRange, setDateRange] = useState("today"); - const [stats, setStats] = useState({ - newUsers: null, - paidUsersStats: null, - }); - const [loading, setLoading] = useState(true); - - useEffect(() => { - const fetchStats = async () => { - setLoading(true); - const [newUsers, paidUsersStats] = await Promise.all([ - getUsersCreatedInRange(dateRange), - getPaidUsersStatsInRange(dateRange), - ]); - setStats({ newUsers, paidUsersStats }); - setLoading(false); - }; - - fetchStats(); - }, [dateRange]); - - return ( -
-
-

Admin Dashboard

- -
- -
-
-

New Users

- {loading ? ( -
- ) : ( -

- {stats.newUsers || 0} -

- )} -
- -
-

- New Paid Users -

- {loading ? ( -
- ) : ( -

- {stats.paidUsersStats?.totalPaidUsers || 0} -

- )} -
- -
-

- Created Video Before Paying -

- {loading ? ( -
-
-
-
- ) : ( - <> -

- {stats.paidUsersStats?.percentage || 0}% -

-

- {stats.paidUsersStats?.usersWhoCreatedVideoFirst || 0} of{" "} - {stats.paidUsersStats?.totalPaidUsers || 0} users -

- - )} -
-
- -
-

User Lookup

- -
-
- ); -} diff --git a/apps/web/app/(org)/dashboard/admin/UserLookup.tsx b/apps/web/app/(org)/dashboard/admin/UserLookup.tsx deleted file mode 100644 index 2f92a0f2b..000000000 --- a/apps/web/app/(org)/dashboard/admin/UserLookup.tsx +++ /dev/null @@ -1,64 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { lookupUserById } from "./actions"; - -export default function UserLookup() { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(false); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setLoading(true); - try { - const formData = new FormData(e.currentTarget); - const result = await lookupUserById(formData); - setData(result); - } catch (error) { - console.error("Error looking up user:", error); - } finally { - setLoading(false); - } - }; - - return ( -
-
-
- -
- - -
-
-
- - {data && ( -
-

User Data:

-
-						{JSON.stringify(data, null, 2)}
-					
-
- )} -
- ); -} diff --git a/apps/web/app/(org)/dashboard/admin/actions.ts b/apps/web/app/(org)/dashboard/admin/actions.ts deleted file mode 100644 index 6fcdb0eba..000000000 --- a/apps/web/app/(org)/dashboard/admin/actions.ts +++ /dev/null @@ -1,301 +0,0 @@ -"use server"; - -import { db } from "@cap/database"; -import { getCurrentUser } from "@cap/database/auth/session"; -import { users, videos } from "@cap/database/schema"; -import { stripe } from "@cap/utils"; -import { and, eq, gte, isNotNull, lte, or, sql } from "drizzle-orm"; -import { type DateRange, getDateRangeFilter } from "./dateRangeUtils"; - -export async function lookupUserById(data: FormData) { - const currentUser = await getCurrentUser(); - if (currentUser?.email !== "richie@mcilroy.co") return; - - const [user] = await db() - .select() - .from(users) - .where(eq(users.id, data.get("id") as string)); - - return user; -} - -export async function getUsersCreatedToday() { - const currentUser = await getCurrentUser(); - if (currentUser?.email !== "richie@mcilroy.co") return null; - - const startOfToday = new Date(); - startOfToday.setHours(0, 0, 0, 0); - - const result = await db() - .select({ count: sql`count(*)` }) - .from(users) - .where(gte(users.created_at, startOfToday)); - - return result[0]?.count || 0; -} - -export async function getUsersCreatedInRange(dateRange: DateRange) { - const currentUser = await getCurrentUser(); - if (currentUser?.email !== "richie@mcilroy.co") return null; - - const { start, end } = getDateRangeFilter(dateRange); - - const result = await db() - .select({ count: sql`count(*)` }) - .from(users) - .where(and(gte(users.created_at, start), lte(users.created_at, end))); - - return result[0]?.count || 0; -} - -export async function getPaidUsersStats() { - const currentUser = await getCurrentUser(); - if (currentUser?.email !== "richie@mcilroy.co") return null; - - // Get all users with active subscriptions (including third-party) - const paidUsers = await db() - .select({ - id: users.id, - email: users.email, - stripeCustomerId: users.stripeCustomerId, - stripeSubscriptionStatus: users.stripeSubscriptionStatus, - stripeSubscriptionId: users.stripeSubscriptionId, - thirdPartyStripeSubscriptionId: users.thirdPartyStripeSubscriptionId, - created_at: users.created_at, - }) - .from(users) - .where( - or( - and( - isNotNull(users.stripeSubscriptionId), - or( - eq(users.stripeSubscriptionStatus, "active"), - eq(users.stripeSubscriptionStatus, "trialing"), - ), - ), - isNotNull(users.thirdPartyStripeSubscriptionId), - ), - ); - - // For each paid user, check if they created videos before subscribing - const paidUsersWithVideoCheck = await Promise.all( - paidUsers.map(async (user) => { - // Get subscription start date from Stripe - let subscriptionStartDate: Date | null = null; - - if ( - user.stripeCustomerId && - user.stripeSubscriptionId && - !user.thirdPartyStripeSubscriptionId - ) { - try { - const subscription = await stripe().subscriptions.retrieve( - user.stripeSubscriptionId, - ); - subscriptionStartDate = new Date(subscription.created * 1000); - } catch (error) { - console.error( - `Failed to fetch subscription for user ${user.id}:`, - error, - ); - // For third-party subscriptions or errors, assume they subscribed when first video was created - const firstVideo = await db() - .select({ createdAt: videos.createdAt }) - .from(videos) - .where(eq(videos.ownerId, user.id)) - .orderBy(videos.createdAt) - .limit(1); - - if (firstVideo[0]) { - // Add 1 day to first video as a conservative estimate - subscriptionStartDate = new Date(firstVideo[0].createdAt); - subscriptionStartDate.setDate(subscriptionStartDate.getDate() + 1); - } - } - } else if (user.thirdPartyStripeSubscriptionId) { - // For third-party subscriptions, we can't get the exact date from Stripe - // So we'll use a conservative approach: assume they subscribed after their first video - const firstVideo = await db() - .select({ createdAt: videos.createdAt }) - .from(videos) - .where(eq(videos.ownerId, user.id)) - .orderBy(videos.createdAt) - .limit(1); - - if (firstVideo[0]) { - // Add 1 day to first video as a conservative estimate - subscriptionStartDate = new Date(firstVideo[0].createdAt); - subscriptionStartDate.setDate(subscriptionStartDate.getDate() + 1); - } - } - - // Check if user created videos before subscription - let createdVideoBeforeSubscription = false; - if (subscriptionStartDate) { - const videosBeforeSubscription = await db() - .select({ count: sql`count(*)` }) - .from(videos) - .where( - and( - eq(videos.ownerId, user.id), - sql`${videos.createdAt} < ${subscriptionStartDate}`, - ), - ); - - createdVideoBeforeSubscription = - (videosBeforeSubscription[0]?.count || 0) > 0; - } - - return { - ...user, - subscriptionStartDate, - createdVideoBeforeSubscription, - }; - }), - ); - - const totalPaidUsers = paidUsersWithVideoCheck.length; - const usersWhoCreatedVideoFirst = paidUsersWithVideoCheck.filter( - (user) => user.createdVideoBeforeSubscription, - ).length; - - const percentage = - totalPaidUsers > 0 - ? Math.round((usersWhoCreatedVideoFirst / totalPaidUsers) * 100) - : 0; - - return { - totalPaidUsers, - usersWhoCreatedVideoFirst, - percentage, - }; -} - -export async function getPaidUsersStatsInRange(dateRange: DateRange) { - const currentUser = await getCurrentUser(); - if (currentUser?.email !== "richie@mcilroy.co") return null; - - const { start, end } = getDateRangeFilter(dateRange); - - // Get paid users who joined within the date range - const paidUsers = await db() - .select({ - id: users.id, - email: users.email, - stripeCustomerId: users.stripeCustomerId, - stripeSubscriptionStatus: users.stripeSubscriptionStatus, - stripeSubscriptionId: users.stripeSubscriptionId, - thirdPartyStripeSubscriptionId: users.thirdPartyStripeSubscriptionId, - created_at: users.created_at, - }) - .from(users) - .where( - and( - or( - and( - isNotNull(users.stripeSubscriptionId), - or( - eq(users.stripeSubscriptionStatus, "active"), - eq(users.stripeSubscriptionStatus, "trialing"), - ), - ), - isNotNull(users.thirdPartyStripeSubscriptionId), - ), - gte(users.created_at, start), - lte(users.created_at, end), - ), - ); - - // For each paid user, check if they created videos before subscribing - const paidUsersWithVideoCheck = await Promise.all( - paidUsers.map(async (user) => { - // Get subscription start date from Stripe - let subscriptionStartDate: Date | null = null; - - if ( - user.stripeCustomerId && - user.stripeSubscriptionId && - !user.thirdPartyStripeSubscriptionId - ) { - try { - const subscription = await stripe().subscriptions.retrieve( - user.stripeSubscriptionId, - ); - subscriptionStartDate = new Date(subscription.created * 1000); - } catch (error) { - console.error( - `Failed to fetch subscription for user ${user.id}:`, - error, - ); - // For third-party subscriptions or errors, assume they subscribed when first video was created - const firstVideo = await db() - .select({ createdAt: videos.createdAt }) - .from(videos) - .where(eq(videos.ownerId, user.id)) - .orderBy(videos.createdAt) - .limit(1); - - if (firstVideo[0]) { - // Add 1 day to first video as a conservative estimate - subscriptionStartDate = new Date(firstVideo[0].createdAt); - subscriptionStartDate.setDate(subscriptionStartDate.getDate() + 1); - } - } - } else if (user.thirdPartyStripeSubscriptionId) { - // For third-party subscriptions, we can't get the exact date from Stripe - // So we'll use a conservative approach: assume they subscribed after their first video - const firstVideo = await db() - .select({ createdAt: videos.createdAt }) - .from(videos) - .where(eq(videos.ownerId, user.id)) - .orderBy(videos.createdAt) - .limit(1); - - if (firstVideo[0]) { - // Add 1 day to first video as a conservative estimate - subscriptionStartDate = new Date(firstVideo[0].createdAt); - subscriptionStartDate.setDate(subscriptionStartDate.getDate() + 1); - } - } - - // Check if user created videos before subscription - let createdVideoBeforeSubscription = false; - if (subscriptionStartDate) { - const videosBeforeSubscription = await db() - .select({ count: sql`count(*)` }) - .from(videos) - .where( - and( - eq(videos.ownerId, user.id), - sql`${videos.createdAt} < ${subscriptionStartDate}`, - ), - ); - - createdVideoBeforeSubscription = - (videosBeforeSubscription[0]?.count || 0) > 0; - } - - return { - ...user, - subscriptionStartDate, - createdVideoBeforeSubscription, - }; - }), - ); - - const totalPaidUsers = paidUsersWithVideoCheck.length; - const usersWhoCreatedVideoFirst = paidUsersWithVideoCheck.filter( - (user) => user.createdVideoBeforeSubscription, - ).length; - - const percentage = - totalPaidUsers > 0 - ? Math.round((usersWhoCreatedVideoFirst / totalPaidUsers) * 100) - : 0; - - return { - totalPaidUsers, - usersWhoCreatedVideoFirst, - percentage, - }; -} diff --git a/apps/web/app/(org)/dashboard/admin/dateRangeUtils.ts b/apps/web/app/(org)/dashboard/admin/dateRangeUtils.ts deleted file mode 100644 index 47c897e2e..000000000 --- a/apps/web/app/(org)/dashboard/admin/dateRangeUtils.ts +++ /dev/null @@ -1,51 +0,0 @@ -export type DateRange = - | "today" - | "yesterday" - | "last7days" - | "thisMonth" - | "allTime"; - -export function getDateRangeFilter(range: DateRange): { - start: Date; - end: Date; -} { - const now = new Date(); - const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - - switch (range) { - case "today": - return { - start: today, - end: new Date(today.getTime() + 24 * 60 * 60 * 1000), - }; - case "yesterday": { - const yesterday = new Date(today); - yesterday.setDate(yesterday.getDate() - 1); - return { - start: yesterday, - end: today, - }; - } - case "last7days": { - const last7Days = new Date(today); - last7Days.setDate(last7Days.getDate() - 7); - return { - start: last7Days, - end: new Date(today.getTime() + 24 * 60 * 60 * 1000), - }; - } - case "thisMonth": { - const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); - const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1); - return { - start: monthStart, - end: monthEnd, - }; - } - case "allTime": - return { - start: new Date(0), - end: new Date(), - }; - } -} diff --git a/apps/web/app/(org)/dashboard/admin/loading.tsx b/apps/web/app/(org)/dashboard/admin/loading.tsx deleted file mode 100644 index 55e7cd314..000000000 --- a/apps/web/app/(org)/dashboard/admin/loading.tsx +++ /dev/null @@ -1,40 +0,0 @@ -export default function AdminDashboardLoading() { - return ( -
-

Admin Dashboard

- -
-
-
-
-
-
-
- -
-
-
-
-
-
- -
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
- ); -} diff --git a/apps/web/app/(org)/dashboard/admin/page.tsx b/apps/web/app/(org)/dashboard/admin/page.tsx deleted file mode 100644 index 1ba406285..000000000 --- a/apps/web/app/(org)/dashboard/admin/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { getCurrentUser } from "@cap/database/auth/session"; -import { redirect } from "next/navigation"; -import AdminDashboardClient from "./AdminDashboardClient"; - -export default async function AdminDashboard() { - const currentUser = await getCurrentUser(); - - if (currentUser?.email !== "richie@mcilroy.co") { - redirect("/dashboard"); - } - - return ; -} diff --git a/apps/web/app/(org)/dashboard/caps/components/PasswordDialog.tsx b/apps/web/app/(org)/dashboard/caps/components/PasswordDialog.tsx index 747717b7a..b8d24cc37 100644 --- a/apps/web/app/(org)/dashboard/caps/components/PasswordDialog.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/PasswordDialog.tsx @@ -9,6 +9,7 @@ import { DialogTitle, Input, } from "@cap/ui"; +import type { Video } from "@cap/web-domain"; import { faLock } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useMutation } from "@tanstack/react-query"; @@ -22,7 +23,7 @@ import { interface PasswordDialogProps { isOpen: boolean; onClose: () => void; - videoId: string; + videoId: Video.VideoId; hasPassword: boolean; onPasswordUpdated: (protectedStatus: boolean) => void; } diff --git a/apps/web/app/(org)/dashboard/caps/components/SharingDialog.tsx b/apps/web/app/(org)/dashboard/caps/components/SharingDialog.tsx index ebf3fc183..4eb32644f 100644 --- a/apps/web/app/(org)/dashboard/caps/components/SharingDialog.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/SharingDialog.tsx @@ -9,6 +9,7 @@ import { Input, Switch, } from "@cap/ui"; +import type { Video } from "@cap/web-domain"; import { faCopy, faShareNodes } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useMutation } from "@tanstack/react-query"; @@ -22,12 +23,11 @@ import { shareCap } from "@/actions/caps/share"; import { useDashboardContext } from "@/app/(org)/dashboard/Contexts"; import type { Spaces } from "@/app/(org)/dashboard/dashboard-data"; import { Tooltip } from "@/components/Tooltip"; -import { usePublicEnv } from "@/utils/public-env"; interface SharingDialogProps { isOpen: boolean; onClose: () => void; - capId: string; + capId: Video.VideoId; capName: string; sharedSpaces: { id: string; @@ -68,7 +68,7 @@ export const SharingDialog: React.FC = ({ spaceIds, public: isPublic, }: { - capId: string; + capId: Video.VideoId; spaceIds: string[]; public: boolean; }) => { diff --git a/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx b/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx index d94a0427e..5ca9cc12f 100644 --- a/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx @@ -2,6 +2,7 @@ import { Button } from "@cap/ui"; import { userIsPro } from "@cap/utils"; +import type { Folder } from "@cap/web-domain"; import { faUpload } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useRouter } from "next/navigation"; @@ -21,7 +22,7 @@ export const UploadCapButton = ({ }: { size?: "sm" | "lg" | "md"; grey?: boolean; - folderId?: string; + folderId?: Folder.FolderId; }) => { const { user } = useDashboardContext(); const inputRef = useRef(null); @@ -83,7 +84,7 @@ export const UploadCapButton = ({ async function legacyUploadCap( file: File, - folderId: string | undefined, + folderId: Folder.FolderId | undefined, setUploadStatus: (state: UploadStatus | undefined) => void, ) { const parser = await import("@remotion/media-parser"); diff --git a/apps/web/app/(org)/dashboard/caps/page.tsx b/apps/web/app/(org)/dashboard/caps/page.tsx index 0304cc857..376aa4c1d 100644 --- a/apps/web/app/(org)/dashboard/caps/page.tsx +++ b/apps/web/app/(org)/dashboard/caps/page.tsx @@ -23,7 +23,7 @@ export const metadata: Metadata = { }; // Helper function to fetch shared spaces data for videos -async function getSharedSpacesForVideos(videoIds: string[]) { +async function getSharedSpacesForVideos(videoIds: Video.VideoId[]) { if (videoIds.length === 0) return {}; // Fetch space-level sharing diff --git a/apps/web/app/(org)/dashboard/folder/[id]/components/BreadcrumbItem.tsx b/apps/web/app/(org)/dashboard/folder/[id]/components/BreadcrumbItem.tsx index f8b8aa398..30f486272 100644 --- a/apps/web/app/(org)/dashboard/folder/[id]/components/BreadcrumbItem.tsx +++ b/apps/web/app/(org)/dashboard/folder/[id]/components/BreadcrumbItem.tsx @@ -1,16 +1,18 @@ "use client"; +import type { Folder } from "@cap/web-domain"; import clsx from "clsx"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { toast } from "sonner"; + import { moveVideoToFolder } from "@/actions/folders/moveVideoToFolder"; import { useDashboardContext } from "../../../Contexts"; import { AllFolders } from "../../../caps/components/Folders"; interface BreadcrumbItemProps { - id: string; + id: Folder.FolderId; name: string; color: "normal" | "blue" | "red" | "yellow"; isLast: boolean; diff --git a/apps/web/app/(org)/dashboard/folder/[id]/components/ClientMyCapsLink.tsx b/apps/web/app/(org)/dashboard/folder/[id]/components/ClientMyCapsLink.tsx index 689fd053b..70ed6af15 100644 --- a/apps/web/app/(org)/dashboard/folder/[id]/components/ClientMyCapsLink.tsx +++ b/apps/web/app/(org)/dashboard/folder/[id]/components/ClientMyCapsLink.tsx @@ -1,6 +1,7 @@ "use client"; import { Avatar } from "@cap/ui"; +import type { Video } from "@cap/web-domain"; import clsx from "clsx"; import Image from "next/image"; import Link from "next/link"; @@ -48,7 +49,7 @@ export function ClientMyCapsLink() { }; const handleDrop = async ( - e: React.DragEvent | { id: string; name: string }, + e: React.DragEvent | { id: Video.VideoId; name: string }, ) => { if ("preventDefault" in e) { e.preventDefault(); @@ -74,7 +75,7 @@ export function ClientMyCapsLink() { }; // Common function to process the drop for both desktop and mobile - const processDrop = async (capData: { id: string; name: string }) => { + const processDrop = async (capData: { id: Video.VideoId; name: string }) => { setIsDragOver(false); try { diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/AddVideosDialogBase.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/AddVideosDialogBase.tsx index 8ee0fd446..cb3d7a013 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/AddVideosDialogBase.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/AddVideosDialogBase.tsx @@ -9,6 +9,7 @@ import { Input, LoadingSpinner, } from "@cap/ui"; +import type { Video } from "@cap/web-domain"; import { faVideo } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -28,14 +29,14 @@ interface AddVideosDialogBaseProps { entityId: string; entityName: string; onVideosAdded?: () => void; - addVideos: (entityId: string, videoIds: string[]) => Promise; - removeVideos: (entityId: string, videoIds: string[]) => Promise; + addVideos: (entityId: string, videoIds: Video.VideoId[]) => Promise; + removeVideos: (entityId: string, videoIds: Video.VideoId[]) => Promise; getVideos: (limit?: number) => Promise; getEntityVideoIds: (entityId: string) => Promise; } -export interface Video { - id: string; +export interface VideoData { + id: Video.VideoId; ownerId: string; name: string; createdAt: Date; @@ -62,7 +63,7 @@ const AddVideosDialogBase: React.FC = ({ getVideos, getEntityVideoIds, }) => { - const [selectedVideos, setSelectedVideos] = useState([]); + const [selectedVideos, setSelectedVideos] = useState([]); const [searchTerm, setSearchTerm] = useState(""); const filterTabs = ["all", "added", "notAdded"]; @@ -75,7 +76,7 @@ const AddVideosDialogBase: React.FC = ({ }, }); - const { data: videosData, isLoading } = useQuery({ + const { data: videosData, isLoading } = useQuery({ queryKey: ["user-videos"], queryFn: async () => { const result = await getVideos(); @@ -89,7 +90,7 @@ const AddVideosDialogBase: React.FC = ({ gcTime: 1000 * 60 * 5, }); - const { data: entityVideoIds } = useQuery({ + const { data: entityVideoIds } = useQuery({ queryKey: ["entity-video-ids", entityId], queryFn: async () => { const result = await getEntityVideoIds(entityId); @@ -108,8 +109,8 @@ const AddVideosDialogBase: React.FC = ({ toAdd, toRemove, }: { - toAdd: string[]; - toRemove: string[]; + toAdd: Video.VideoId[]; + toRemove: Video.VideoId[]; }) => { let addResult = { success: true, message: "", error: "" }; let removeResult = { success: true, message: "", error: "" }; @@ -153,21 +154,25 @@ const AddVideosDialogBase: React.FC = ({ const [videoTab, setVideoTab] = useState<(typeof filterTabs)[number]>("all"); // Memoize filtered videos for stable reference - const filteredVideos: Video[] = useMemo(() => { + const filteredVideos: VideoData[] = useMemo(() => { let vids = - videosData?.filter((video: Video) => + videosData?.filter((video: VideoData) => video.name.toLowerCase().includes(searchTerm.toLowerCase()), ) || []; if (videoTab === "added") { - vids = vids.filter((video: Video) => entityVideoIds?.includes(video.id)); + vids = vids.filter((video: VideoData) => + entityVideoIds?.includes(video.id), + ); } else if (videoTab === "notAdded") { - vids = vids.filter((video: Video) => !entityVideoIds?.includes(video.id)); + vids = vids.filter( + (video: VideoData) => !entityVideoIds?.includes(video.id), + ); } return vids; }, [videosData, searchTerm, videoTab, entityVideoIds]); // Memoize handleVideoToggle for stable reference - const handleVideoToggle = useCallback((videoId: string) => { + const handleVideoToggle = useCallback((videoId: Video.VideoId) => { setSelectedVideos((prev) => prev.includes(videoId) ? prev.filter((id) => id !== videoId) diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/VideoCard.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/VideoCard.tsx index a3eaf2806..b1fe3b05b 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/VideoCard.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/VideoCard.tsx @@ -6,10 +6,10 @@ import type React from "react"; import { memo } from "react"; import { Tooltip } from "@/components/Tooltip"; import { VideoThumbnail } from "@/components/VideoThumbnail"; -import type { Video } from "./AddVideosDialogBase"; +import type { VideoData } from "./AddVideosDialogBase"; interface VideoCardProps { - video: Video; + video: VideoData; isSelected: boolean; onToggle: () => void; isAlreadyInEntity: boolean; diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/VirtualizedVideoGrid.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/VirtualizedVideoGrid.tsx index 1a8a90572..50237a561 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/VirtualizedVideoGrid.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/VirtualizedVideoGrid.tsx @@ -1,13 +1,14 @@ +import type { Video } from "@cap/web-domain"; import { Grid, useGrid } from "@virtual-grid/react"; import React, { useEffect, useRef, useState } from "react"; -import type { Video } from "./AddVideosDialogBase"; +import type { VideoData } from "./AddVideosDialogBase"; import VideoCard from "./VideoCard"; interface VirtualizedVideoGridProps { - videos: Video[]; + videos: VideoData[]; selectedVideos: string[]; - handleVideoToggle: (id: string) => void; - entityVideoIds: string[]; + handleVideoToggle: (id: Video.VideoId) => void; + entityVideoIds: Video.VideoId[]; height?: number; columnCount?: number; rowHeight?: number; diff --git a/apps/web/app/(org)/verify-otp/form.tsx b/apps/web/app/(org)/verify-otp/form.tsx index f2effe76c..e40cdf902 100644 --- a/apps/web/app/(org)/verify-otp/form.tsx +++ b/apps/web/app/(org)/verify-otp/form.tsx @@ -78,6 +78,7 @@ export function VerifyOTPForm({ // shoutout https://github.com/buoyad/Tally/pull/14 const res = await fetch( `/api/auth/callback/email?email=${encodeURIComponent(email)}&token=${encodeURIComponent(otpCode)}&callbackUrl=${encodeURIComponent("/login-success")}`, + { redirect: "manual" }, ); if (!res.url.includes("/login-success")) { diff --git a/apps/web/app/api/desktop/[...route]/session.ts b/apps/web/app/api/desktop/[...route]/session.ts index 72b99f8ff..9082d8b66 100644 --- a/apps/web/app/api/desktop/[...route]/session.ts +++ b/apps/web/app/api/desktop/[...route]/session.ts @@ -1,12 +1,10 @@ import { db } from "@cap/database"; -import { authOptions } from "@cap/database/auth/auth-options"; import { getCurrentUser } from "@cap/database/auth/session"; import { authApiKeys } from "@cap/database/schema"; import { serverEnv } from "@cap/env"; import { zValidator } from "@hono/zod-validator"; import { Hono } from "hono"; import { getCookie } from "hono/cookie"; -import { getServerSession } from "next-auth"; import { decode } from "next-auth/jwt"; import { z } from "zod"; diff --git a/apps/web/app/api/desktop/[...route]/video.ts b/apps/web/app/api/desktop/[...route]/video.ts index 725a2282e..06d6dae44 100644 --- a/apps/web/app/api/desktop/[...route]/video.ts +++ b/apps/web/app/api/desktop/[...route]/video.ts @@ -83,7 +83,7 @@ app.get( const [video] = await db() .select() .from(videos) - .where(eq(videos.id, videoId)); + .where(eq(videos.id, Video.VideoId.make(videoId))); if (video) { return c.json({ @@ -96,7 +96,7 @@ app.get( } } - const idToUse = nanoId(); + const idToUse = Video.VideoId.make(nanoId()); const videoName = name ?? @@ -203,7 +203,7 @@ app.delete( "/delete", zValidator("query", z.object({ videoId: z.string() })), async (c) => { - const { videoId } = c.req.valid("query"); + const videoId = Video.VideoId.make(c.req.valid("query").videoId); const user = c.get("user"); try { diff --git a/apps/web/app/api/playlist/route.ts b/apps/web/app/api/playlist/route.ts index 6a8ea869a..3d706f1c4 100644 --- a/apps/web/app/api/playlist/route.ts +++ b/apps/web/app/api/playlist/route.ts @@ -61,7 +61,7 @@ const ApiLive = HttpApiBuilder.api(Api).pipe( ); const [S3ProviderLayer, customBucket] = - yield* s3Buckets.getProviderLayer(video.bucketId); + yield* s3Buckets.getProviderForBucket(video.bucketId); return yield* getPlaylistResponse( video, diff --git a/apps/web/app/api/screenshot/route.ts b/apps/web/app/api/screenshot/route.ts deleted file mode 100644 index 00e1d0bda..000000000 --- a/apps/web/app/api/screenshot/route.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { db } from "@cap/database"; -import { getCurrentUser } from "@cap/database/auth/session"; -import { s3Buckets, videos } from "@cap/database/schema"; -import { eq } from "drizzle-orm"; -import type { NextRequest } from "next/server"; -import { getHeaders } from "@/utils/helpers"; -import { createBucketProvider } from "@/utils/s3"; - -export const revalidate = 0; - -export async function OPTIONS(request: NextRequest) { - const origin = request.headers.get("origin") as string; - - return new Response(null, { - status: 200, - headers: getHeaders(origin), - }); -} - -export async function GET(request: NextRequest) { - const searchParams = request.nextUrl.searchParams; - const userId = searchParams.get("userId") || ""; - const videoId = searchParams.get("screenshotId") || ""; - const origin = request.headers.get("origin") as string; - - if (!userId || !videoId) { - return new Response( - JSON.stringify({ - error: true, - message: "userId or videoId not supplied", - }), - { status: 401, headers: getHeaders(origin) }, - ); - } - - const query = await db() - .select({ video: videos, bucket: s3Buckets }) - .from(videos) - .leftJoin(s3Buckets, eq(videos.bucket, s3Buckets.id)) - .where(eq(videos.id, videoId)); - - if (query.length === 0) { - return new Response( - JSON.stringify({ error: true, message: "Video does not exist" }), - { status: 401, headers: getHeaders(origin) }, - ); - } - - const result = query[0]; - if (!result?.video) { - return new Response( - JSON.stringify({ error: true, message: "Video not found" }), - { status: 401, headers: getHeaders(origin) }, - ); - } - - const { video, bucket } = result; - - if (video.public === false) { - const user = await getCurrentUser(); - - if (!user || user.id !== video.ownerId) { - return new Response( - JSON.stringify({ error: true, message: "Video is not public" }), - { status: 401, headers: getHeaders(origin) }, - ); - } - } - - const bucketProvider = await createBucketProvider(bucket); - const screenshotPrefix = `${userId}/${videoId}/`; - - try { - const objects = await bucketProvider.listObjects({ - prefix: screenshotPrefix, - }); - - const screenshot = objects.Contents?.find((object) => - object.Key?.endsWith(".png"), - ); - - if (!screenshot) { - return new Response( - JSON.stringify({ error: true, message: "Screenshot not found" }), - { status: 404, headers: getHeaders(origin) }, - ); - } - - let screenshotUrl: string; - - screenshotUrl = await bucketProvider.getSignedObjectUrl(screenshot.Key!); - - return new Response(JSON.stringify({ url: screenshotUrl }), { - status: 200, - headers: getHeaders(origin), - }); - } catch (error) { - return new Response( - JSON.stringify({ - error: error, - message: "Error generating screenshot URL", - }), - { status: 500, headers: getHeaders(origin) }, - ); - } -} diff --git a/apps/web/app/api/thumbnail/route.ts b/apps/web/app/api/thumbnail/route.ts index 8f33bded1..fa645c74b 100644 --- a/apps/web/app/api/thumbnail/route.ts +++ b/apps/web/app/api/thumbnail/route.ts @@ -1,5 +1,6 @@ import { db } from "@cap/database"; import { s3Buckets, videos } from "@cap/database/schema"; +import { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import type { NextRequest } from "next/server"; import { getHeaders } from "@/utils/helpers"; @@ -33,7 +34,7 @@ export async function GET(request: NextRequest) { }) .from(videos) .leftJoin(s3Buckets, eq(videos.bucket, s3Buckets.id)) - .where(eq(videos.id, videoId)); + .where(eq(videos.id, Video.VideoId.make(videoId))); if (query.length === 0) { return new Response( diff --git a/apps/web/app/api/upload/[...route]/multipart.ts b/apps/web/app/api/upload/[...route]/multipart.ts index 12999a322..b6783ee46 100644 --- a/apps/web/app/api/upload/[...route]/multipart.ts +++ b/apps/web/app/api/upload/[...route]/multipart.ts @@ -2,6 +2,7 @@ import { db, updateIfDefined } from "@cap/database"; import { s3Buckets, videos, videoUploads } from "@cap/database/schema"; import type { VideoMetadata } from "@cap/database/types"; import { serverEnv } from "@cap/env"; +import { Video } from "@cap/web-domain"; import { zValidator } from "@hono/zod-validator"; import { and, eq } from "drizzle-orm"; import { Hono } from "hono"; @@ -286,7 +287,12 @@ app.post( height: updateIfDefined(body.height, videos.height), fps: updateIfDefined(body.fps, videos.fps), }) - .where(and(eq(videos.id, videoId), eq(videos.ownerId, user.id))); + .where( + and( + eq(videos.id, Video.VideoId.make(videoId)), + eq(videos.ownerId, user.id), + ), + ); // This proves authentication if (result.rowsAffected > 0) diff --git a/apps/web/app/api/upload/[...route]/signed.ts b/apps/web/app/api/upload/[...route]/signed.ts index 06e8b132e..6975c5c6b 100644 --- a/apps/web/app/api/upload/[...route]/signed.ts +++ b/apps/web/app/api/upload/[...route]/signed.ts @@ -4,10 +4,10 @@ import { } from "@aws-sdk/client-cloudfront"; import { db, updateIfDefined } from "@cap/database"; import { s3Buckets, videos } from "@cap/database/schema"; -import type { VideoMetadata } from "@cap/database/types"; import { serverEnv } from "@cap/env"; +import { Video } from "@cap/web-domain"; import { zValidator } from "@hono/zod-validator"; -import { and, eq, sql } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { Hono } from "hono"; import { z } from "zod"; import { createBucketProvider } from "@/utils/s3"; @@ -154,7 +154,12 @@ app.post( height: updateIfDefined(height, videos.height), fps: updateIfDefined(fps, videos.fps), }) - .where(and(eq(videos.id, videoIdToUse), eq(videos.ownerId, user.id))); + .where( + and( + eq(videos.id, Video.VideoId.make(videoIdToUse)), + eq(videos.ownerId, user.id), + ), + ); if (videoIdFromKey) { try { diff --git a/apps/web/app/api/video/delete/route.ts b/apps/web/app/api/video/delete/route.ts index b2bde7d11..0797e9f03 100644 --- a/apps/web/app/api/video/delete/route.ts +++ b/apps/web/app/api/video/delete/route.ts @@ -39,10 +39,6 @@ const ApiLive = HttpApiBuilder.api(Api).pipe( Effect.logError(e).pipe( Effect.andThen(() => new HttpApiError.InternalServerError()), ), - UnknownException: (e) => - Effect.logError(e).pipe( - Effect.andThen(() => new HttpApiError.InternalServerError()), - ), }), ), ); diff --git a/apps/web/app/api/video/domain-info/route.ts b/apps/web/app/api/video/domain-info/route.ts index 0d6e13d29..dd6d2e83a 100644 --- a/apps/web/app/api/video/domain-info/route.ts +++ b/apps/web/app/api/video/domain-info/route.ts @@ -1,5 +1,6 @@ import { db } from "@cap/database"; import { organizations, sharedVideos, videos } from "@cap/database/schema"; +import { Video } from "@cap/web-domain"; import { and, eq } from "drizzle-orm"; import type { NextRequest } from "next/server"; @@ -13,21 +14,20 @@ export async function GET(request: NextRequest) { try { // First, get the video to find the owner or shared space - const video = await db() + const [video] = await db() .select({ id: videos.id, ownerId: videos.ownerId, }) .from(videos) - .where(eq(videos.id, videoId)) + .where(eq(videos.id, Video.VideoId.make(videoId))) .limit(1); - if (video.length === 0) { + if (!video) { return Response.json({ error: "Video not found" }, { status: 404 }); } - const videoData = video[0]; - if (!videoData || !videoData.ownerId) { + if (!video.ownerId) { return Response.json({ error: "Invalid video data" }, { status: 500 }); } @@ -37,7 +37,7 @@ export async function GET(request: NextRequest) { organizationId: sharedVideos.organizationId, }) .from(sharedVideos) - .where(eq(sharedVideos.videoId, videoId)) + .where(eq(sharedVideos.videoId, Video.VideoId.make(videoId))) .limit(1); let organizationId = null; @@ -77,7 +77,7 @@ export async function GET(request: NextRequest) { domainVerified: organizations.domainVerified, }) .from(organizations) - .where(eq(organizations.ownerId, videoData.ownerId)) + .where(eq(organizations.ownerId, video.ownerId)) .limit(1); if ( diff --git a/apps/web/app/api/video/og/route.tsx b/apps/web/app/api/video/og/route.tsx index 386a6b6a9..5223a23ae 100644 --- a/apps/web/app/api/video/og/route.tsx +++ b/apps/web/app/api/video/og/route.tsx @@ -1,7 +1,8 @@ +import { Video } from "@cap/web-domain"; import type { NextRequest } from "next/server"; import { generateVideoOgImage } from "@/actions/videos/get-og-image"; export async function GET(req: NextRequest) { const videoId = req.nextUrl.searchParams.get("videoId") as string; - return generateVideoOgImage(videoId); + return generateVideoOgImage(Video.VideoId.make(videoId)); } diff --git a/apps/web/app/api/videos/[videoId]/retry-transcription/route.ts b/apps/web/app/api/videos/[videoId]/retry-transcription/route.ts index 8f84a0af6..93e0eaffc 100644 --- a/apps/web/app/api/videos/[videoId]/retry-transcription/route.ts +++ b/apps/web/app/api/videos/[videoId]/retry-transcription/route.ts @@ -1,12 +1,13 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { videos } from "@cap/database/schema"; +import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; export async function POST( _request: Request, - { params }: { params: { videoId: string } }, + { params }: { params: { videoId: Video.VideoId } }, ) { try { const user = await getCurrentUser(); diff --git a/apps/web/app/embed/[videoId]/_components/PasswordOverlay.tsx b/apps/web/app/embed/[videoId]/_components/PasswordOverlay.tsx index 58ee3fe29..91606fbae 100644 --- a/apps/web/app/embed/[videoId]/_components/PasswordOverlay.tsx +++ b/apps/web/app/embed/[videoId]/_components/PasswordOverlay.tsx @@ -1,6 +1,7 @@ "use client"; import { Button, Dialog, DialogContent, Input, Logo } from "@cap/ui"; +import type { Video } from "@cap/web-domain"; import { useMutation } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { useState } from "react"; @@ -9,7 +10,7 @@ import { verifyVideoPassword } from "@/actions/videos/password"; interface PasswordOverlayProps { isOpen: boolean; - videoId: string; + videoId: Video.VideoId; } export const PasswordOverlay: React.FC = ({ diff --git a/apps/web/app/embed/[videoId]/page.tsx b/apps/web/app/embed/[videoId]/page.tsx index 95c672f8d..b12f2432f 100644 --- a/apps/web/app/embed/[videoId]/page.tsx +++ b/apps/web/app/embed/[videoId]/page.tsx @@ -126,6 +126,7 @@ export default async function EmbedVideoPage(props: Props) { id: videos.id, name: videos.name, ownerId: videos.ownerId, + orgId: videos.orgId, createdAt: videos.createdAt, updatedAt: videos.updatedAt, awsRegion: videos.awsRegion, diff --git a/apps/web/app/s/[videoId]/_components/PasswordOverlay.tsx b/apps/web/app/s/[videoId]/_components/PasswordOverlay.tsx index 58ee3fe29..91606fbae 100644 --- a/apps/web/app/s/[videoId]/_components/PasswordOverlay.tsx +++ b/apps/web/app/s/[videoId]/_components/PasswordOverlay.tsx @@ -1,6 +1,7 @@ "use client"; import { Button, Dialog, DialogContent, Input, Logo } from "@cap/ui"; +import type { Video } from "@cap/web-domain"; import { useMutation } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { useState } from "react"; @@ -9,7 +10,7 @@ import { verifyVideoPassword } from "@/actions/videos/password"; interface PasswordOverlayProps { isOpen: boolean; - videoId: string; + videoId: Video.VideoId; } export const PasswordOverlay: React.FC = ({ diff --git a/apps/web/app/s/[videoId]/_components/Sidebar.tsx b/apps/web/app/s/[videoId]/_components/Sidebar.tsx index 5896b1374..bb806ff7b 100644 --- a/apps/web/app/s/[videoId]/_components/Sidebar.tsx +++ b/apps/web/app/s/[videoId]/_components/Sidebar.tsx @@ -1,6 +1,7 @@ import type { userSelectProps } from "@cap/database/auth/session"; import type { comments as commentsSchema, videos } from "@cap/database/schema"; import { classNames } from "@cap/utils"; +import type { Video } from "@cap/web-domain"; import { AnimatePresence, motion } from "framer-motion"; import { forwardRef, Suspense, useState } from "react"; import { Activity } from "./tabs/Activity"; @@ -29,7 +30,7 @@ interface SidebarProps { setCommentsData: React.Dispatch>; views: MaybePromise; onSeek?: (time: number) => void; - videoId: string; + videoId: Video.VideoId; aiData?: { title?: string | null; summary?: string | null; diff --git a/apps/web/app/s/[videoId]/_components/tabs/Activity/Comments.tsx b/apps/web/app/s/[videoId]/_components/tabs/Activity/Comments.tsx index 94b33da2b..499cd5015 100644 --- a/apps/web/app/s/[videoId]/_components/tabs/Activity/Comments.tsx +++ b/apps/web/app/s/[videoId]/_components/tabs/Activity/Comments.tsx @@ -1,5 +1,6 @@ import type { userSelectProps } from "@cap/database/auth/session"; import { Button } from "@cap/ui"; +import type { Video } from "@cap/web-domain"; import { useSearchParams } from "next/navigation"; import type React from "react"; import { @@ -24,7 +25,7 @@ export const Comments = Object.assign( { setComments: React.Dispatch>; user: typeof userSelectProps | null; - videoId: string; + videoId: Video.VideoId; optimisticComments: CommentType[]; setOptimisticComments: (newComment: CommentType) => void; handleCommentSuccess: (comment: CommentType) => void; @@ -63,13 +64,7 @@ export const Comments = Object.assign( } }; - useImperativeHandle( - ref, - () => ({ - scrollToBottom, - }), - [], - ); + useImperativeHandle(ref, () => ({ scrollToBottom }), []); const rootComments = optimisticComments.filter( (comment) => !comment.parentCommentId || comment.parentCommentId === "", diff --git a/apps/web/app/s/[videoId]/_components/tabs/Activity/index.tsx b/apps/web/app/s/[videoId]/_components/tabs/Activity/index.tsx index 96e03eb70..baf5b795a 100644 --- a/apps/web/app/s/[videoId]/_components/tabs/Activity/index.tsx +++ b/apps/web/app/s/[videoId]/_components/tabs/Activity/index.tsx @@ -1,6 +1,7 @@ "use client"; import type { userSelectProps } from "@cap/database/auth/session"; +import type { Video } from "@cap/web-domain"; import type React from "react"; import { forwardRef, Suspense, useState } from "react"; import { CapCardAnalytics } from "@/app/(org)/dashboard/caps/components/CapCard/CapCardAnalytics"; @@ -16,7 +17,7 @@ interface ActivityProps { user: typeof userSelectProps | null; onSeek?: (time: number) => void; handleCommentSuccess: (comment: CommentType) => void; - videoId: string; + videoId: Video.VideoId; optimisticComments: CommentType[]; setOptimisticComments: (newComment: CommentType) => void; isOwnerOrMember: boolean; diff --git a/apps/web/app/s/[videoId]/page.tsx b/apps/web/app/s/[videoId]/page.tsx index 61f652c74..64d176dd2 100644 --- a/apps/web/app/s/[videoId]/page.tsx +++ b/apps/web/app/s/[videoId]/page.tsx @@ -39,7 +39,7 @@ export const dynamicParams = true; export const revalidate = 30; // Helper function to fetch shared spaces data for a video -async function getSharedSpacesForVideo(videoId: string) { +async function getSharedSpacesForVideo(videoId: Video.VideoId) { // Fetch space-level sharing const spaceSharing = await db() .select({ @@ -266,6 +266,7 @@ export default async function ShareVideoPage(props: Props) { id: videos.id, name: videos.name, ownerId: videos.ownerId, + orgId: videos.orgId, createdAt: videos.createdAt, updatedAt: videos.updatedAt, awsRegion: videos.awsRegion, diff --git a/apps/web/hooks/use-transcript.ts b/apps/web/hooks/use-transcript.ts index e5731f98f..c8d6dab95 100644 --- a/apps/web/hooks/use-transcript.ts +++ b/apps/web/hooks/use-transcript.ts @@ -1,8 +1,9 @@ +import type { Video } from "@cap/web-domain"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { getTranscript } from "@/actions/videos/get-transcript"; export const useTranscript = ( - videoId: string, + videoId: Video.VideoId, transcriptionStatus?: string | null, ) => { return useQuery({ diff --git a/apps/web/lib/Notification.ts b/apps/web/lib/Notification.ts index 4bded917b..626d0a179 100644 --- a/apps/web/lib/Notification.ts +++ b/apps/web/lib/Notification.ts @@ -5,6 +5,7 @@ import { db } from "@cap/database"; import { nanoId } from "@cap/database/helpers"; import { comments, notifications, users, videos } from "@cap/database/schema"; import type { Notification, NotificationBase } from "@cap/web-api-contract"; +import { Video } from "@cap/web-domain"; import { and, eq, sql } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import type { UserPreferences } from "@/app/(org)/dashboard/dashboard-data"; @@ -41,7 +42,7 @@ export async function createNotification( }) .from(videos) .innerJoin(users, eq(users.id, videos.ownerId)) - .where(eq(videos.id, notification.videoId)) + .where(eq(videos.id, Video.VideoId.make(notification.videoId))) .limit(1); if (!videoResult) { diff --git a/apps/web/lib/folder.ts b/apps/web/lib/folder.ts index 94099c36d..126b35386 100644 --- a/apps/web/lib/folder.ts +++ b/apps/web/lib/folder.ts @@ -33,9 +33,9 @@ export async function getFolderById(folderId: string | undefined) { return folder; } -export async function getFolderBreadcrumb(folderId: string) { +export async function getFolderBreadcrumb(folderId: Folder.FolderId) { const breadcrumb: Array<{ - id: string; + id: Folder.FolderId; name: string; color: "normal" | "blue" | "red" | "yellow"; }> = []; @@ -60,7 +60,7 @@ export async function getFolderBreadcrumb(folderId: string) { } // Helper function to fetch shared spaces data for videos -async function getSharedSpacesForVideos(videoIds: string[]) { +async function getSharedSpacesForVideos(videoIds: Video.VideoId[]) { if (videoIds.length === 0) return {}; // Fetch space-level sharing @@ -142,7 +142,7 @@ async function getSharedSpacesForVideos(videoIds: string[]) { return sharedSpacesMap; } -export async function getVideosByFolderId(folderId: string) { +export async function getVideosByFolderId(folderId: Folder.FolderId) { if (!folderId) throw new Error("Folder ID is required"); const videoData = await db() diff --git a/apps/web/lib/server.ts b/apps/web/lib/server.ts index db5ad9e77..57f409d46 100644 --- a/apps/web/lib/server.ts +++ b/apps/web/lib/server.ts @@ -1,10 +1,8 @@ import "server-only"; -import { db } from "@cap/database"; import { decrypt } from "@cap/database/crypto"; import { Database, - DatabaseError, Folders, HttpAuthMiddlewareLive, S3Buckets, @@ -14,6 +12,7 @@ import { import { type HttpAuthMiddleware, Video } from "@cap/web-domain"; import * as NodeSdk from "@effect/opentelemetry/NodeSdk"; import { + FetchHttpClient, type HttpApi, HttpApiBuilder, HttpMiddleware, @@ -25,15 +24,7 @@ import { cookies } from "next/headers"; import { allowedOrigins } from "@/utils/cors"; import { getTracingConfig } from "./tracing"; -const DatabaseLive = Layer.sync(Database, () => ({ - execute: (cb) => - Effect.tryPromise({ - try: () => cb(db()), - catch: (error) => new DatabaseError({ message: String(error) }), - }), -})); - -const TracingLayer = NodeSdk.layer(getTracingConfig); +export const TracingLayer = NodeSdk.layer(getTracingConfig); const CookiePasswordAttachmentLive = Layer.effect( Video.VideoPasswordAttachment, @@ -53,8 +44,8 @@ export const Dependencies = Layer.mergeAll( Videos.Default, VideosPolicy.Default, Folders.Default, - TracingLayer, -).pipe(Layer.provideMerge(DatabaseLive)); + Database.Default, +).pipe(Layer.provideMerge(Layer.mergeAll(TracingLayer, FetchHttpClient.layer))); // purposefully not exposed const EffectRuntime = ManagedRuntime.make(Dependencies); diff --git a/apps/web/lib/tracing-server.ts b/apps/web/lib/tracing-server.ts new file mode 100644 index 000000000..53b062d27 --- /dev/null +++ b/apps/web/lib/tracing-server.ts @@ -0,0 +1,5 @@ +import * as NodeSdk from "@effect/opentelemetry/NodeSdk"; + +import { getTracingConfig } from "./tracing"; + +export const TracingLayer = NodeSdk.layer(getTracingConfig); diff --git a/apps/web/lib/tracing.ts b/apps/web/lib/tracing.ts index 3a75e2e1e..71b016f57 100644 --- a/apps/web/lib/tracing.ts +++ b/apps/web/lib/tracing.ts @@ -22,7 +22,7 @@ export const getTracingConfig = Effect.gen(function* () { return { resource: { serviceName: "cap-web" }, spanProcessor: Option.match(axiomProcessor, { - onNone: () => [], + onNone: () => [new BatchSpanProcessor(new OTLPTraceExporter({}))], onSome: (processor) => [processor], }), }; diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 0a30c0e48..0c3133bb7 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -52,7 +52,7 @@ const nextConfig = { process.env.NODE_ENV === "development" && { protocol: "http", hostname: "localhost", - port: "3902", + port: "9000", pathname: "**", }, ].filter(Boolean), @@ -110,6 +110,25 @@ const nextConfig = { // If the DOCKER_BUILD environment variable is set to true, we are output nextjs to standalone ready for docker deployment output: process.env.NEXT_PUBLIC_DOCKER_BUILD === "true" ? "standalone" : undefined, + // webpack: (config) => { + // config.module.rules.push({ + // test: /\.(?:js|ts)$/, + // use: [ + // { + // loader: "babel-loader", + // options: { + // presets: ["next/babel"], + // plugins: [ + // "@babel/plugin-transform-private-property-in-object", + // "@babel/plugin-transform-private-methods", + // ], + // }, + // }, + // ], + // }); + + // return config; + // }, }; export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json index b00cb0f0a..e61110c48 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -27,9 +27,13 @@ "@cap/web-domain": "workspace:*", "@deepgram/sdk": "^3.3.4", "@dub/analytics": "^0.0.27", + "@effect/cluster": "^0.48.6", "@effect/opentelemetry": "^0.56.1", "@effect/platform": "^0.90.1", - "@effect/rpc": "^0.68.3", + "@effect/platform-node": "^0.96.1", + "@effect/rpc": "^0.69.2", + "@effect/sql-mysql2": "^0.45.1", + "@effect/workflow": "^0.9.5", "@fortawesome/free-brands-svg-icons": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^0.2.2", @@ -74,9 +78,9 @@ "date-fns": "^4.1.0", "dompurify": "^3.2.6", "dotenv": "^16.3.1", - "drizzle-orm": "0.43.1", + "drizzle-orm": "0.44.5", "dub": "^0.64.0", - "effect": "^3.17.7", + "effect": "^3.17.13", "file-saver": "^2.0.5", "framer-motion": "^11.13.1", "geist": "^1.3.1", @@ -92,7 +96,7 @@ "media-chrome": "^4.12.0", "moment": "^2.30.1", "motion": "^12.18.1", - "next": "14.2.3", + "next": "^14", "next-auth": "^4.24.5", "next-contentlayer2": "^0.5.3", "next-mdx-remote": "^5.0.0", @@ -119,6 +123,8 @@ "zod": "^3.25.76" }, "devDependencies": { + "@babel/plugin-transform-private-methods": "^7.27.1", + "@babel/plugin-transform-private-property-in-object": "^7.27.1", "@smithy/types": "^4.3.1", "@types/canvas-confetti": "^1.9.0", "@types/file-saver": "^2.0.7", @@ -131,6 +137,7 @@ "@types/react-responsive-masonry": "^2.6.0", "@types/uuid": "^9.0.8", "autoprefixer": "^10.4.14", + "babel-loader": "^10.0.0", "eslint": "^9.30.1", "eslint-config-next": "14.1.0", "postcss": "^8.4.23", diff --git a/apps/workflow-manager/package.json b/apps/workflow-manager/package.json new file mode 100644 index 000000000..9671eb4c4 --- /dev/null +++ b/apps/workflow-manager/package.json @@ -0,0 +1,15 @@ +{ + "name": "@cap/workflow-manager", + "type": "module", + "scripts": { + "dev": "dotenv -e ../../.env -- deno --allow-all --watch src/index.ts" + }, + "dependencies": { + "@effect/platform-node": "^0.96.1", + "@effect/sql-mysql2": "^0.45.1", + "effect": "^3.17.13" + }, + "devDependencies": { + "dotenv-cli": "^10.0.0" + } +} diff --git a/apps/workflow-manager/src/index.ts b/apps/workflow-manager/src/index.ts new file mode 100644 index 000000000..1d337c744 --- /dev/null +++ b/apps/workflow-manager/src/index.ts @@ -0,0 +1,23 @@ +import { + NodeClusterShardManagerSocket, + NodeRuntime, +} from "@effect/platform-node"; +import { MysqlClient } from "@effect/sql-mysql2"; +import { Config, Effect, Layer, Logger, Redacted } from "effect"; + +const DatabaseLive = Layer.unwrapEffect( + Effect.gen(function* () { + const url = Redacted.make(yield* Config.string("DATABASE_URL")); + + return MysqlClient.layer({ url }); + }), +); + +NodeClusterShardManagerSocket.layer({ + storage: "sql", +}).pipe( + Layer.provide(DatabaseLive), + Layer.provide(Logger.pretty), + Layer.launch, + NodeRuntime.runMain, +); diff --git a/apps/workflow-runner/package.json b/apps/workflow-runner/package.json new file mode 100644 index 000000000..7583d910b --- /dev/null +++ b/apps/workflow-runner/package.json @@ -0,0 +1,23 @@ +{ + "name": "@cap/workflow-runner", + "type": "module", + "scripts": { + "dev": "pnpm dotenv -e ../../.env -- deno run --allow-all --watch ./src/index.ts" + }, + "dependencies": { + "@cap/web-backend": "workspace:*", + "@cap/web-domain": "workspace:*", + "@effect/cluster": "^0.48.6", + "@effect/opentelemetry": "^0.56.1", + "@effect/platform": "^0.90.1", + "@effect/platform-node": "^0.96.1", + "@effect/sql-mysql2": "^0.45.1", + "@effect/workflow": "^0.9.5", + "@opentelemetry/exporter-trace-otlp-http": "^0.203.0", + "@opentelemetry/sdk-trace-base": "^2.0.1", + "effect": "^3.17.13" + }, + "devDependencies": { + "dotenv-cli": "^10.0.0" + } +} diff --git a/apps/workflow-runner/src/index.ts b/apps/workflow-runner/src/index.ts new file mode 100644 index 000000000..2a4a8e112 --- /dev/null +++ b/apps/workflow-runner/src/index.ts @@ -0,0 +1,66 @@ +import { createServer } from "node:http"; +import { Database, S3Buckets, Videos, Workflows } from "@cap/web-backend"; +import { ClusterWorkflowEngine, RunnerAddress } from "@effect/cluster"; +import * as NodeSdk from "@effect/opentelemetry/NodeSdk"; +import { FetchHttpClient, HttpApiBuilder, HttpServer } from "@effect/platform"; +import { + NodeClusterRunnerSocket, + NodeHttpServer, + NodeRuntime, +} from "@effect/platform-node"; +import { MysqlClient } from "@effect/sql-mysql2"; +import { WorkflowProxyServer } from "@effect/workflow"; +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; +import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base"; +import { Config, Effect, Layer, Option } from "effect"; + +const SqlLayer = Layer.unwrapEffect( + Effect.gen(function* () { + const url = yield* Config.string("DATABASE_URL").pipe((v) => + Config.redacted(v), + ); + return MysqlClient.layer({ url }); + }), +); + +const ClusterWorkflowLive = ClusterWorkflowEngine.layer.pipe( + Layer.provide( + NodeClusterRunnerSocket.layer({ + storage: "sql", + shardingConfig: { + runnerAddress: Option.some(RunnerAddress.make("localhost", 42069)), + }, + }), + ), + Layer.provide(SqlLayer), +); + +const WorkflowApiLive = HttpApiBuilder.api(Workflows.Api).pipe( + Layer.provide( + WorkflowProxyServer.layerHttpApi( + Workflows.Api, + "workflows", + Workflows.Workflows, + ), + ), + Layer.provide(Workflows.WorkflowsLayer), + Layer.provide(ClusterWorkflowLive), + HttpServer.withLogAddress, +); + +const TracingLayer = NodeSdk.layer(() => ({ + resource: { serviceName: "cap-workflow-runner" }, + spanProcessor: [new BatchSpanProcessor(new OTLPTraceExporter({}))], +})); + +HttpApiBuilder.serve().pipe( + Layer.provide(WorkflowApiLive), + Layer.provide(NodeHttpServer.layer(createServer, { port: 42169 })), + Layer.provide(Videos.Default), + Layer.provide(S3Buckets.Default), + Layer.provide(Database.Default), + Layer.provide(FetchHttpClient.layer), + Layer.provide(TracingLayer), + Layer.launch, + NodeRuntime.runMain, +); diff --git a/biome.json b/biome.json index f2a353d51..621aba760 100644 --- a/biome.json +++ b/biome.json @@ -20,7 +20,10 @@ "linter": { "enabled": true, "rules": { - "recommended": true + "recommended": true, + "suspicious": { + "noShadowRestrictedNames": "off" + } } }, "javascript": { diff --git a/package.json b/package.json index bee43acc5..7eb90c241 100644 --- a/package.json +++ b/package.json @@ -37,5 +37,10 @@ "name": "cap", "engines": { "node": "20" + }, + "pnpm": { + "overrides": { + "undici": "5.28.4" + } } } diff --git a/packages/database/auth/auth-options.tsx b/packages/database/auth/auth-options.ts similarity index 95% rename from packages/database/auth/auth-options.tsx rename to packages/database/auth/auth-options.ts index 31f797e93..92b4fb817 100644 --- a/packages/database/auth/auth-options.tsx +++ b/packages/database/auth/auth-options.ts @@ -1,7 +1,6 @@ +import crypto from "node:crypto"; import { serverEnv } from "@cap/env"; -import crypto from "crypto"; import { eq } from "drizzle-orm"; -import { cookies } from "next/headers"; import type { NextAuthOptions } from "next-auth"; import { getServerSession as _getServerSession } from "next-auth"; import type { Adapter } from "next-auth/adapters"; @@ -9,13 +8,14 @@ import EmailProvider from "next-auth/providers/email"; import GoogleProvider from "next-auth/providers/google"; import type { Provider } from "next-auth/providers/index"; import WorkOSProvider from "next-auth/providers/workos"; -import { db } from "../"; -import { dub } from "../dub"; -import { sendEmail } from "../emails/config"; -import { nanoId } from "../helpers"; -import { organizationMembers, organizations, users } from "../schema"; -import { isEmailAllowedForSignup } from "./domain-utils"; -import { DrizzleAdapter } from "./drizzle-adapter"; + +import { dub } from "../dub.ts"; +import { sendEmail } from "../emails/config.ts"; +import { nanoId } from "../helpers.ts"; +import { db } from "../index.ts"; +import { organizationMembers, organizations, users } from "../schema.ts"; +import { isEmailAllowedForSignup } from "./domain-utils.ts"; +import { DrizzleAdapter } from "./drizzle-adapter.ts"; export const config = { maxDuration: 120, @@ -136,6 +136,7 @@ export const authOptions = (): NextAuthOptions => { dbUser.activeOrganizationId === ""; if (needsOrganizationSetup) { + const { cookies } = await import("next/headers"); const dubId = cookies().get("dub_id")?.value; const dubPartnerData = cookies().get("dub_partner_data")?.value; diff --git a/packages/database/auth/drizzle-adapter.ts b/packages/database/auth/drizzle-adapter.ts index 14d61270a..94a196a02 100644 --- a/packages/database/auth/drizzle-adapter.ts +++ b/packages/database/auth/drizzle-adapter.ts @@ -3,8 +3,9 @@ import { and, eq } from "drizzle-orm"; import type { PlanetScaleDatabase } from "drizzle-orm/planetscale-serverless"; import type { Adapter } from "next-auth/adapters"; import type Stripe from "stripe"; -import { nanoId } from "../helpers"; -import { accounts, sessions, users, verificationTokens } from "../schema"; + +import { nanoId } from "../helpers.ts"; +import { accounts, sessions, users, verificationTokens } from "../schema.ts"; export function DrizzleAdapter(db: PlanetScaleDatabase): Adapter { return { diff --git a/packages/database/index.ts b/packages/database/index.ts index b29c14dfd..921de496f 100644 --- a/packages/database/index.ts +++ b/packages/database/index.ts @@ -1,11 +1,10 @@ -import { serverEnv } from "@cap/env"; import { Client, type Config } from "@planetscale/database"; import { sql } from "drizzle-orm"; import type { AnyMySqlColumn } from "drizzle-orm/mysql-core"; import { drizzle } from "drizzle-orm/planetscale-serverless"; function createDrizzle() { - const URL = serverEnv().DATABASE_URL; + const URL = process.env.DATABASE_URL!; let fetchHandler: Promise | undefined; diff --git a/packages/database/package.json b/packages/database/package.json index 5484a796d..609e272e5 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -1,6 +1,7 @@ { "name": "@cap/database", "private": true, + "type": "module", "types": "./index.ts", "main": "index.js", "scripts": { @@ -16,16 +17,18 @@ "dependencies": { "@cap/env": "workspace:*", "@cap/web-domain": "workspace:*", + "@effect/sql-mysql2": "^0.45.1", "@mattrax/mysql-planetscale": "^0.0.3", "@paralleldrive/cuid2": "^2.2.2", "@planetscale/database": "^1.13.0", "@react-email/components": "^0.1.0", "@react-email/render": "1.1.2", "@react-email/tailwind": "^1.0.5", - "drizzle-orm": "0.43.1", + "drizzle-orm": "0.44.5", "dub": "^0.64.0", + "effect": "^3.17.13", "nanoid": "^5.0.4", - "next": "14.2.9", + "next": "^14", "next-auth": "^4.24.5", "react-email": "^4.0.16", "resend": "4.6.0", @@ -48,5 +51,16 @@ }, "engines": { "node": "20" + }, + "exports": { + ".": "./index.ts", + "./auth/auth-options": "./auth/auth-options.ts", + "./auth/session": "./auth/session.ts", + "./emails/config": "./emails/config.ts", + "./emails/*": "./emails/*.tsx", + "./schema": "./schema.ts", + "./crypto": "./crypto.ts", + "./helpers": "./helpers.ts", + "./types": "./types/index.ts" } } diff --git a/packages/database/schema.ts b/packages/database/schema.ts index fdb8beb8b..7bc232434 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -8,6 +8,7 @@ import { int, json, mysqlTable, + primaryKey, text, timestamp, tinyint, @@ -15,8 +16,9 @@ import { varchar, } from "drizzle-orm/mysql-core"; import { relations } from "drizzle-orm/relations"; -import { nanoIdLength } from "./helpers"; -import type { VideoMetadata } from "./types"; + +import { nanoIdLength } from "./helpers.ts"; +import type { VideoMetadata } from "./types/index.ts"; const nanoId = customType<{ data: string; notNull: true }>({ dataType() { @@ -240,6 +242,8 @@ export const videos = mysqlTable( { id: nanoId("id").notNull().primaryKey().unique().$type(), ownerId: nanoId("ownerId").notNull(), + // TODO: make this non-null + orgId: nanoIdNullable("orgId"), name: varchar("name", { length: 255 }).notNull().default("My Video"), bucket: nanoIdNullable("bucket"), // in seconds @@ -258,7 +262,7 @@ export const videos = mysqlTable( >() .notNull() .default({ type: "MediaConvert" }), - folderId: nanoIdNullable("folderId"), + folderId: nanoIdNullable("folderId").$type(), createdAt: timestamp("createdAt").notNull().defaultNow(), updatedAt: timestamp("updatedAt").notNull().defaultNow().onUpdateNow(), // PRIVATE @@ -275,19 +279,19 @@ export const videos = mysqlTable( jobStatus: varchar("jobStatus", { length: 255 }), skipProcessing: boolean("skipProcessing").notNull().default(false), }, - (table) => ({ - idIndex: index("id_idx").on(table.id), - ownerIdIndex: index("owner_id_idx").on(table.ownerId), - publicIndex: index("is_public_idx").on(table.public), - folderIdIndex: index("folder_id_idx").on(table.folderId), - }), + (table) => [ + index("id_idx").on(table.id), + index("owner_id_idx").on(table.ownerId), + index("is_public_idx").on(table.public), + index("folder_id_idx").on(table.folderId), + ], ); export const sharedVideos = mysqlTable( "shared_videos", { id: nanoId("id").notNull().primaryKey().unique(), - videoId: nanoId("videoId").notNull(), + videoId: nanoId("videoId").notNull().$type(), organizationId: nanoId("organizationId").notNull(), sharedByUserId: nanoId("sharedByUserId").notNull(), sharedAt: timestamp("sharedAt").notNull().defaultNow(), @@ -313,7 +317,7 @@ export const comments = mysqlTable( content: text("content").notNull(), timestamp: float("timestamp"), authorId: nanoId("authorId").notNull(), - videoId: nanoId("videoId").notNull(), + videoId: nanoId("videoId").notNull().$type(), createdAt: timestamp("createdAt").notNull().defaultNow(), updatedAt: timestamp("updatedAt").notNull().defaultNow().onUpdateNow(), parentCommentId: nanoId("parentCommentId"), @@ -648,3 +652,16 @@ export const videoUploads = mysqlTable("video_uploads", { startedAt: timestamp("started_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }); + +export const importedVideos = mysqlTable( + "imported_videos", + { + id: nanoId("id").notNull(), + orgId: nanoIdNullable("orgId").notNull(), + source: varchar("source", { length: 255, enum: ["loom"] }).notNull(), + sourceId: varchar("source_id", { length: 255 }).notNull(), + }, + (table) => [ + primaryKey({ columns: [table.orgId, table.source, table.sourceId] }), + ], +); diff --git a/packages/database/types/index.ts b/packages/database/types/index.ts index e6c55b6c9..599d032aa 100644 --- a/packages/database/types/index.ts +++ b/packages/database/types/index.ts @@ -1 +1,3 @@ export * from "./metadata"; + +import "./next-auth"; diff --git a/packages/env/index.ts b/packages/env/index.ts index 51282f4fb..815f62a2e 100644 --- a/packages/env/index.ts +++ b/packages/env/index.ts @@ -1,2 +1,2 @@ -export { buildEnv, NODE_ENV } from "./build"; -export { serverEnv } from "./server"; +export { buildEnv, NODE_ENV } from "./build.ts"; +export { serverEnv } from "./server.ts"; diff --git a/packages/env/package.json b/packages/env/package.json index 7fe3a31fa..67a2d7089 100644 --- a/packages/env/package.json +++ b/packages/env/package.json @@ -1,6 +1,7 @@ { "name": "@cap/env", "private": true, + "type": "module", "main": "./index.ts", "types": "./index.ts", "dependencies": { diff --git a/packages/env/server.ts b/packages/env/server.ts index 678825bc5..32f671735 100644 --- a/packages/env/server.ts +++ b/packages/env/server.ts @@ -65,6 +65,8 @@ function createServerEnv() { CLOUDFRONT_KEYPAIR_PRIVATE_KEY: z.string().optional(), S3_PUBLIC_ENDPOINT: z.string().optional(), S3_INTERNAL_ENDPOINT: z.string().optional(), + REMOTE_WORKFLOW_URL: z.string().optional(), + REMOTE_WORKFLOW_SECRET: z.string().optional(), }, experimental__runtimeEnv: { ...process.env, diff --git a/packages/local-docker/docker-compose.yml b/packages/local-docker/docker-compose.yml index a6b1d01f9..fe095e118 100644 --- a/packages/local-docker/docker-compose.yml +++ b/packages/local-docker/docker-compose.yml @@ -23,19 +23,17 @@ services: # Local S3 Strorage minio: container_name: minio-storage - image: "bitnami/minio:latest" + image: "minio/minio:latest" restart: unless-stopped ports: - - "3902:3902" - - "3903:3903" + - "9000:9000" + - "9001:9001" environment: - - MINIO_ROOT_USER=capS3root - - MINIO_ROOT_PASSWORD=capS3root - - MINIO_API_PORT_NUMBER=3902 - - MINIO_CONSOLE_PORT_NUMBER=3903 + MINIO_ROOT_USER: capS3root + MINIO_ROOT_PASSWORD: capS3root volumes: - - minio-data:/bitnami/minio/data - - minio-certs:/certs + - ~/minio/data:/data + command: server /data --console-address ":9001" createbuckets: container_name: minio-bucket-creation @@ -45,7 +43,7 @@ services: entrypoint: > /bin/sh -c " sleep 10; - /usr/bin/mc alias set myminio http://minio:3902 capS3root capS3root; + /usr/bin/mc alias set myminio http://minio:9000 capS3root capS3root; /usr/bin/mc mb myminio/capso; echo '{\"Version\": \"2012-10-17\",\"Statement\": [{\"Effect\": \"Allow\",\"Principal\": {\"AWS\": [\"*\"]},\"Action\": [\"s3:GetObject\"],\"Resource\": [\"arn:aws:s3:::capso/*\"]}]}' > /tmp/policy.json; /usr/bin/mc anonymous set-json /tmp/policy.json myminio/capso; diff --git a/packages/utils/package.json b/packages/utils/package.json index fbd9dcb12..082b364a0 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,10 +1,10 @@ { "name": "@cap/utils", - "version": "0.0.0", - "main": "./src/index.tsx", - "types": "./src/index.tsx", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", "exports": { - ".": "./src/index.tsx" + ".": "./src/index.ts" }, "scripts": { "typecheck": "tsc -b" diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts new file mode 100644 index 000000000..72c80b23d --- /dev/null +++ b/packages/utils/src/index.ts @@ -0,0 +1,5 @@ +export * from "./constants/plans.ts"; +export * from "./constants/s3.ts"; +export * from "./helpers.ts"; +export * from "./lib/stripe/stripe.ts"; +export * from "./types/database.ts"; diff --git a/packages/utils/src/index.tsx b/packages/utils/src/index.tsx deleted file mode 100644 index 5e82387dc..000000000 --- a/packages/utils/src/index.tsx +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./constants/plans"; -export * from "./constants/s3"; -export * from "./helpers"; -export * from "./lib/stripe/stripe"; -export * from "./types/database"; diff --git a/packages/web-api-contract-effect/package.json b/packages/web-api-contract-effect/package.json index 603541edd..012b335bb 100644 --- a/packages/web-api-contract-effect/package.json +++ b/packages/web-api-contract-effect/package.json @@ -6,6 +6,6 @@ "type": "module", "dependencies": { "@effect/platform": "^0.90.1", - "effect": "^3.17.7" + "effect": "^3.17.13" } } diff --git a/packages/web-backend/package.json b/packages/web-backend/package.json index a34b8b7fd..21fb14a50 100644 --- a/packages/web-backend/package.json +++ b/packages/web-backend/package.json @@ -12,12 +12,14 @@ "@cap/database": "workspace:*", "@cap/utils": "workspace:*", "@cap/web-domain": "workspace:*", + "@effect/cluster": "^0.48.6", "@effect/platform": "^0.90.1", - "@effect/rpc": "^0.68.3", + "@effect/rpc": "^0.69.2", + "@effect/workflow": "^0.9.5", "@smithy/types": "^4.3.1", - "drizzle-orm": "0.43.1", - "effect": "^3.17.7", - "server-only": "^0.0.1", - "next": "14.2.3" + "drizzle-orm": "0.44.5", + "effect": "^3.17.13", + "next": "^14", + "server-only": "^0.0.1" } } diff --git a/packages/web-backend/src/Auth.ts b/packages/web-backend/src/Auth.ts index 757776c45..46e0b39ba 100644 --- a/packages/web-backend/src/Auth.ts +++ b/packages/web-backend/src/Auth.ts @@ -1,11 +1,11 @@ import { getServerSession } from "@cap/database/auth/auth-options"; import * as Db from "@cap/database/schema"; import { CurrentUser, HttpAuthMiddleware } from "@cap/web-domain"; -import { HttpApiError, type HttpApp } from "@effect/platform"; +import { HttpApiError, HttpServerRequest } from "@effect/platform"; import * as Dz from "drizzle-orm"; -import { type Cause, Effect, Layer, Option } from "effect"; +import { type Cause, Effect, Layer, Option, Schema } from "effect"; -import { Database, type DatabaseError } from "./Database"; +import { Database, type DatabaseError } from "./Database.ts"; export const getCurrentUser = Effect.gen(function* () { const db = yield* Database; @@ -37,24 +37,47 @@ export const HttpAuthMiddlewareLive = Layer.effect( return HttpAuthMiddleware.of( Effect.gen(function* () { - const user = yield* getCurrentUser.pipe( - Effect.flatten, + const headers = yield* HttpServerRequest.schemaHeaders( + Schema.Struct({ authorization: Schema.optional(Schema.String) }), + ); + const authHeader = headers.authorization?.split(" ")[1]; + + let user; + + if (authHeader?.length === 36) { + user = yield* database + .execute((db) => + db + .select() + .from(Db.users) + .leftJoin( + Db.authApiKeys, + Dz.eq(Db.users.id, Db.authApiKeys.userId), + ) + .where(Dz.eq(Db.authApiKeys.id, authHeader)), + ) + .pipe(Effect.map(([entry]) => Option.fromNullable(entry?.users))); + } else { + user = yield* getCurrentUser; + } + + return yield* user.pipe( + Option.map((user) => ({ + id: user.id, + email: user.email, + activeOrgId: user.activeOrganizationId, + })), Effect.catchTag( "NoSuchElementException", () => new HttpApiError.Unauthorized(), ), ); - - return { - id: user.id, - email: user.email, - activeOrgId: user.activeOrganizationId, - }; }).pipe( Effect.provideService(Database, database), Effect.catchTags({ UnknownException: () => new HttpApiError.InternalServerError(), DatabaseError: () => new HttpApiError.InternalServerError(), + ParseError: () => new HttpApiError.BadRequest(), }), ), ); diff --git a/packages/web-backend/src/Database.ts b/packages/web-backend/src/Database.ts index 1bc6a516b..16e6eb01c 100644 --- a/packages/web-backend/src/Database.ts +++ b/packages/web-backend/src/Database.ts @@ -1,15 +1,19 @@ -import type { db } from "@cap/database"; -import { Context, Data, type Effect } from "effect"; +import { db } from "@cap/database"; +import { Effect, Schema } from "effect"; -export class Database extends Context.Tag("Database")< - Database, - { - execute( - callback: (_: ReturnType) => Promise, - ): Effect.Effect; - } ->() {} +export class DatabaseError extends Schema.TaggedError()( + "DatabaseError", + { cause: Schema.Unknown }, +) {} -export class DatabaseError extends Data.TaggedError("DatabaseError")<{ - message: string; -}> {} +export class Database extends Effect.Service()("Database", { + effect: Effect.gen(function* () { + return { + execute: (cb: (_: ReturnType) => Promise) => + Effect.tryPromise({ + try: () => cb(db()), + catch: (cause) => new DatabaseError({ cause }), + }), + }; + }), +}) {} diff --git a/packages/web-backend/src/Folders/FoldersPolicy.ts b/packages/web-backend/src/Folders/FoldersPolicy.ts index 7b0f0e47b..42b677ec6 100644 --- a/packages/web-backend/src/Folders/FoldersPolicy.ts +++ b/packages/web-backend/src/Folders/FoldersPolicy.ts @@ -2,7 +2,8 @@ import * as Db from "@cap/database/schema"; import { type Folder, Policy } from "@cap/web-domain"; import * as Dz from "drizzle-orm"; import { Effect } from "effect"; -import { Database } from "../Database"; + +import { Database } from "../Database.ts"; export class FoldersPolicy extends Effect.Service()( "FoldersPolicy", @@ -41,5 +42,6 @@ export class FoldersPolicy extends Effect.Service()( return { canEdit }; }), + dependencies: [Database.Default], }, ) {} diff --git a/packages/web-backend/src/Folders/FoldersRpcs.ts b/packages/web-backend/src/Folders/FoldersRpcs.ts index afc1a72be..078d6f10f 100644 --- a/packages/web-backend/src/Folders/FoldersRpcs.ts +++ b/packages/web-backend/src/Folders/FoldersRpcs.ts @@ -1,7 +1,7 @@ import { Folder, InternalError } from "@cap/web-domain"; import { Effect } from "effect"; -import { Folders } from "."; +import { Folders } from "./index.ts"; export const FolderRpcsLive = Folder.FolderRpcs.toLayer( Effect.gen(function* () { diff --git a/packages/web-backend/src/Folders/index.ts b/packages/web-backend/src/Folders/index.ts index b620072a5..bf4b2c10f 100644 --- a/packages/web-backend/src/Folders/index.ts +++ b/packages/web-backend/src/Folders/index.ts @@ -3,9 +3,10 @@ import * as Db from "@cap/database/schema"; import { CurrentUser, Folder, Policy } from "@cap/web-domain"; import * as Dz from "drizzle-orm"; import { Effect, Option } from "effect"; -import { Database, type DatabaseError } from "../Database"; -import { FoldersPolicy } from "./FoldersPolicy"; +import { Database, type DatabaseError } from "../Database.ts"; +import { FoldersPolicy } from "./FoldersPolicy.ts"; +// @effect-diagnostics-next-line leakingRequirements:off export class Folders extends Effect.Service()("Folders", { effect: Effect.gen(function* () { const db = yield* Database; @@ -123,5 +124,5 @@ export class Folders extends Effect.Service()("Folders", { }), }; }), - dependencies: [FoldersPolicy.Default], + dependencies: [FoldersPolicy.Default, Database.Default], }) {} diff --git a/packages/web-backend/src/Loom/ImportVideo.ts b/packages/web-backend/src/Loom/ImportVideo.ts new file mode 100644 index 000000000..021f1d16e --- /dev/null +++ b/packages/web-backend/src/Loom/ImportVideo.ts @@ -0,0 +1,161 @@ +import { S3Bucket, Video } from "@cap/web-domain"; +import { Headers, HttpClient } from "@effect/platform"; +import { Activity, Workflow } from "@effect/workflow"; +import { Effect, Option, Schedule, Schema, Stream } from "effect"; + +import { DatabaseError } from "../Database.ts"; +import { S3Buckets } from "../S3Buckets/index.ts"; +import { S3BucketAccess, S3Error } from "../S3Buckets/S3BucketAccess.ts"; +import { Videos } from "../Videos/index.ts"; + +export class LoomApiError extends Schema.TaggedError( + "LoomApiError", +)("LoomApiError", { cause: Schema.Unknown }) {} + +export const LoomImportVideoError = Schema.Union( + DatabaseError, + Video.NotFoundError, + S3Error, + LoomApiError, +); + +export const LoomImportVideo = Workflow.make({ + name: "LoomImportVideo", + payload: { + cap: Schema.Struct({ + userId: Schema.String, + orgId: Schema.String, + }), + loom: Schema.Struct({ + userId: Schema.String, + orgId: Schema.String, + video: Schema.Struct({ + id: Schema.String, + name: Schema.String, + downloadUrl: Schema.String, + width: Schema.OptionFromNullOr(Schema.Number), + height: Schema.OptionFromNullOr(Schema.Number), + fps: Schema.OptionFromNullOr(Schema.Number), + durationSecs: Schema.OptionFromNullOr(Schema.Number), + }), + }), + attempt: Schema.optional(Schema.Number), + }, + error: LoomImportVideoError, + idempotencyKey: (p) => + `${p.cap.userId}-${p.loom.orgId}-${p.loom.video.id}-${p.attempt ?? 0}`, +}); + +export const LoomImportVideoLive = LoomImportVideo.toLayer( + Effect.fn(function* (payload) { + const videos = yield* Videos; + const s3Buckets = yield* S3Buckets; + const http = yield* HttpClient.HttpClient; + + const { videoId, customBucketId } = yield* Activity.make({ + name: "CreateVideoRecord", + error: LoomImportVideoError, + success: Schema.Struct({ + videoId: Video.VideoId, + customBucketId: Schema.Option(S3Bucket.S3BucketId), + }), + execute: Effect.gen(function* () { + const loomVideo = payload.loom.video; + + const [_, customBucket] = yield* s3Buckets + .getProviderForUser(payload.cap.userId) + .pipe(Effect.catchAll(() => Effect.die(null))); + + const customBucketId = Option.map(customBucket, (b) => b.id); + + const videoId = yield* videos.create({ + ownerId: payload.cap.userId, + orgId: Option.some(payload.cap.orgId), + bucketId: customBucketId, + source: { type: "desktopMP4" as const }, + name: payload.loom.video.name, + duration: loomVideo.durationSecs, + width: loomVideo.width, + height: loomVideo.height, + public: true, + metadata: Option.none(), + folderId: Option.none(), + transcriptionStatus: Option.none(), + importSource: new Video.ImportSource({ + source: "loom", + id: loomVideo.id, + }), + }); + + return { videoId, customBucketId }; + }), + }); + + const source = new Video.Mp4Source({ + videoId: videoId, + ownerId: payload.cap.userId, + }); + + const { fileKey } = yield* Activity.make({ + name: "DownloadVideo", + error: LoomImportVideoError, + success: Schema.Struct({ fileKey: Schema.String }), + execute: Effect.gen(function* () { + const [bucketProvider] = + yield* s3Buckets.getProviderForBucket(customBucketId); + + return yield* Effect.gen(function* () { + const s3Bucket = yield* S3BucketAccess; + + const resp = yield* http + .get(payload.loom.video.downloadUrl) + .pipe(Effect.catchAll((cause) => new LoomApiError({ cause }))); + const contentLength = Headers.get( + resp.headers, + "content-length", + ).pipe( + Option.map((v) => Number(v)), + Option.getOrUndefined, + ); + yield* Effect.log(`Downloading ${contentLength} bytes`); + + let downloadedBytes = 0; + + const key = source.getFileKey(); + + yield* s3Bucket + .putObject( + key, + resp.stream.pipe( + Stream.tap((bytes) => { + downloadedBytes += bytes.length; + return Effect.void; + }), + ), + { contentLength }, + ) + .pipe( + Effect.race( + // TODO: Connect this with upload progress + Effect.repeat( + Effect.gen(function* () { + const bytes = yield* Effect.succeed(downloadedBytes); + yield* Effect.log(`Downloaded ${bytes} bytes`); + }), + Schedule.forever.pipe(Schedule.delayed(() => "2 seconds")), + ).pipe(Effect.delay("100 millis")), + ), + ); + + yield* Effect.log( + `Uploaded video for user '${payload.cap.userId}' at key '${key}'`, + ); + + return { fileKey: key }; + }).pipe(Effect.provide(bucketProvider)); + }), + }); + + return { fileKey, videoId }; + }), +); diff --git a/packages/web-backend/src/Loom/index.ts b/packages/web-backend/src/Loom/index.ts new file mode 100644 index 000000000..3610e7138 --- /dev/null +++ b/packages/web-backend/src/Loom/index.ts @@ -0,0 +1 @@ +export { LoomImportVideo, LoomImportVideoLive } from "./ImportVideo.ts"; diff --git a/packages/web-backend/src/Organisations/OrganisationsRepo.ts b/packages/web-backend/src/Organisations/OrganisationsRepo.ts index 314b46d3a..73a8c02fe 100644 --- a/packages/web-backend/src/Organisations/OrganisationsRepo.ts +++ b/packages/web-backend/src/Organisations/OrganisationsRepo.ts @@ -3,7 +3,7 @@ import type { Video } from "@cap/web-domain"; import * as Dz from "drizzle-orm"; import { Effect } from "effect"; -import { Database } from "../Database"; +import { Database } from "../Database.ts"; export class OrganisationsRepo extends Effect.Service()( "OrganisationsRepo", @@ -33,5 +33,6 @@ export class OrganisationsRepo extends Effect.Service()( ), }; }), + dependencies: [Database.Default], }, ) {} diff --git a/packages/web-backend/src/Rpcs.ts b/packages/web-backend/src/Rpcs.ts index 54ded2158..0cced04ca 100644 --- a/packages/web-backend/src/Rpcs.ts +++ b/packages/web-backend/src/Rpcs.ts @@ -5,10 +5,10 @@ import { } from "@cap/web-domain"; import { Effect, Layer, Option } from "effect"; -import { getCurrentUser } from "./Auth"; -import { Database } from "./Database"; -import { FolderRpcsLive } from "./Folders/FoldersRpcs"; -import { VideosRpcsLive } from "./Videos/VideosRpcs"; +import { getCurrentUser } from "./Auth.ts"; +import { Database } from "./Database.ts"; +import { FolderRpcsLive } from "./Folders/FoldersRpcs.ts"; +import { VideosRpcsLive } from "./Videos/VideosRpcs.ts"; export const RpcsLive = Layer.mergeAll(VideosRpcsLive, FolderRpcsLive); diff --git a/packages/web-backend/src/S3Buckets/S3BucketAccess.ts b/packages/web-backend/src/S3Buckets/S3BucketAccess.ts index 02573f105..4254a7b2e 100644 --- a/packages/web-backend/src/S3Buckets/S3BucketAccess.ts +++ b/packages/web-backend/src/S3Buckets/S3BucketAccess.ts @@ -1,23 +1,24 @@ +import { Readable } from "node:stream"; import * as S3 from "@aws-sdk/client-s3"; import { createPresignedPost, type PresignedPostOptions, } from "@aws-sdk/s3-presigned-post"; import * as S3Presigner from "@aws-sdk/s3-request-presigner"; -import type { - RequestPresigningArguments, - StreamingBlobPayloadInputTypes, -} from "@smithy/types"; -import { type Cause, Data, Effect, Option } from "effect"; -import { S3BucketClientProvider } from "./S3BucketClientProvider"; +import type { RequestPresigningArguments } from "@smithy/types"; +import { type Cause, Effect, Option, Schema, Stream } from "effect"; -export class S3Error extends Data.TaggedError("S3Error")<{ message: string }> {} +import { S3BucketClientProvider } from "./S3BucketClientProvider.ts"; + +export class S3Error extends Schema.TaggedError()("S3Error", { + cause: Schema.Unknown, +}) {} const wrapS3Promise = ( callback: ( provider: S3BucketClientProvider["Type"], ) => Promise | Effect.Effect, Cause.UnknownException>, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const provider = yield* S3BucketClientProvider; @@ -26,7 +27,7 @@ const wrapS3Promise = ( if (cbResult instanceof Promise) { return yield* Effect.tryPromise({ try: () => cbResult, - catch: (e) => new S3Error({ message: String(e) }), + catch: (cause) => new S3Error({ cause }), }); } @@ -34,11 +35,13 @@ const wrapS3Promise = ( Effect.flatMap((cbResult) => Effect.tryPromise({ try: () => cbResult, - catch: (e) => new S3Error({ message: String(e) }), + catch: (cause) => new S3Error({ cause }), }), ), ); - }); + }).pipe( + Effect.catchTag("UnknownException", (cause) => new S3Error({ cause })), + ); // @effect-diagnostics-next-line leakingRequirements:off export class S3BucketAccess extends Effect.Service()( @@ -105,24 +108,42 @@ export class S3BucketAccess extends Effect.Service()( ), ), ), - putObject: ( + putObject: ( key: string, - body: StreamingBlobPayloadInputTypes, - fields?: { contentType?: string }, + body: string | Uint8Array | ArrayBuffer | Stream.Stream, + fields?: { contentType?: string; contentLength?: number }, ) => wrapS3Promise((provider) => provider.getInternal.pipe( - Effect.map((client) => - client.send( - new S3.PutObjectCommand({ - Bucket: provider.bucket, - Key: key, - Body: body, - ContentType: fields?.contentType, - }), - ), + Effect.flatMap((client) => + Effect.gen(function* () { + let _body; + + if (typeof body === "string" || body instanceof Uint8Array) { + _body = body; + } else if (body instanceof ArrayBuffer) { + _body = new Uint8Array(body); + } else { + _body = body.pipe( + Stream.toReadableStreamRuntime(yield* Effect.runtime()), + (s) => Readable.fromWeb(s as any), + ); + } + + return client.send( + new S3.PutObjectCommand({ + Bucket: provider.bucket, + Key: key, + Body: _body, + ContentType: fields?.contentType, + ContentLength: fields?.contentLength, + }), + ); + }), ), ), + ).pipe( + Effect.withSpan("S3BucketAccess.putObject", { attributes: { key } }), ), /** Copy an object within the same bucket */ copyObject: ( diff --git a/packages/web-backend/src/S3Buckets/S3BucketsRepo.ts b/packages/web-backend/src/S3Buckets/S3BucketsRepo.ts index 4c483ae51..3a6fb3365 100644 --- a/packages/web-backend/src/S3Buckets/S3BucketsRepo.ts +++ b/packages/web-backend/src/S3Buckets/S3BucketsRepo.ts @@ -3,7 +3,7 @@ import { S3Bucket, type Video } from "@cap/web-domain"; import * as Dz from "drizzle-orm"; import { Effect, Option } from "effect"; -import { Database } from "../Database"; +import { Database } from "../Database.ts"; export class S3BucketsRepo extends Effect.Service()( "S3BucketsRepo", @@ -48,7 +48,26 @@ export class S3BucketsRepo extends Effect.Service()( }), ); - return { getForVideo, getById }; + const getForUser = Effect.fn("S3BucketsRepo.getForUser")( + (userId: string) => + Effect.gen(function* () { + const [res] = yield* db.execute((db) => + db + .select({ bucket: Db.s3Buckets }) + .from(Db.s3Buckets) + .where(Dz.eq(Db.s3Buckets.ownerId, userId)), + ); + + return Option.fromNullable(res).pipe( + Option.map((v) => + S3Bucket.decodeSync({ ...v.bucket, name: v.bucket.bucketName }), + ), + ); + }), + ); + + return { getForVideo, getById, getForUser }; }), + dependencies: [Database.Default], }, ) {} diff --git a/packages/web-backend/src/S3Buckets/index.ts b/packages/web-backend/src/S3Buckets/index.ts index 0d4507d8e..1cbf14085 100644 --- a/packages/web-backend/src/S3Buckets/index.ts +++ b/packages/web-backend/src/S3Buckets/index.ts @@ -4,9 +4,11 @@ import { decrypt } from "@cap/database/crypto"; import { S3_BUCKET_URL } from "@cap/utils"; import type { S3Bucket } from "@cap/web-domain"; import { Config, Context, Effect, Layer, Option } from "effect"; -import { S3BucketAccess } from "./S3BucketAccess"; -import { S3BucketClientProvider } from "./S3BucketClientProvider"; -import { S3BucketsRepo } from "./S3BucketsRepo"; + +import { Database } from "../Database.ts"; +import { S3BucketAccess } from "./S3BucketAccess.ts"; +import { S3BucketClientProvider } from "./S3BucketClientProvider.ts"; +import { S3BucketsRepo } from "./S3BucketsRepo.ts"; export class S3Buckets extends Effect.Service()("S3Buckets", { effect: Effect.gen(function* () { @@ -133,8 +135,45 @@ export class S3Buckets extends Effect.Service()("S3Buckets", { ), ); + const getProvider = Effect.fn("S3Buckets.getProviderLayer")(function* ( + customBucket: Option.Option, + ) { + const layer = yield* Option.match(customBucket, { + onNone: () => { + const provider = Layer.succeed(S3BucketClientProvider, { + getInternal: Effect.succeed(createDefaultClient(true)), + getPublic: Effect.succeed(createDefaultClient(false)), + bucket: defaultConfigs.bucket, + }); + + return Option.match(cloudfrontBucketAccess, { + onSome: (access) => access, + onNone: () => defaultBucketAccess, + }).pipe(Layer.merge(provider), Effect.succeed); + }, + onSome: (customBucket) => + Effect.gen(function* () { + const bucket = yield* Effect.promise(() => + decrypt(customBucket.name), + ); + + const provider = Layer.succeed(S3BucketClientProvider, { + getInternal: Effect.promise(() => + createBucketClient(customBucket), + ), + getPublic: Effect.promise(() => createBucketClient(customBucket)), + bucket, + }); + + return Layer.merge(defaultBucketAccess, provider); + }), + }); + + return [layer, customBucket] as const; + }); + return { - getProviderLayer: Effect.fn("S3Buckets.getProviderLayer")(function* ( + getProviderForBucket: Effect.fn("S3Buckets.getProviderById")(function* ( bucketId: Option.Option, ) { const customBucket = yield* bucketId.pipe( @@ -143,40 +182,18 @@ export class S3Buckets extends Effect.Service()("S3Buckets", { Effect.map(Option.flatten), ); - let layer; - - if (Option.isNone(customBucket)) { - const provider = Layer.succeed(S3BucketClientProvider, { - getInternal: Effect.succeed(createDefaultClient(true)), - getPublic: Effect.succeed(createDefaultClient(false)), - bucket: defaultConfigs.bucket, - }); + return yield* getProvider(customBucket); + }), + getProviderForUser: Effect.fn("S3Buckets.getProviderForUser")(function* ( + userId: string, + ) { + const customBucket = yield* repo + .getForUser(userId) + .pipe(Effect.option, Effect.map(Option.flatten)); - layer = Option.match(cloudfrontBucketAccess, { - onSome: (access) => access, - onNone: () => defaultBucketAccess, - }).pipe(Layer.merge(provider)); - } else { - layer = defaultBucketAccess.pipe( - Layer.merge( - Layer.succeed(S3BucketClientProvider, { - getInternal: Effect.promise(() => - createBucketClient(customBucket.value), - ), - getPublic: Effect.promise(() => - createBucketClient(customBucket.value), - ), - bucket: yield* Effect.promise(() => - decrypt(customBucket.value.name), - ), - }), - ), - ); - } - - return [layer, customBucket] as const; + return yield* getProvider(customBucket); }), }; }), - dependencies: [S3BucketsRepo.Default], + dependencies: [S3BucketsRepo.Default, Database.Default], }) {} diff --git a/packages/web-backend/src/Spaces/SpacesRepo.ts b/packages/web-backend/src/Spaces/SpacesRepo.ts index 0797026ac..bb77e42f5 100644 --- a/packages/web-backend/src/Spaces/SpacesRepo.ts +++ b/packages/web-backend/src/Spaces/SpacesRepo.ts @@ -3,7 +3,7 @@ import type { Video } from "@cap/web-domain"; import * as Dz from "drizzle-orm"; import { Effect } from "effect"; -import { Database } from "../Database"; +import { Database } from "../Database.ts"; export class SpacesRepo extends Effect.Service()("SpacesRepo", { effect: Effect.gen(function* () { @@ -28,4 +28,5 @@ export class SpacesRepo extends Effect.Service()("SpacesRepo", { ), }; }), + dependencies: [Database.Default], }) {} diff --git a/packages/web-backend/src/Videos/VideosPolicy.ts b/packages/web-backend/src/Videos/VideosPolicy.ts index c6cb0ea30..9e3aa6f6f 100644 --- a/packages/web-backend/src/Videos/VideosPolicy.ts +++ b/packages/web-backend/src/Videos/VideosPolicy.ts @@ -1,9 +1,10 @@ import { Policy, Video } from "@cap/web-domain"; import { Array, Effect, Option } from "effect"; -import { OrganisationsRepo } from "../Organisations/OrganisationsRepo"; -import { SpacesRepo } from "../Spaces/SpacesRepo"; -import { VideosRepo } from "./VideosRepo"; +import { Database } from "../Database.ts"; +import { OrganisationsRepo } from "../Organisations/OrganisationsRepo.ts"; +import { SpacesRepo } from "../Spaces/SpacesRepo.ts"; +import { VideosRepo } from "./VideosRepo.ts"; export class VideosPolicy extends Effect.Service()( "VideosPolicy", @@ -91,6 +92,7 @@ export class VideosPolicy extends Effect.Service()( VideosRepo.Default, OrganisationsRepo.Default, SpacesRepo.Default, + Database.Default, ], }, ) {} diff --git a/packages/web-backend/src/Videos/VideosRepo.ts b/packages/web-backend/src/Videos/VideosRepo.ts index 2f3b66c56..daa597ff6 100644 --- a/packages/web-backend/src/Videos/VideosRepo.ts +++ b/packages/web-backend/src/Videos/VideosRepo.ts @@ -2,76 +2,96 @@ import { nanoId } from "@cap/database/helpers"; import * as Db from "@cap/database/schema"; import { Video } from "@cap/web-domain"; import * as Dz from "drizzle-orm"; +import type { MySqlInsertBase } from "drizzle-orm/mysql-core"; import { Effect, Option } from "effect"; -import { Database } from "../Database"; +import type { Schema } from "effect/Schema"; +import { Database } from "../Database.ts"; + +export type CreateVideoInput = Omit< + Schema.Type, + "id" | "createdAt" | "updatedAt" +> & { password?: string; importSource?: Video.ImportSource }; export class VideosRepo extends Effect.Service()("VideosRepo", { effect: Effect.gen(function* () { const db = yield* Database; - return { - /** - * Gets a `Video` and its accompanying password if available. - * - * The password is returned separately as the `Video` class is client-safe - */ - getById: (id: Video.VideoId) => - Effect.gen(function* () { - const [video] = yield* db.execute((db) => - db.select().from(Db.videos).where(Dz.eq(Db.videos.id, id)), - ); + /** + * Gets a `Video` and its accompanying password if available. + * + * The password is returned separately as the `Video` class is client-safe + */ + const getById = (id: Video.VideoId) => + Effect.gen(function* () { + const [video] = yield* db.execute((db) => + db.select().from(Db.videos).where(Dz.eq(Db.videos.id, id)), + ); + + return Option.fromNullable(video).pipe( + Option.map( + (v) => + [ + Video.Video.decodeSync({ + ...v, + bucketId: v.bucket, + createdAt: v.createdAt.toISOString(), + updatedAt: v.updatedAt.toISOString(), + metadata: v.metadata as any, + }), + Option.fromNullable(video?.password), + ] as const, + ), + ); + }); + + const delete_ = (id: Video.VideoId) => + db.execute((db) => db.delete(Db.videos).where(Dz.eq(Db.videos.id, id))); - return Option.fromNullable(video).pipe( - Option.map( - (v) => - [ - Video.Video.decodeSync({ - ...v, - bucketId: v.bucket, - createdAt: v.createdAt.toISOString(), - updatedAt: v.updatedAt.toISOString(), - metadata: v.metadata as any, - }), - Option.fromNullable(video?.password), - ] as const, - ), - ); - }), - delete: (id: Video.VideoId) => - db.execute((db) => db.delete(Db.videos).where(Dz.eq(Db.videos.id, id))), - create: ( - data: Pick< - (typeof Video.Video)["Encoded"], - | "ownerId" - | "name" - | "bucketId" - | "metadata" - | "public" - | "transcriptionStatus" - | "source" - | "folderId" - > & { password?: string }, - ) => { - const id = nanoId(); + const create = (data: CreateVideoInput) => + Effect.gen(function* () { + const id = Video.VideoId.make(nanoId()); - return db.execute((db) => - db - .insert(Db.videos) - .values({ - id, - ownerId: data.ownerId, - name: data.name, - bucket: data.bucketId, - metadata: data.metadata, - public: data.public, - transcriptionStatus: data.transcriptionStatus, - source: data.source, - folderId: data.folderId, - password: data.password, - }) - .then(() => Video.VideoId.make(id)), + yield* db.execute((db) => + db.transaction(async (db) => { + const promises: MySqlInsertBase[] = [ + db.insert(Db.videos).values([ + { + ...data, + id, + orgId: Option.getOrNull(data.orgId ?? Option.none()), + bucket: Option.getOrNull(data.bucketId ?? Option.none()), + metadata: Option.getOrNull(data.metadata ?? Option.none()), + transcriptionStatus: Option.getOrNull( + data.transcriptionStatus ?? Option.none(), + ), + folderId: Option.getOrNull(data.folderId ?? Option.none()), + width: Option.getOrNull(data.width ?? Option.none()), + height: Option.getOrNull(data.height ?? Option.none()), + duration: Option.getOrNull(data.duration ?? Option.none()), + }, + ]), + ]; + + if (data.importSource && Option.isSome(data.orgId)) + promises.push( + db.insert(Db.importedVideos).values([ + { + id, + orgId: data.orgId.value, + source: data.importSource.source, + sourceId: data.importSource.id, + }, + ]), + ); + + await Promise.all(promises); + }), ); - }, - }; + + return id; + }); + + return { getById, delete: delete_, create }; }), + dependencies: [Database.Default], }) {} diff --git a/packages/web-backend/src/Videos/VideosRpcs.ts b/packages/web-backend/src/Videos/VideosRpcs.ts index c04de7f1d..5f2e12d23 100644 --- a/packages/web-backend/src/Videos/VideosRpcs.ts +++ b/packages/web-backend/src/Videos/VideosRpcs.ts @@ -1,7 +1,8 @@ import { InternalError, Video } from "@cap/web-domain"; import { Effect } from "effect"; -import { provideOptionalAuth } from "../Auth"; -import { Videos } from "."; + +import { provideOptionalAuth } from "../Auth.ts"; +import { Videos } from "./index.ts"; export const VideosRpcsLive = Video.VideoRpcs.toLayer( Effect.gen(function* () { @@ -13,7 +14,6 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer( Effect.catchTags({ DatabaseError: () => new InternalError({ type: "database" }), S3Error: () => new InternalError({ type: "s3" }), - UnknownException: () => new InternalError({ type: "unknown" }), }), ), VideoDuplicate: (videoId) => @@ -21,13 +21,11 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer( Effect.catchTags({ DatabaseError: () => new InternalError({ type: "database" }), S3Error: () => new InternalError({ type: "s3" }), - UnknownException: () => new InternalError({ type: "unknown" }), }), ), GetUploadProgress: (videoId) => videos.getUploadProgress(videoId).pipe( provideOptionalAuth, - (v) => v, Effect.catchTags({ DatabaseError: () => new InternalError({ type: "database" }), UnknownException: () => new InternalError({ type: "unknown" }), diff --git a/packages/web-backend/src/Videos/index.ts b/packages/web-backend/src/Videos/index.ts index 720e77df5..49d08d6cc 100644 --- a/packages/web-backend/src/Videos/index.ts +++ b/packages/web-backend/src/Videos/index.ts @@ -2,11 +2,12 @@ import * as Db from "@cap/database/schema"; import { CurrentUser, Policy, Video } from "@cap/web-domain"; import * as Dz from "drizzle-orm"; import { Array, Effect, Option, pipe } from "effect"; -import { Database } from "../Database"; -import { S3Buckets } from "../S3Buckets"; -import { S3BucketAccess } from "../S3Buckets/S3BucketAccess"; -import { VideosPolicy } from "./VideosPolicy"; -import { VideosRepo } from "./VideosRepo"; + +import { Database } from "../Database.ts"; +import { S3Buckets } from "../S3Buckets/index.ts"; +import { S3BucketAccess } from "../S3Buckets/S3BucketAccess.ts"; +import { VideosPolicy } from "./VideosPolicy.ts"; +import { VideosRepo } from "./VideosRepo.ts"; export class Videos extends Effect.Service()("Videos", { effect: Effect.gen(function* () { @@ -38,7 +39,7 @@ export class Videos extends Effect.Service()("Videos", { Effect.flatMap(Effect.catchAll(() => new Video.NotFoundError())), ); - const [S3ProviderLayer] = yield* s3Buckets.getProviderLayer( + const [S3ProviderLayer] = yield* s3Buckets.getProviderForBucket( video.bucketId, ); @@ -78,12 +79,12 @@ export class Videos extends Effect.Service()("Videos", { Policy.withPolicy(policy.isOwner(videoId)), ); - const [S3ProviderLayer] = yield* s3Buckets.getProviderLayer( + const [S3ProviderLayer] = yield* s3Buckets.getProviderForBucket( video.bucketId, ); // Don't duplicate password or sharing data - const newVideoId = yield* repo.create(yield* video.toJS()); + const newVideoId = yield* repo.create(video); yield* Effect.gen(function* () { const s3 = yield* S3BucketAccess; @@ -135,7 +136,14 @@ export class Videos extends Effect.Service()("Videos", { Option.map((r) => new Video.UploadProgress(r)), ); }), + + create: Effect.fn("Videos.create")(repo.create), }; }), - dependencies: [VideosPolicy.Default, VideosRepo.Default, S3Buckets.Default], + dependencies: [ + VideosPolicy.Default, + VideosRepo.Default, + Database.Default, + S3Buckets.Default, + ], }) {} diff --git a/packages/web-backend/src/Workflows.ts b/packages/web-backend/src/Workflows.ts new file mode 100644 index 000000000..33b6c8313 --- /dev/null +++ b/packages/web-backend/src/Workflows.ts @@ -0,0 +1,31 @@ +import { HttpApi, type HttpApiClient } from "@effect/platform"; +import * as Rpc from "@effect/rpc"; +import { WorkflowProxy, WorkflowProxyServer } from "@effect/workflow"; +import { Context, Layer } from "effect"; + +import { LoomImportVideo, LoomImportVideoLive } from "./Loom/index.ts"; + +export const Workflows = [LoomImportVideo] as const; +export const RpcGroup = WorkflowProxy.toRpcGroup(Workflows); +export const RpcSerialization = Rpc.RpcSerialization.layerJson; + +export class RpcClient extends Context.Tag("Workflows/RpcClient")< + RpcClient, + Rpc.RpcClient.RpcClient< + Rpc.RpcGroup.Rpcs, + Rpc.RpcClientError.RpcClientError + > +>() {} + +const ApiGroup = WorkflowProxy.toHttpApiGroup("workflows", Workflows); +export const Api = HttpApi.make("workflow-api").add(ApiGroup); + +export class HttpClient extends Context.Tag("Workflows/HttpClient")< + HttpClient, + HttpApiClient.Client +>() {} + +export const WorkflowsLayer = Layer.mergeAll(LoomImportVideoLive); + +export const WorkflowsRpcLayer = + WorkflowProxyServer.layerRpcHandlers(Workflows); diff --git a/packages/web-backend/src/index.ts b/packages/web-backend/src/index.ts index dde8f5b34..ebf421ec8 100644 --- a/packages/web-backend/src/index.ts +++ b/packages/web-backend/src/index.ts @@ -1,10 +1,10 @@ -import "server-only"; - -export * from "./Auth"; -export * from "./Database"; -export { Folders } from "./Folders"; -export * from "./Rpcs"; -export { S3Buckets } from "./S3Buckets"; -export { S3BucketAccess } from "./S3Buckets/S3BucketAccess"; -export { Videos } from "./Videos"; -export { VideosPolicy } from "./Videos/VideosPolicy"; +export * from "./Auth.ts"; +export * from "./Database.ts"; +export { Folders } from "./Folders/index.ts"; +export * from "./Loom/index.ts"; +export * from "./Rpcs.ts"; +export { S3Buckets } from "./S3Buckets/index.ts"; +export { S3BucketAccess } from "./S3Buckets/S3BucketAccess.ts"; +export { Videos } from "./Videos/index.ts"; +export { VideosPolicy } from "./Videos/VideosPolicy.ts"; +export * as Workflows from "./Workflows.ts"; diff --git a/packages/web-backend/tsconfig.json b/packages/web-backend/tsconfig.json index 38b814f6d..00c6c9934 100644 --- a/packages/web-backend/tsconfig.json +++ b/packages/web-backend/tsconfig.json @@ -7,9 +7,6 @@ "composite": true, "outDir": "dist", "noEmit": false, - "emitDeclarationOnly": true, - "strict": true, - "noUncheckedIndexedAccess": true - }, - "references": [{ "path": "../web-domain" }, { "path": "../database" }] + "emitDeclarationOnly": true + } } diff --git a/packages/web-domain/package.json b/packages/web-domain/package.json index 49ba61d0e..6c768bc6b 100644 --- a/packages/web-domain/package.json +++ b/packages/web-domain/package.json @@ -6,7 +6,8 @@ "type": "module", "dependencies": { "@effect/platform": "^0.90.1", - "@effect/rpc": "^0.68.3", - "effect": "^3.17.7" + "@effect/rpc": "^0.69.2", + "@effect/workflow": "^0.9.5", + "effect": "^3.17.13" } } diff --git a/packages/web-domain/src/Authentication.ts b/packages/web-domain/src/Authentication.ts index db92b874a..b4ef466d3 100644 --- a/packages/web-domain/src/Authentication.ts +++ b/packages/web-domain/src/Authentication.ts @@ -2,7 +2,7 @@ import { HttpApiError, HttpApiMiddleware } from "@effect/platform"; import { RpcMiddleware } from "@effect/rpc"; import { Context, Schema } from "effect"; -import { InternalError } from "./Errors"; +import { InternalError } from "./Errors.ts"; export class CurrentUser extends Context.Tag("CurrentUser")< CurrentUser, @@ -16,6 +16,7 @@ export class HttpAuthMiddleware extends HttpApiMiddleware.Tag("LoomApiError")( + "LoomApiError", + { cause: Schema.Unknown }, +) {} + +const LoomImportVideoError = Schema.Union( + // DatabaseError, + Video.NotFoundError, + // S3Error, + LoomApiError, +); + +export const LoomImportVideo = Workflow.make({ + name: "LoomImportVideo", + payload: { + cap: Schema.Struct({ + userId: Schema.String, + orgId: Schema.String, + }), + loom: Schema.Struct({ + userId: Schema.String, + orgId: Schema.String, + video: Schema.Struct({ + id: Schema.String, + name: Schema.String, + downloadUrl: Schema.String, + width: Schema.OptionFromNullOr(Schema.Number), + height: Schema.OptionFromNullOr(Schema.Number), + fps: Schema.OptionFromNullOr(Schema.Number), + durationSecs: Schema.OptionFromNullOr(Schema.Number), + }), + }), + attempt: Schema.optional(Schema.Number), + }, + error: LoomImportVideoError, + idempotencyKey: (p) => + `${p.cap.userId}-${p.loom.orgId}-${p.loom.video.id}-${p.attempt ?? 0}`, +}); diff --git a/packages/web-domain/src/Policy.ts b/packages/web-domain/src/Policy.ts index 750e0d751..d159eafd1 100644 --- a/packages/web-domain/src/Policy.ts +++ b/packages/web-domain/src/Policy.ts @@ -1,7 +1,8 @@ // shoutout https://lucas-barake.github.io/building-a-composable-policy-system/ import { Context, Data, Effect, type Option, Schema } from "effect"; -import { CurrentUser } from "./Authentication"; + +import { CurrentUser } from "./Authentication.ts"; export type Policy = Effect.Effect< void, diff --git a/packages/web-domain/src/Rpcs.ts b/packages/web-domain/src/Rpcs.ts index 747359980..6cb50ece2 100644 --- a/packages/web-domain/src/Rpcs.ts +++ b/packages/web-domain/src/Rpcs.ts @@ -1,4 +1,6 @@ -import { FolderRpcs } from "./Folder"; -import { VideoRpcs } from "./Video"; +import { RpcGroup } from "@effect/rpc"; -export const Rpcs = VideoRpcs.merge(FolderRpcs); +import { FolderRpcs } from "./Folder.ts"; +import { VideoRpcs } from "./Video.ts"; + +export const Rpcs = RpcGroup.make().merge(VideoRpcs, FolderRpcs); diff --git a/packages/web-domain/src/S3Bucket.ts b/packages/web-domain/src/S3Bucket.ts index e332f349b..327826e88 100644 --- a/packages/web-domain/src/S3Bucket.ts +++ b/packages/web-domain/src/S3Bucket.ts @@ -4,7 +4,7 @@ export const S3BucketId = Schema.String.pipe(Schema.brand("S3BucketId")); export type S3BucketId = typeof S3BucketId.Type; export class S3Bucket extends Schema.Class("S3Bucket")({ - id: Schema.String, + id: S3BucketId, ownerId: Schema.String, region: Schema.String, endpoint: Schema.OptionFromNullOr(Schema.String), @@ -13,4 +13,6 @@ export class S3Bucket extends Schema.Class("S3Bucket")({ secretAccessKey: Schema.String, }) {} +export const Workflows = [S3Bucket] as const; + export const decodeSync = Schema.decodeSync(S3Bucket); diff --git a/packages/web-domain/src/Video.ts b/packages/web-domain/src/Video.ts index b04317fe0..3c0f68845 100644 --- a/packages/web-domain/src/Video.ts +++ b/packages/web-domain/src/Video.ts @@ -1,10 +1,11 @@ import { Rpc, RpcGroup } from "@effect/rpc"; import { Context, Effect, Option, Schema } from "effect"; -import { RpcAuthMiddleware } from "./Authentication"; -import { InternalError } from "./Errors"; -import { FolderId } from "./Folder"; -import { PolicyDeniedError } from "./Policy"; -import { S3BucketId } from "./S3Bucket"; + +import { RpcAuthMiddleware } from "./Authentication.ts"; +import { InternalError } from "./Errors.ts"; +import { FolderId } from "./Folder.ts"; +import { PolicyDeniedError } from "./Policy.ts"; +import { S3BucketId } from "./S3Bucket.ts"; export const VideoId = Schema.String.pipe(Schema.brand("VideoId")); export type VideoId = typeof VideoId.Type; @@ -13,25 +14,48 @@ export type VideoId = typeof VideoId.Type; export class Video extends Schema.Class