From bcf4c9ab9226ad8a7b468a84827c0b241a531f8b Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 22 Oct 2025 19:20:38 +0800 Subject: [PATCH 01/11] use proper rpc endpoints --- apps/web/app/(org)/dashboard/caps/Caps.tsx | 10 +- .../caps/components/CapCard/CapCard.tsx | 7 +- .../dashboard/caps/components/Folder.tsx | 10 +- .../caps/components/NewFolderDialog.tsx | 19 ++- .../[id]/components/FolderVideosSection.tsx | 10 +- .../[id]/components/SubfolderDialog.tsx | 18 +-- .../app/(org)/dashboard/folder/[id]/page.tsx | 6 +- .../dashboard/settings/account/Settings.tsx | 117 ++++++----------- .../(org)/dashboard/settings/account/page.tsx | 2 + .../components/OrganizationIcon.tsx | 118 ++++++++---------- .../[spaceId]/folder/[folderId]/page.tsx | 6 +- .../(org)/dashboard/spaces/[spaceId]/page.tsx | 6 +- .../(org)/onboarding/components/Bottom.tsx | 15 ++- .../components/CustomDomainPage.tsx | 19 ++- .../onboarding/components/InviteTeamPage.tsx | 19 ++- .../components/OrganizationSetupPage.tsx | 19 ++- .../onboarding/components/WelcomePage.tsx | 14 +-- .../app/api/upload/[...route]/multipart.ts | 7 +- .../[videoId]/_components/ProgressCircle.tsx | 11 +- apps/web/components/FileInput.tsx | 3 - apps/web/lib/server.ts | 4 + apps/web/lib/use-signed-image-url.ts | 25 ++-- packages/database/schema.ts | 1 + packages/utils/package.json | 5 +- packages/web-backend/src/Auth.ts | 32 +++-- packages/web-backend/src/Database.ts | 4 +- .../web-backend/src/Folders/FoldersRpcs.ts | 2 +- packages/web-backend/src/Folders/index.ts | 11 +- packages/web-backend/src/IconImages/index.ts | 51 ++++++++ .../src/Organisations/OrganisationsRpcs.ts | 19 +++ .../web-backend/src/Organisations/index.ts | 63 ++++++++++ packages/web-backend/src/Rpcs.ts | 11 +- .../src/S3Buckets/S3BucketAccess.ts | 1 + .../src/S3Buckets/S3BucketClientProvider.ts | 1 + packages/web-backend/src/S3Buckets/index.ts | 20 +-- packages/web-backend/src/Users/UsersRpcs.ts | 113 ++--------------- packages/web-backend/src/Users/helpers.ts | 36 ------ packages/web-backend/src/Users/index.ts | 30 ++++- packages/web-backend/src/index.ts | 2 + packages/web-domain/src/Authentication.ts | 5 +- packages/web-domain/src/IconImage.ts | 45 +++++++ packages/web-domain/src/Organisation.ts | 25 ++++ packages/web-domain/src/Rpcs.ts | 8 +- packages/web-domain/src/User.ts | 61 +++------ packages/web-domain/src/index.ts | 1 + 45 files changed, 529 insertions(+), 483 deletions(-) create mode 100644 packages/web-backend/src/IconImages/index.ts create mode 100644 packages/web-backend/src/Organisations/OrganisationsRpcs.ts create mode 100644 packages/web-backend/src/Organisations/index.ts delete mode 100644 packages/web-backend/src/Users/helpers.ts create mode 100644 packages/web-domain/src/IconImage.ts diff --git a/apps/web/app/(org)/dashboard/caps/Caps.tsx b/apps/web/app/(org)/dashboard/caps/Caps.tsx index bc0ff17e6b..1ca7952e73 100644 --- a/apps/web/app/(org)/dashboard/caps/Caps.tsx +++ b/apps/web/app/(org)/dashboard/caps/Caps.tsx @@ -9,10 +9,8 @@ import { Effect, Exit } from "effect"; import { useRouter, useSearchParams } from "next/navigation"; import { useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; -import { useEffectMutation } from "@/lib/EffectRuntime"; +import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; import { useVideosAnalyticsQuery } from "@/lib/Queries/Analytics"; -import { AnalyticsRequest } from "@/lib/Requests/AnalyticsRequest"; -import { Rpc, withRpc } from "@/lib/Rpcs"; import { useDashboardContext } from "../Contexts"; import { NewFolderDialog, @@ -147,12 +145,12 @@ export const Caps = ({ }); }; + const rpc = useRpcClient(); + const { mutate: deleteCaps, isPending: isDeletingCaps } = useEffectMutation({ mutationFn: Effect.fn(function* (ids: Video.VideoId[]) { if (ids.length === 0) return; - const rpc = yield* Rpc; - const fiber = yield* Effect.gen(function* () { const results = yield* Effect.all( ids.map((id) => rpc.VideoDelete(id).pipe(Effect.exit)), @@ -203,7 +201,7 @@ export const Caps = ({ }); const { mutate: deleteCap, isPending: isDeletingCap } = useEffectMutation({ - mutationFn: (id: Video.VideoId) => withRpc((r) => r.VideoDelete(id)), + mutationFn: (id: Video.VideoId) => rpc.VideoDelete(id), onSuccess: () => { toast.success("Cap deleted successfully"); router.refresh(); diff --git a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx index a6fa4d6e6f..8540ba5477 100644 --- a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx @@ -41,7 +41,7 @@ import { type ImageLoadingStatus, VideoThumbnail, } from "@/components/VideoThumbnail"; -import { useEffectMutation } from "@/lib/EffectRuntime"; +import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; import { withRpc } from "@/lib/Rpcs"; import { usePublicEnv } from "@/utils/public-env"; import { PasswordDialog } from "../PasswordDialog"; @@ -133,11 +133,12 @@ export const CapCard = ({ const [confirmOpen, setConfirmOpen] = useState(false); const router = useRouter(); + const rpc = useRpcClient(); const downloadMutation = useEffectMutation({ mutationFn: () => Effect.gen(function* () { - const result = yield* withRpc((r) => r.VideoGetDownloadInfo(cap.id)); + const result = yield* rpc.VideoGetDownloadInfo(cap.id); const httpClient = yield* HttpClient.HttpClient; if (Option.isSome(result)) { const fetchResponse = yield* httpClient.get(result.value.downloadUrl); @@ -175,7 +176,7 @@ export const CapCard = ({ }); const duplicateMutation = useEffectMutation({ - mutationFn: () => withRpc((r) => r.VideoDuplicate(cap.id)), + mutationFn: () => rpc.VideoDuplicate(cap.id), onSuccess: () => { router.refresh(); }, diff --git a/apps/web/app/(org)/dashboard/caps/components/Folder.tsx b/apps/web/app/(org)/dashboard/caps/components/Folder.tsx index 239821e25a..becf3f852a 100644 --- a/apps/web/app/(org)/dashboard/caps/components/Folder.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/Folder.tsx @@ -9,8 +9,7 @@ import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { moveVideoToFolder } from "@/actions/folders/moveVideoToFolder"; -import { useEffectMutation } from "@/lib/EffectRuntime"; -import { withRpc } from "@/lib/Rpcs"; +import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; import { ConfirmationDialog } from "../../_components/ConfirmationDialog"; import { useDashboardContext, useTheme } from "../../Contexts"; import { registerDropTarget } from "../../folder/[id]/components/ClientCapCard"; @@ -70,8 +69,10 @@ const FolderCard = ({ }), }); + const rpc = useRpcClient(); + const deleteFolder = useEffectMutation({ - mutationFn: (id: Folder.FolderId) => withRpc((r) => r.FolderDelete(id)), + mutationFn: (id: Folder.FolderId) => rpc.FolderDelete(id), onSuccess: () => { router.refresh(); toast.success("Folder deleted successfully"); @@ -83,8 +84,7 @@ const FolderCard = ({ }); const updateFolder = useEffectMutation({ - mutationFn: (data: Folder.FolderUpdate) => - withRpc((r) => r.FolderUpdate(data)), + mutationFn: (data: Folder.FolderUpdate) => rpc.FolderUpdate(data), onSuccess: () => { toast.success("Folder name updated successfully"); router.refresh(); diff --git a/apps/web/app/(org)/dashboard/caps/components/NewFolderDialog.tsx b/apps/web/app/(org)/dashboard/caps/components/NewFolderDialog.tsx index 6565de9b7b..065f2e4ea9 100644 --- a/apps/web/app/(org)/dashboard/caps/components/NewFolderDialog.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/NewFolderDialog.tsx @@ -18,8 +18,7 @@ import { Option } from "effect"; import { useRouter } from "next/navigation"; import React, { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; -import { useEffectMutation } from "@/lib/EffectRuntime"; -import { withRpc } from "@/lib/Rpcs"; +import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; import { BlueFolder, type FolderHandle, @@ -101,16 +100,16 @@ export const NewFolderDialog: React.FC = ({ ), ); + const rpc = useRpcClient(); + const createFolder = useEffectMutation({ mutationFn: (data: { name: string; color: Folder.FolderColor }) => - withRpc((r) => - r.FolderCreate({ - name: data.name, - color: data.color, - spaceId: Option.fromNullable(spaceId), - parentId: Option.none(), - }), - ), + rpc.FolderCreate({ + name: data.name, + color: data.color, + spaceId: Option.fromNullable(spaceId), + parentId: Option.none(), + }), onSuccess: () => { setFolderName(""); setSelectedColor(null); diff --git a/apps/web/app/(org)/dashboard/folder/[id]/components/FolderVideosSection.tsx b/apps/web/app/(org)/dashboard/folder/[id]/components/FolderVideosSection.tsx index 443591ae44..5b4f40b0e1 100644 --- a/apps/web/app/(org)/dashboard/folder/[id]/components/FolderVideosSection.tsx +++ b/apps/web/app/(org)/dashboard/folder/[id]/components/FolderVideosSection.tsx @@ -6,10 +6,8 @@ import { useRouter } from "next/navigation"; import { useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { useDashboardContext } from "@/app/(org)/dashboard/Contexts"; -import { useEffectMutation } from "@/lib/EffectRuntime"; +import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; import { useVideosAnalyticsQuery } from "@/lib/Queries/Analytics"; -import { AnalyticsRequest } from "@/lib/Requests/AnalyticsRequest"; -import { Rpc, withRpc } from "@/lib/Rpcs"; import type { VideoData } from "../../../caps/Caps"; import { CapCard } from "../../../caps/components/CapCard/CapCard"; import { SelectedCapsBar } from "../../../caps/components/SelectedCapsBar"; @@ -31,12 +29,12 @@ export default function FolderVideosSection({ const [selectedCaps, setSelectedCaps] = useState([]); const previousCountRef = useRef(0); + const rpc = useRpcClient(); + const { mutate: deleteCaps, isPending: isDeletingCaps } = useEffectMutation({ mutationFn: Effect.fn(function* (ids: Video.VideoId[]) { if (ids.length === 0) return; - const rpc = yield* Rpc; - const fiber = yield* Effect.gen(function* () { const results = yield* Effect.all( ids.map((id) => rpc.VideoDelete(id).pipe(Effect.exit)), @@ -87,7 +85,7 @@ export default function FolderVideosSection({ }); const { mutate: deleteCap, isPending: isDeletingCap } = useEffectMutation({ - mutationFn: (id: Video.VideoId) => withRpc((r) => r.VideoDelete(id)), + mutationFn: (id: Video.VideoId) => rpc.VideoDelete(id), onSuccess: () => { toast.success("Cap deleted successfully"); router.refresh(); diff --git a/apps/web/app/(org)/dashboard/folder/[id]/components/SubfolderDialog.tsx b/apps/web/app/(org)/dashboard/folder/[id]/components/SubfolderDialog.tsx index 0c0561d560..3fdc67ba35 100644 --- a/apps/web/app/(org)/dashboard/folder/[id]/components/SubfolderDialog.tsx +++ b/apps/web/app/(org)/dashboard/folder/[id]/components/SubfolderDialog.tsx @@ -19,7 +19,7 @@ import { Option } from "effect"; import { useRouter } from "next/navigation"; import React, { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; -import { useEffectMutation } from "@/lib/EffectRuntime"; +import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; import { withRpc } from "@/lib/Rpcs"; import { useDashboardContext } from "../../../Contexts"; import { @@ -107,16 +107,16 @@ export const SubfolderDialog: React.FC = ({ ), ); + const rpc = useRpcClient(); + const createSubfolder = useEffectMutation({ mutationFn: (data: { name: string; color: Folder.FolderColor }) => - withRpc((r) => - r.FolderCreate({ - name: data.name, - color: data.color, - spaceId: Option.fromNullable(activeSpace?.id), - parentId: Option.some(parentFolderId), - }), - ), + rpc.FolderCreate({ + name: data.name, + color: data.color, + spaceId: Option.fromNullable(activeSpace?.id), + parentId: Option.some(parentFolderId), + }), onSuccess: () => { setFolderName(""); setSelectedColor(null); diff --git a/apps/web/app/(org)/dashboard/folder/[id]/page.tsx b/apps/web/app/(org)/dashboard/folder/[id]/page.tsx index 139af910f9..78e4862187 100644 --- a/apps/web/app/(org)/dashboard/folder/[id]/page.tsx +++ b/apps/web/app/(org)/dashboard/folder/[id]/page.tsx @@ -1,6 +1,7 @@ import { getCurrentUser } from "@cap/database/auth/session"; import { serverEnv } from "@cap/env"; -import { CurrentUser, Folder } from "@cap/web-domain"; +import { Folder } from "@cap/web-domain"; +import { makeCurrentUserLayer } from "@cap/web-backend"; import { Effect } from "effect"; import { notFound } from "next/navigation"; import { @@ -9,6 +10,7 @@ import { getVideosByFolderId, } from "@/lib/folder"; import { runPromise } from "@/lib/server"; + import { UploadCapButton } from "../../caps/components"; import FolderCard from "../../caps/components/Folder"; import { @@ -86,7 +88,7 @@ const FolderPage = async (props: PageProps<"/dashboard/folder/[id]">) => { /> ); - }).pipe(Effect.provideService(CurrentUser, user), runPromise); + }).pipe(Effect.provide(makeCurrentUserLayer(user)), runPromise); }; export default FolderPage; diff --git a/apps/web/app/(org)/dashboard/settings/account/Settings.tsx b/apps/web/app/(org)/dashboard/settings/account/Settings.tsx index 96d61bc111..1b63533c27 100644 --- a/apps/web/app/(org)/dashboard/settings/account/Settings.tsx +++ b/apps/web/app/(org)/dashboard/settings/account/Settings.tsx @@ -11,22 +11,18 @@ import { } from "@cap/ui"; import { Organisation } from "@cap/web-domain"; import { useMutation } from "@tanstack/react-query"; -import { Effect } from "effect"; +import { Effect, Option } from "effect"; import { useRouter } from "next/navigation"; import { useEffect, useId, useState } from "react"; import { toast } from "sonner"; import { SignedImageUrl } from "@/components/SignedImageUrl"; -import { useEffectMutation } from "@/lib/EffectRuntime"; +import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; import { withRpc } from "@/lib/Rpcs"; import { useDashboardContext } from "../../Contexts"; import { ProfileImage } from "./components/ProfileImage"; import { patchAccountSettings } from "./server"; -export const Settings = ({ - user, -}: { - user?: typeof users.$inferSelect | null; -}) => { +export const Settings = ({ user }: { user: typeof users.$inferSelect }) => { const router = useRouter(); const { organizationData } = useDashboardContext(); const [firstName, setFirstName] = useState(user?.name || ""); @@ -91,81 +87,50 @@ export const Settings = ({ return () => window.removeEventListener("beforeunload", handleBeforeUnload); }, [hasChanges]); - const uploadProfileImageMutation = useEffectMutation({ - mutationFn: (file: File) => { - if (!user?.id) { - return Effect.fail(new Error("User ID is required")); - } + const rpc = useRpcClient(); - return Effect.promise(() => file.arrayBuffer()).pipe( - Effect.map((arrayBuffer) => new Uint8Array(arrayBuffer)), - Effect.flatMap((data) => - withRpc((rpc) => - rpc.UploadImage({ - data, - contentType: file.type, - fileName: file.name, - type: "user" as const, - entityId: user.id, - oldImageKey: user.image, - }), - ), - ), - Effect.tap(() => - Effect.sync(() => { - setProfileImageOverride(undefined); - toast.success("Profile image updated successfully"); - router.refresh(); - }), - ), - Effect.catchAll((error) => - Effect.sync(() => { - console.error("Error uploading profile image:", error); - setProfileImageOverride(undefined); - toast.error( - error instanceof Error - ? error.message - : "Failed to upload profile image", - ); - throw error; - }), - ), + const uploadProfileImageMutation = useEffectMutation({ + mutationFn: Effect.fn(function* (file: File) { + const arrayBuffer = yield* Effect.promise(() => file.arrayBuffer()); + yield* rpc.UserUpdate({ + id: user.id, + image: Option.some({ + data: new Uint8Array(arrayBuffer), + contentType: file.type, + fileName: file.name, + }), + }); + }), + onSuccess: () => { + setProfileImageOverride(undefined); + toast.success("Profile image updated successfully"); + router.refresh(); + }, + onError: (error) => { + console.error("Error uploading profile image:", error); + setProfileImageOverride(undefined); + toast.error( + error instanceof Error + ? error.message + : "Failed to upload profile image", ); }, }); const removeProfileImageMutation = useEffectMutation({ - mutationFn: () => { - if (!user?.id) { - return Effect.fail(new Error("User ID is required")); - } - - return withRpc((rpc) => - rpc.RemoveImage({ - imageKey: user.image || "", - type: "user" as const, - entityId: user.id, - }), - ).pipe( - Effect.tap(() => - Effect.sync(() => { - setProfileImageOverride(null); - toast.success("Profile image removed"); - router.refresh(); - }), - ), - Effect.catchAll((error) => - Effect.sync(() => { - console.error("Error removing profile image:", error); - setProfileImageOverride(initialProfileImage); - toast.error( - error instanceof Error - ? error.message - : "Failed to remove profile image", - ); - throw error; - }), - ), + mutationFn: () => rpc.UserUpdate({ id: user.id, image: Option.none() }), + onSuccess: () => { + setProfileImageOverride(null); + toast.success("Profile image removed"); + router.refresh(); + }, + onError: (error) => { + console.error("Error removing profile image:", error); + setProfileImageOverride(initialProfileImage); + toast.error( + error instanceof Error + ? error.message + : "Failed to remove profile image", ); }, }); diff --git a/apps/web/app/(org)/dashboard/settings/account/page.tsx b/apps/web/app/(org)/dashboard/settings/account/page.tsx index daed483fd5..52955afc27 100644 --- a/apps/web/app/(org)/dashboard/settings/account/page.tsx +++ b/apps/web/app/(org)/dashboard/settings/account/page.tsx @@ -1,5 +1,6 @@ import { getCurrentUser } from "@cap/database/auth/session"; import type { Metadata } from "next"; +import { redirect } from "next/navigation"; import { Settings } from "./Settings"; export const metadata: Metadata = { @@ -8,6 +9,7 @@ export const metadata: Metadata = { export default async function SettingsPage() { const user = await getCurrentUser(); + if (!user) redirect("/login"); return ; } diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationIcon.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationIcon.tsx index 32f7947eed..bba51312e3 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationIcon.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationIcon.tsx @@ -1,14 +1,15 @@ "use client"; import { CardDescription, Label } from "@cap/ui"; -import { Effect } from "effect"; +import { Effect, Option } from "effect"; import { useRouter } from "next/navigation"; -import { useId, useState } from "react"; +import { useId } from "react"; import { toast } from "sonner"; import { FileInput } from "@/components/FileInput"; -import * as EffectRuntime from "@/lib/EffectRuntime"; import { withRpc } from "@/lib/Rpcs"; import { useDashboardContext } from "../../../Contexts"; +import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; +import { Organisation } from "@cap/web-domain"; export const OrganizationIcon = () => { const router = useRouter(); @@ -17,74 +18,55 @@ export const OrganizationIcon = () => { const organizationId = activeOrganization?.organization.id; const existingIconUrl = activeOrganization?.organization.iconUrl ?? null; - const [isUploading, setIsUploading] = useState(false); + const rpc = useRpcClient(); - const handleFileChange = async (file: File | null) => { - // If file is null, it means the user removed the file - if (!file || !organizationId) return; + const uploadIcon = useEffectMutation({ + mutationFn: Effect.fn(function* ({ + file, + organizationId, + }: { + organizationId: Organisation.OrganisationId; + file: File; + }) { + const arrayBuffer = yield* Effect.promise(() => file.arrayBuffer()); - // Upload the file to the server immediately - try { - setIsUploading(true); - - const arrayBuffer = await file.arrayBuffer(); - const data = new Uint8Array(arrayBuffer); - - await EffectRuntime.EffectRuntime.runPromise( - withRpc((rpc) => - rpc.UploadImage({ - data, - contentType: file.type, - fileName: file.name, - type: "organization" as const, - entityId: organizationId, - oldImageKey: existingIconUrl, - }), - ).pipe( - Effect.tap(() => - Effect.sync(() => { - toast.success("Organization icon updated successfully"); - router.refresh(); - }), - ), - ), - ); - } catch (error) { + yield* rpc.OrganisationUpdate({ + id: organizationId, + image: Option.some({ + contentType: file.type, + fileName: file.name, + data: new Uint8Array(arrayBuffer), + }), + }); + }), + onSuccess: () => { + toast.success("Organization icon updated successfully"); + router.refresh(); + }, + onError: (error) => { toast.error( error instanceof Error ? error.message : "Failed to upload icon", ); - } finally { - setIsUploading(false); - } - }; + }, + }); - const handleRemoveIcon = async () => { - if (!organizationId) return; - - try { - await EffectRuntime.EffectRuntime.runPromise( - withRpc((rpc) => - rpc.RemoveImage({ - imageKey: existingIconUrl || "", - type: "organization" as const, - entityId: organizationId, - }), - ).pipe( - Effect.tap(() => - Effect.sync(() => { - toast.success("Organization icon removed successfully"); - router.refresh(); - }), - ), - ), - ); - } catch (error) { + const removeIcon = useEffectMutation({ + mutationFn: (organizationId: Organisation.OrganisationId) => + rpc.OrganisationUpdate({ + id: organizationId, + image: Option.none(), + }), + onSuccess: () => { + toast.success("Organization icon removed successfully"); + router.refresh(); + }, + onError: (error) => { console.error("Error removing organization icon:", error); toast.error( error instanceof Error ? error.message : "Failed to remove icon", ); - } - }; + }, + }); return (
@@ -100,11 +82,17 @@ export const OrganizationIcon = () => { id={iconInputId} name="icon" type="organization" - onChange={handleFileChange} - disabled={isUploading} - isLoading={isUploading} + onChange={(file) => { + if (!file || !organizationId) return; + uploadIcon.mutate({ organizationId, file }); + }} + disabled={uploadIcon.isPending} + isLoading={uploadIcon.isPending} initialPreviewUrl={existingIconUrl} - onRemove={handleRemoveIcon} + onRemove={() => { + if (!organizationId) return; + removeIcon.mutate(organizationId); + }} maxFileSizeBytes={1 * 1024 * 1024} // 1MB />
diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/folder/[folderId]/page.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/folder/[folderId]/page.tsx index 655f78b9d5..c530083cf5 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/folder/[folderId]/page.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/folder/[folderId]/page.tsx @@ -1,7 +1,7 @@ import { getCurrentUser } from "@cap/database/auth/session"; import { serverEnv } from "@cap/env"; -import { Spaces } from "@cap/web-backend"; -import { CurrentUser, type Folder, Space } from "@cap/web-domain"; +import { makeCurrentUserLayer, Spaces } from "@cap/web-backend"; +import { type Folder, Space } from "@cap/web-domain"; import { Effect } from "effect"; import { notFound } from "next/navigation"; import FolderCard from "@/app/(org)/dashboard/caps/components/Folder"; @@ -109,7 +109,7 @@ const FolderPage = async (props: { ); }).pipe( Effect.catchTag("PolicyDenied", () => Effect.sync(() => notFound())), - Effect.provideService(CurrentUser, user), + Effect.provide(makeCurrentUserLayer(user)), runPromise, ); }; diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx index 750d527067..6dd9b3dbe4 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx @@ -12,8 +12,8 @@ import { videoUploads, } from "@cap/database/schema"; import { serverEnv } from "@cap/env"; -import { Spaces } from "@cap/web-backend"; -import { CurrentUser, type Organisation, Space, Video } from "@cap/web-domain"; +import { makeCurrentUserLayer, Spaces } from "@cap/web-backend"; +import { type Organisation, Space, Video } from "@cap/web-domain"; import { and, count, desc, eq, isNull, sql } from "drizzle-orm"; import { Effect } from "effect"; import type { Metadata } from "next"; @@ -100,7 +100,7 @@ export default async function SharedCapsPage(props: { s.getSpaceOrOrg(Space.SpaceId.make(params.spaceId)), ).pipe( Effect.catchTag("PolicyDenied", () => Effect.sync(() => notFound())), - Effect.provideService(CurrentUser, user), + Effect.provide(makeCurrentUserLayer(user)), runPromise, ); diff --git a/apps/web/app/(org)/onboarding/components/Bottom.tsx b/apps/web/app/(org)/onboarding/components/Bottom.tsx index c1a1eb5ed1..6e3070cb8e 100644 --- a/apps/web/app/(org)/onboarding/components/Bottom.tsx +++ b/apps/web/app/(org)/onboarding/components/Bottom.tsx @@ -5,20 +5,19 @@ import { useRouter } from "next/navigation"; import { signOut } from "next-auth/react"; import { startTransition } from "react"; import { toast } from "sonner"; -import { useEffectMutation } from "@/lib/EffectRuntime"; -import { withRpc } from "@/lib/Rpcs"; +import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; export const Bottom = () => { const router = useRouter(); + const rpc = useRpcClient(); + const skipToDashboard = useEffectMutation({ mutationFn: () => - withRpc((r) => - r.UserCompleteOnboardingStep({ - step: "skipToDashboard", - data: undefined, - }), - ), + rpc.UserCompleteOnboardingStep({ + step: "skipToDashboard", + data: undefined, + }), onSuccess: () => { startTransition(() => { router.push("/dashboard/caps"); diff --git a/apps/web/app/(org)/onboarding/components/CustomDomainPage.tsx b/apps/web/app/(org)/onboarding/components/CustomDomainPage.tsx index 8f7ec09d9d..2572ba8d45 100644 --- a/apps/web/app/(org)/onboarding/components/CustomDomainPage.tsx +++ b/apps/web/app/(org)/onboarding/components/CustomDomainPage.tsx @@ -6,26 +6,21 @@ import { useRouter } from "next/navigation"; import { startTransition, useState } from "react"; import { toast } from "sonner"; import { UpgradeModal } from "@/components/UpgradeModal"; -import { useEffectMutation } from "@/lib/EffectRuntime"; -import { withRpc } from "@/lib/Rpcs"; +import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; import { Base } from "./Base"; export function CustomDomainPage() { const router = useRouter(); + const rpc = useRpcClient(); const [showUpgradeModal, setShowUpgradeModal] = useState(false); const customDomainMutation = useEffectMutation({ - mutationFn: (redirect: boolean) => - Effect.gen(function* () { - yield* withRpc((r) => - r.UserCompleteOnboardingStep({ - step: "customDomain", - data: undefined, - }), - ); - return redirect; + mutationFn: (_redirect: boolean) => + rpc.UserCompleteOnboardingStep({ + step: "customDomain", + data: undefined, }), - onSuccess: (redirect: boolean) => { + onSuccess: (_, redirect) => { startTransition(() => { if (redirect) { router.push("/onboarding/invite-team"); diff --git a/apps/web/app/(org)/onboarding/components/InviteTeamPage.tsx b/apps/web/app/(org)/onboarding/components/InviteTeamPage.tsx index 6a3e8cbd9f..326b1673a1 100644 --- a/apps/web/app/(org)/onboarding/components/InviteTeamPage.tsx +++ b/apps/web/app/(org)/onboarding/components/InviteTeamPage.tsx @@ -11,7 +11,7 @@ import { useRouter } from "next/navigation"; import { type MouseEvent, startTransition, useId, useState } from "react"; import { toast } from "sonner"; import { useStripeContext } from "@/app/Layout/StripeContext"; -import { useEffectMutation } from "@/lib/EffectRuntime"; +import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; import { withRpc } from "@/lib/Rpcs"; import { homepageCopy } from "../../../../data/homepage-copy"; import { Base } from "./Base"; @@ -22,6 +22,8 @@ export function InviteTeamPage() { const [users, setUsers] = useState(1); const [isAnnually, setIsAnnually] = useState(true); const router = useRouter(); + const rpc = useRpcClient(); + const CAP_PRO_ANNUAL_PRICE_PER_USER = homepageCopy.pricing.pro.pricing.annual; const CAP_PRO_MONTHLY_PRICE_PER_USER = homepageCopy.pricing.pro.pricing.monthly; @@ -39,17 +41,12 @@ export function InviteTeamPage() { const decrementUsers = () => setUsers((n) => (n > 1 ? n - 1 : 1)); const inviteTeamMutation = useEffectMutation({ - mutationFn: (redirect: boolean) => - Effect.gen(function* () { - yield* withRpc((r) => - r.UserCompleteOnboardingStep({ - step: "inviteTeam", - data: undefined, - }), - ); - return redirect; + mutationFn: (_redirect: boolean) => + rpc.UserCompleteOnboardingStep({ + step: "inviteTeam", + data: undefined, }), - onSuccess: (redirect: boolean) => { + onSuccess: (_, redirect: boolean) => { startTransition(() => { if (redirect) { router.push("/onboarding/download"); diff --git a/apps/web/app/(org)/onboarding/components/OrganizationSetupPage.tsx b/apps/web/app/(org)/onboarding/components/OrganizationSetupPage.tsx index a34804fc8f..31a8a09e1b 100644 --- a/apps/web/app/(org)/onboarding/components/OrganizationSetupPage.tsx +++ b/apps/web/app/(org)/onboarding/components/OrganizationSetupPage.tsx @@ -8,7 +8,7 @@ import Image from "next/image"; import { useRouter } from "next/navigation"; import { startTransition, useRef, useState } from "react"; import { toast } from "sonner"; -import { useEffectMutation } from "@/lib/EffectRuntime"; +import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; import { withRpc } from "@/lib/Rpcs"; import { Base } from "./Base"; @@ -23,6 +23,7 @@ export function OrganizationSetupPage({ const [selectedFile, setSelectedFile] = useState(null); const fileInputRef = useRef(null); const router = useRouter(); + const rpc = useRpcClient(); const handleFileChange = () => { const file = fileInputRef.current?.files?.[0]; @@ -52,15 +53,13 @@ export function OrganizationSetupPage({ }; } - yield* withRpc((r) => - r.UserCompleteOnboardingStep({ - step: "organizationSetup", - data: { - organizationName: data.organizationName, - organizationIcon, - }, - }), - ); + yield* rpc.UserCompleteOnboardingStep({ + step: "organizationSetup", + data: { + organizationName: data.organizationName, + organizationIcon, + }, + }); }), onSuccess: () => { startTransition(() => { diff --git a/apps/web/app/(org)/onboarding/components/WelcomePage.tsx b/apps/web/app/(org)/onboarding/components/WelcomePage.tsx index 070bd97d56..5c3168c67b 100644 --- a/apps/web/app/(org)/onboarding/components/WelcomePage.tsx +++ b/apps/web/app/(org)/onboarding/components/WelcomePage.tsx @@ -4,23 +4,21 @@ import { Button, Input } from "@cap/ui"; import { useRouter } from "next/navigation"; import { startTransition, useState } from "react"; import { toast } from "sonner"; -import { useEffectMutation } from "@/lib/EffectRuntime"; -import { withRpc } from "@/lib/Rpcs"; +import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; import { Base } from "./Base"; export function WelcomePage() { const [firstName, setFirstName] = useState(""); const [lastName, setLastName] = useState(""); const router = useRouter(); + const rpc = useRpcClient(); const welcomeMutation = useEffectMutation({ mutationFn: (data: { firstName: string; lastName?: string }) => - withRpc((r) => - r.UserCompleteOnboardingStep({ - step: "welcome", - data, - }), - ), + rpc.UserCompleteOnboardingStep({ + step: "welcome", + data, + }), onSuccess: () => { startTransition(() => { router.push("/onboarding/organization-setup"); diff --git a/apps/web/app/api/upload/[...route]/multipart.ts b/apps/web/app/api/upload/[...route]/multipart.ts index e3d39487e3..1cbeb34e43 100644 --- a/apps/web/app/api/upload/[...route]/multipart.ts +++ b/apps/web/app/api/upload/[...route]/multipart.ts @@ -8,6 +8,8 @@ import { serverEnv } from "@cap/env"; import { AwsCredentials, Database, + makeCurrentUser, + makeCurrentUserLayer, provideOptionalAuth, S3Buckets, Videos, @@ -70,7 +72,6 @@ app.post( .where(eq(Db.videoUploads.videoId, video.value[0].id)), ); }).pipe( - provideOptionalAuth, Effect.tapError(Effect.logError), Effect.catchAll((e) => { if (e._tag === "VideoNotFoundError") @@ -80,7 +81,7 @@ app.post( c.json({ error: "Error initiating multipart upload" }, 500), ); }), - Effect.provideService(CurrentUser, user), + Effect.provide(makeCurrentUserLayer(user)), runPromise, ); if (resp) return resp; @@ -478,6 +479,6 @@ app.post( ); }), ); - }).pipe(Effect.provideService(CurrentUser, user), runPromise); + }).pipe(Effect.provide(makeCurrentUserLayer(user)), runPromise); }, ); diff --git a/apps/web/app/s/[videoId]/_components/ProgressCircle.tsx b/apps/web/app/s/[videoId]/_components/ProgressCircle.tsx index 0148e20306..ce5a6c3ae0 100644 --- a/apps/web/app/s/[videoId]/_components/ProgressCircle.tsx +++ b/apps/web/app/s/[videoId]/_components/ProgressCircle.tsx @@ -3,8 +3,7 @@ import type { Video } from "@cap/web-domain"; import clsx from "clsx"; import { Effect, Option } from "effect"; -import { useEffectQuery } from "@/lib/EffectRuntime"; -import { withRpc } from "@/lib/Rpcs"; +import { useEffectQuery, useRpcClient } from "@/lib/EffectRuntime"; type UploadProgress = | { status: "fetching" } @@ -27,12 +26,14 @@ export function useUploadProgress( videoId: Video.VideoId, enabled: boolean, ): UploadProgress | null { + const rpc = useRpcClient(); + const query = useEffectQuery({ queryKey: ["getUploadProgress", videoId], queryFn: () => - withRpc((rpc) => rpc.GetUploadProgress(videoId)).pipe( - Effect.map((v) => Option.getOrNull(v ?? Option.none())), - ), + rpc + .GetUploadProgress(videoId) + .pipe(Effect.map((v) => Option.getOrNull(v ?? Option.none()))), enabled, refetchInterval: (query) => { if (!enabled || !query.state.data) return false; diff --git a/apps/web/components/FileInput.tsx b/apps/web/components/FileInput.tsx index 25cdfe55d6..89c90b0b90 100644 --- a/apps/web/components/FileInput.tsx +++ b/apps/web/components/FileInput.tsx @@ -59,9 +59,6 @@ export const FileInput: React.FC = ({ ); const [isLocalPreview, setIsLocalPreview] = useState(false); - // Get signed URL for S3 keys - const { data: signedUrl } = useSignedImageUrl(previewUrl, type); - const previousPreviewRef = useRef<{ url: string | null; isLocal: boolean; diff --git a/apps/web/lib/server.ts b/apps/web/lib/server.ts index 58c85cf4ef..cbec875e5e 100644 --- a/apps/web/lib/server.ts +++ b/apps/web/lib/server.ts @@ -7,10 +7,12 @@ import { Database, Folders, HttpAuthMiddlewareLive, + Organisations, OrganisationsPolicy, S3Buckets, Spaces, SpacesPolicy, + Users, Videos, VideosPolicy, VideosRepo, @@ -105,6 +107,8 @@ export const Dependencies = Layer.mergeAll( SpacesPolicy.Default, OrganisationsPolicy.Default, Spaces.Default, + Users.Default, + Organisations.Default, AwsCredentials.Default, WorkflowRpcLive, layerTracer, diff --git a/apps/web/lib/use-signed-image-url.ts b/apps/web/lib/use-signed-image-url.ts index d5d3caf3a8..8c83bbd21b 100644 --- a/apps/web/lib/use-signed-image-url.ts +++ b/apps/web/lib/use-signed-image-url.ts @@ -1,6 +1,6 @@ +import { skipToken } from "@tanstack/react-query"; import { Effect } from "effect"; -import { useEffectQuery } from "@/lib/EffectRuntime"; -import { withRpc } from "./Rpcs"; +import { useEffectQuery, useRpcClient } from "@/lib/EffectRuntime"; /** * Hook to get signed URL for an S3 image key @@ -12,17 +12,22 @@ export function useSignedImageUrl( key: string | null | undefined, type: "user" | "organization", ) { + const rpc = useRpcClient(); + return useEffectQuery({ queryKey: ["signedImageUrl", key, type], - queryFn: () => { - if (!key) { - return Effect.succeed(key); - } + queryFn: key + ? () => { + if (!key) { + return Effect.succeed(key); + } - return withRpc((rpc) => rpc.GetSignedImageUrl({ key, type })) - .pipe(Effect.map((result) => result.url)) - .pipe(Effect.catchTag("InternalError", () => Effect.succeed(null))); - }, + return rpc.GetSignedImageUrl({ key, type }).pipe( + Effect.map((result) => result.url), + Effect.catchTag("InternalError", () => Effect.succeed(null)), + ); + } + : skipToken, enabled: !!key, }); } diff --git a/packages/database/schema.ts b/packages/database/schema.ts index dd23530621..85f6e2235f 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -1,6 +1,7 @@ import type { Comment, Folder, + IconImage, Organisation, S3Bucket, Space, diff --git a/packages/utils/package.json b/packages/utils/package.json index 4f201dfe51..cc9536c5f7 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,8 +1,9 @@ { "name": "@cap/utils", "type": "module", - "main": "./src/index.ts", - "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, "scripts": { "typecheck": "tsc -b", "build": "tsdown" diff --git a/packages/web-backend/src/Auth.ts b/packages/web-backend/src/Auth.ts index df27be4a36..f8836fe22d 100644 --- a/packages/web-backend/src/Auth.ts +++ b/packages/web-backend/src/Auth.ts @@ -4,6 +4,7 @@ import { CurrentUser, type DatabaseError, HttpAuthMiddleware, + type IconImage, } from "@cap/web-domain"; import { HttpApiError, HttpServerRequest } from "@effect/platform"; import * as Dz from "drizzle-orm"; @@ -34,6 +35,22 @@ export const getCurrentUser = Effect.gen(function* () { ); }).pipe(Effect.withSpan("getCurrentUser")); +export const makeCurrentUser = ( + user: Option.Option.Value>, +) => + CurrentUser.of({ + id: user.id, + email: user.email, + activeOrganizationId: user.activeOrganizationId, + iconUrlOrKey: Option.fromNullable( + user.image as IconImage.ImageUrlOrKey | null, + ), + }); + +export const makeCurrentUserLayer = ( + user: Option.Option.Value>, +) => Layer.succeed(CurrentUser, makeCurrentUser(user)); + export const HttpAuthMiddlewareLive = Layer.effect( HttpAuthMiddleware, Effect.gen(function* () { @@ -66,11 +83,7 @@ export const HttpAuthMiddlewareLive = Layer.effect( } return yield* user.pipe( - Option.map((user) => ({ - id: user.id, - email: user.email, - activeOrganizationId: user.activeOrganizationId, - })), + Option.map(makeCurrentUser), Effect.catchTag( "NoSuchElementException", () => new HttpApiError.Unauthorized(), @@ -98,16 +111,9 @@ export const provideOptionalAuth = ( yield* Effect.log(`Providing auth for user ${user.value.id}`); return yield* user.pipe( - Option.map((user) => - CurrentUser.context({ - id: user.id, - email: user.email, - activeOrganizationId: user.activeOrganizationId, - }), - ), Option.match({ onNone: () => app, - onSome: (ctx) => app.pipe(Effect.provide(ctx)), + onSome: (user) => app.pipe(Effect.provide(makeCurrentUserLayer(user))), }), ); }); diff --git a/packages/web-backend/src/Database.ts b/packages/web-backend/src/Database.ts index 9564098db3..25eacdccc1 100644 --- a/packages/web-backend/src/Database.ts +++ b/packages/web-backend/src/Database.ts @@ -2,10 +2,12 @@ import { db } from "@cap/database"; import { DatabaseError } from "@cap/web-domain"; import { Effect } from "effect"; +export type DbClient = ReturnType; + export class Database extends Effect.Service()("Database", { effect: Effect.gen(function* () { return { - use: (cb: (_: ReturnType) => Promise) => + use: (cb: (_: DbClient) => Promise) => Effect.tryPromise({ try: () => cb(db()), catch: (cause) => new DatabaseError({ cause }), diff --git a/packages/web-backend/src/Folders/FoldersRpcs.ts b/packages/web-backend/src/Folders/FoldersRpcs.ts index 46ed375299..7a1882ba16 100644 --- a/packages/web-backend/src/Folders/FoldersRpcs.ts +++ b/packages/web-backend/src/Folders/FoldersRpcs.ts @@ -30,7 +30,7 @@ export const FolderRpcsLive = Folder.FolderRpcs.toLayer( FolderUpdate: (data) => folders - .update(data.id, data) + .update(data) .pipe( Effect.catchTag( "DatabaseError", diff --git a/packages/web-backend/src/Folders/index.ts b/packages/web-backend/src/Folders/index.ts index 5c39338908..7adb23843a 100644 --- a/packages/web-backend/src/Folders/index.ts +++ b/packages/web-backend/src/Folders/index.ts @@ -129,12 +129,11 @@ export class Folders extends Effect.Service()("Folders", { }), update: Effect.fn("Folders.update")(function* ( - folderId: Folder.FolderId, data: Folder.FolderUpdate, ) { const folder = yield* (yield* repo - .getById(folderId) - .pipe(Policy.withPolicy(policy.canEdit(folderId)))).pipe( + .getById(data.id) + .pipe(Policy.withPolicy(policy.canEdit(data.id)))).pipe( Effect.catchTag( "NoSuchElementException", () => new Folder.NotFoundError(), @@ -145,7 +144,7 @@ export class Folders extends Effect.Service()("Folders", { if (data.parentId && Option.isSome(data.parentId)) { const parentId = data.parentId.value; // Check that we're not creating an immediate circular reference - if (parentId === folderId) + if (parentId === data.id) return yield* new Folder.RecursiveDefinitionError(); const parentFolder = yield* repo @@ -167,7 +166,7 @@ export class Folders extends Effect.Service()("Folders", { // Check for circular references in the folder hierarchy let currentParentId = parentFolder.parentId; while (currentParentId) { - if (currentParentId === folderId) + if (currentParentId === data.id) return yield* new Folder.RecursiveDefinitionError(); const parentId = currentParentId; @@ -192,7 +191,7 @@ export class Folders extends Effect.Service()("Folders", { ? Option.getOrNull(data.parentId) : undefined, }) - .where(Dz.eq(Db.folders.id, folderId)), + .where(Dz.eq(Db.folders.id, data.id)), ); }), }; diff --git a/packages/web-backend/src/IconImages/index.ts b/packages/web-backend/src/IconImages/index.ts new file mode 100644 index 0000000000..0fd53ba8c9 --- /dev/null +++ b/packages/web-backend/src/IconImages/index.ts @@ -0,0 +1,51 @@ +import { IconImage } from "@cap/web-domain"; +import { Effect, Option } from "effect"; + +import { Database, type DbClient } from "../Database"; +import { S3Buckets } from "../S3Buckets"; + +export class IconImages extends Effect.Service()("IconImages", { + effect: Effect.gen(function* () { + const s3Buckets = yield* S3Buckets; + const db = yield* Database; + + const [s3] = yield* s3Buckets.getBucketAccess(); + + const applyUpdate = Effect.fn("IconImages.applyUpdate")(function* (args: { + payload: IconImage.ImageUpdatePayload; + existing: Option.Option; + keyPrefix: string; + update: ( + db: DbClient, + urlOrKey: IconImage.ImageKey | null, + ) => Promise; + }) { + yield* Option.match(args.payload, { + onSome: Effect.fn(function* (image) { + const fileExtension = image.fileName.split(".").pop() || "jpg"; + const s3Key = IconImage.ImageKey.make( + `${args.keyPrefix}/${Date.now()}.${fileExtension}`, + ); + + yield* s3.putObject(s3Key, image.data, { + contentType: image.contentType, + }); + + yield* db.use((db) => args.update(db, s3Key)); + }), + onNone: () => db.use((db) => args.update(db, null)), + }); + + yield* args.existing.pipe( + Option.andThen((iconKeyOrUrl) => + IconImage.extractFileKey(iconKeyOrUrl, s3.isPathStyle), + ), + Option.map(s3.deleteObject), + Effect.transposeOption, + ); + }); + + return { applyUpdate }; + }), + dependencies: [S3Buckets.Default, Database.Default], +}) {} diff --git a/packages/web-backend/src/Organisations/OrganisationsRpcs.ts b/packages/web-backend/src/Organisations/OrganisationsRpcs.ts new file mode 100644 index 0000000000..330f74e641 --- /dev/null +++ b/packages/web-backend/src/Organisations/OrganisationsRpcs.ts @@ -0,0 +1,19 @@ +import { InternalError, Organisation } from "@cap/web-domain"; +import { Effect } from "effect"; +import { Organisations } from "."; + +export const OrganisationsRpcsLive = Organisation.OrganisationRpcs.toLayer( + Effect.gen(function* () { + const orgs = yield* Organisations; + + return { + OrganisationUpdate: (data) => + orgs.update(data).pipe( + Effect.catchTags({ + DatabaseError: () => new InternalError({ type: "database" }), + S3Error: () => new InternalError({ type: "s3" }), + }), + ), + }; + }), +); diff --git a/packages/web-backend/src/Organisations/index.ts b/packages/web-backend/src/Organisations/index.ts new file mode 100644 index 0000000000..1c0ef0f009 --- /dev/null +++ b/packages/web-backend/src/Organisations/index.ts @@ -0,0 +1,63 @@ +import * as Db from "@cap/database/schema"; +import { type IconImage, Organisation, Policy } from "@cap/web-domain"; +import * as Dz from "drizzle-orm"; +import { Array, Effect, Option } from "effect"; + +import { Database } from "../Database"; +import { IconImages } from "../IconImages"; +import { S3Buckets } from "../S3Buckets"; +import { OrganisationsPolicy } from "./OrganisationsPolicy"; + +export class Organisations extends Effect.Service()( + "Organisations", + { + effect: Effect.gen(function* () { + const db = yield* Database; + const policy = yield* OrganisationsPolicy; + const iconImages = yield* IconImages; + + const update = Effect.fn("Organisations.update")(function* ( + payload: Organisation.OrganisationUpdate, + ) { + const organisation = yield* db + .use((db) => + db + .select() + .from(Db.organizations) + .where(Dz.eq(Db.organizations.id, payload.id)), + ) + .pipe( + Effect.flatMap(Array.get(0)), + Effect.catchTag( + "NoSuchElementException", + () => new Organisation.NotFoundError(), + ), + Policy.withPolicy(policy.isOwner(payload.id)), + ); + + if (payload.image) { + yield* iconImages.applyUpdate({ + payload: payload.image, + existing: Option.fromNullable( + organisation.iconUrl as IconImage.ImageUrlOrKey | null, + ), + keyPrefix: `organisations/${organisation.id}`, + update: (db, urlOrKey) => + db + .update(Db.organizations) + .set({ iconUrl: urlOrKey }) + .where(Dz.eq(Db.organizations.id, organisation.id)), + }); + } + }); + + return { update }; + }), + dependencies: [ + IconImages.Default, + S3Buckets.Default, + Database.Default, + OrganisationsPolicy.Default, + ], + }, +) {} diff --git a/packages/web-backend/src/Rpcs.ts b/packages/web-backend/src/Rpcs.ts index 10bd494042..0b4fb06328 100644 --- a/packages/web-backend/src/Rpcs.ts +++ b/packages/web-backend/src/Rpcs.ts @@ -5,9 +5,10 @@ import { } from "@cap/web-domain"; import { Effect, Layer, Option } from "effect"; -import { getCurrentUser } from "./Auth.ts"; +import { getCurrentUser, makeCurrentUser } from "./Auth.ts"; import { Database } from "./Database.ts"; import { FolderRpcsLive } from "./Folders/FoldersRpcs.ts"; +import { OrganisationsRpcsLive } from "./Organisations/OrganisationsRpcs.ts"; import { UsersRpcsLive } from "./Users/UsersRpcs.ts"; import { VideosRpcsLive } from "./Videos/VideosRpcs.ts"; @@ -15,6 +16,7 @@ export const RpcsLive = Layer.mergeAll( VideosRpcsLive, FolderRpcsLive, UsersRpcsLive, + OrganisationsRpcsLive, ); export const RpcAuthMiddlewareLive = Layer.effect( @@ -29,12 +31,7 @@ export const RpcAuthMiddlewareLive = Layer.effect( Effect.flatMap( Option.match({ onNone: () => new UnauthenticatedError(), - onSome: (user) => - Effect.succeed({ - id: user.id, - email: user.email, - activeOrganizationId: user.activeOrganizationId, - }), + onSome: (user) => Effect.succeed(makeCurrentUser(user)), }), ), ), diff --git a/packages/web-backend/src/S3Buckets/S3BucketAccess.ts b/packages/web-backend/src/S3Buckets/S3BucketAccess.ts index 2c7cd698db..5338c7dbeb 100644 --- a/packages/web-backend/src/S3Buckets/S3BucketAccess.ts +++ b/packages/web-backend/src/S3Buckets/S3BucketAccess.ts @@ -38,6 +38,7 @@ export const createS3BucketAccess = Effect.gen(function* () { const provider = yield* S3BucketClientProvider; return { bucketName: provider.bucket, + isPathStyle: provider.isPathStyle, getSignedObjectUrl: (key: string) => wrapS3Promise( provider.getPublic.pipe( diff --git a/packages/web-backend/src/S3Buckets/S3BucketClientProvider.ts b/packages/web-backend/src/S3Buckets/S3BucketClientProvider.ts index 37c612a937..35871b972b 100644 --- a/packages/web-backend/src/S3Buckets/S3BucketClientProvider.ts +++ b/packages/web-backend/src/S3Buckets/S3BucketClientProvider.ts @@ -9,5 +9,6 @@ export class S3BucketClientProvider extends Context.Tag( getInternal: Effect.Effect; getPublic: Effect.Effect; bucket: string; + isPathStyle: boolean; } >() {} diff --git a/packages/web-backend/src/S3Buckets/index.ts b/packages/web-backend/src/S3Buckets/index.ts index 240e3c8a5b..0634bd2f0b 100644 --- a/packages/web-backend/src/S3Buckets/index.ts +++ b/packages/web-backend/src/S3Buckets/index.ts @@ -45,6 +45,9 @@ export class S3Buckets extends Effect.Service()("S3Buckets", { requestStreamBufferSize: 16 * 1024, }); + const endpointIsPathStyle = (endpoint: string) => + endpoint.endsWith("s3.amazonaws.com"); + const createBucketClient = async (bucket: S3Bucket.S3Bucket) => { const endpoint = await (() => { const v = bucket.endpoint.pipe(Option.getOrUndefined); @@ -61,7 +64,7 @@ export class S3Buckets extends Effect.Service()("S3Buckets", { }, forcePathStyle: Option.fromNullable(endpoint).pipe( - Option.map((e) => e.endsWith("s3.amazonaws.com")), + Option.map(endpointIsPathStyle), Option.getOrNull, ) ?? true, useArnRegion: false, @@ -126,6 +129,7 @@ export class S3Buckets extends Effect.Service()("S3Buckets", { getInternal: Effect.succeed(createDefaultClient(true)), getPublic: Effect.succeed(createDefaultClient(false)), bucket: defaultConfigs.bucket, + isPathStyle: defaultConfigs.forcePathStyle, }); return Option.match(cloudfrontBucketAccess, { @@ -139,12 +143,14 @@ export class S3Buckets extends Effect.Service()("S3Buckets", { decrypt(customBucket.name), ); + const client = yield* Effect.promise(() => + createBucketClient(customBucket), + ); const provider = Layer.succeed(S3BucketClientProvider, { - getInternal: Effect.promise(() => - createBucketClient(customBucket), - ), - getPublic: Effect.promise(() => createBucketClient(customBucket)), + getInternal: Effect.succeed(client), + getPublic: Effect.succeed(client), bucket, + isPathStyle: client.config.forcePathStyle ?? true, }); return yield* createS3BucketAccess.pipe(Effect.provide(provider)); @@ -156,9 +162,9 @@ export class S3Buckets extends Effect.Service()("S3Buckets", { return { getBucketAccess: Effect.fn("S3Buckets.getBucketAccess")(function* ( - bucketId: Option.Option, + bucketId?: Option.Option, ) { - const customBucket = yield* bucketId.pipe( + const customBucket = yield* (bucketId ?? Option.none()).pipe( Option.map(repo.getById), Effect.transposeOption, Effect.map(Option.flatten), diff --git a/packages/web-backend/src/Users/UsersRpcs.ts b/packages/web-backend/src/Users/UsersRpcs.ts index f041827183..6b361c7ee2 100644 --- a/packages/web-backend/src/Users/UsersRpcs.ts +++ b/packages/web-backend/src/Users/UsersRpcs.ts @@ -1,17 +1,16 @@ -import * as Db from "@cap/database/schema"; -import { InternalError, Organisation, User } from "@cap/web-domain"; -import * as Dz from "drizzle-orm"; +import { InternalError, User } from "@cap/web-domain"; import { Effect, Layer, Option } from "effect"; import { Database } from "../Database"; import { S3Buckets } from "../S3Buckets"; -import { parseImageKey } from "./helpers"; +import { Users } from "."; import { UsersOnboarding } from "./UsersOnboarding"; export const UsersRpcsLive = User.UserRpcs.toLayer( Effect.gen(function* () { const onboarding = yield* UsersOnboarding; const s3Buckets = yield* S3Buckets; - const db = yield* Database; + const users = yield* Users; + return { UserCompleteOnboardingStep: (payload) => Effect.gen(function* () { @@ -60,104 +59,12 @@ export const UsersRpcsLive = User.UserRpcs.toLayer( ), Effect.catchAll(() => new InternalError({ type: "unknown" })), ), - UploadImage: (payload) => - Effect.gen(function* () { - const oldS3KeyOption = yield* parseImageKey( - payload.oldImageKey, - payload.type, - ); - const [bucket] = yield* s3Buckets.getBucketAccess(Option.none()); - - // Delete old image if it exists and is valid - if (Option.isSome(oldS3KeyOption)) { - yield* bucket.deleteObject(oldS3KeyOption.value); - } - - // Generate new S3 key - const timestamp = Date.now(); - const fileExtension = payload.fileName.split(".").pop() || "jpg"; - const s3Key = `${payload.type}s/${payload.entityId}/${timestamp}.${fileExtension}`; - - // Upload new image - const buffer = Buffer.from(payload.data); - yield* bucket.putObject(s3Key, buffer, { - contentType: payload.contentType, - }); - - // Update database - if (payload.type === "user") { - yield* db.use((db) => - db - .update(Db.users) - .set({ image: s3Key }) - .where(Dz.eq(Db.users.id, User.UserId.make(payload.entityId))), - ); - } else { - yield* db.use((db) => - db - .update(Db.organizations) - .set({ iconUrl: s3Key }) - .where( - Dz.eq( - Db.organizations.id, - Organisation.OrganisationId.make(payload.entityId), - ), - ), - ); - } - - return { key: s3Key }; - }).pipe( - Effect.catchTag("S3Error", () => new InternalError({ type: "s3" })), - Effect.catchTag( - "DatabaseError", - () => new InternalError({ type: "database" }), - ), - Effect.catchAll(() => new InternalError({ type: "unknown" })), - ), - RemoveImage: (payload) => - Effect.gen(function* () { - const s3KeyOption = yield* parseImageKey( - payload.imageKey, - payload.type, - ); - const [bucket] = yield* s3Buckets.getBucketAccess(Option.none()); - - // Only delete if we have a valid S3 key - if (Option.isSome(s3KeyOption)) { - yield* bucket.deleteObject(s3KeyOption.value); - } - - // Update database - if (payload.type === "user") { - yield* db.use((db) => - db - .update(Db.users) - .set({ image: null }) - .where(Dz.eq(Db.users.id, User.UserId.make(payload.entityId))), - ); - } else { - yield* db.use((db) => - db - .update(Db.organizations) - .set({ iconUrl: null }) - .where( - Dz.eq( - Db.organizations.id, - Organisation.OrganisationId.make(payload.entityId), - ), - ), - ); - } - - return { success: true as const }; - }).pipe( - Effect.catchTag("S3Error", () => new InternalError({ type: "s3" })), - Effect.catchTag( - "DatabaseError", - () => new InternalError({ type: "database" }), - ), - Effect.catchAll(() => new InternalError({ type: "unknown" })), + UserUpdate: (data) => + users.update(data).pipe( + Effect.catchTags({ + DatabaseError: () => new InternalError({ type: "database" }), + S3Error: () => new InternalError({ type: "s3" }), + }), ), }; }), diff --git a/packages/web-backend/src/Users/helpers.ts b/packages/web-backend/src/Users/helpers.ts deleted file mode 100644 index 4bb71240cf..0000000000 --- a/packages/web-backend/src/Users/helpers.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { InternalError } from "@cap/web-domain"; -import { Effect, Option } from "effect"; -import * as path from "path"; - -export const parseImageKey = ( - imageKey: string | null | undefined, - expectedType: "user" | "organization", -): Effect.Effect, InternalError> => - Effect.gen(function* () { - // Return None if no image key provided - if (!imageKey || imageKey.trim() === "") { - return Option.none(); - } - - let s3Key = imageKey; - if (imageKey.startsWith("http://") || imageKey.startsWith("https://")) { - const url = new URL(imageKey); - const raw = url.pathname.startsWith("/") - ? url.pathname.slice(1) - : url.pathname; - const decoded = decodeURIComponent(raw); - const normalized = path.posix.normalize(decoded); - if (normalized.includes("..")) { - return yield* Effect.fail(new InternalError({ type: "unknown" })); - } - s3Key = normalized; - } - - const expectedPrefix = - expectedType === "user" ? "users/" : "organizations/"; - if (!s3Key.startsWith(expectedPrefix)) { - return Option.none(); - } - - return Option.some(s3Key); - }); diff --git a/packages/web-backend/src/Users/index.ts b/packages/web-backend/src/Users/index.ts index a31bcd9195..53cdebbc70 100644 --- a/packages/web-backend/src/Users/index.ts +++ b/packages/web-backend/src/Users/index.ts @@ -1,10 +1,34 @@ +import * as Db from "@cap/database/schema"; +import { CurrentUser, type User } from "@cap/web-domain"; +import * as Dz from "drizzle-orm"; import { Effect } from "effect"; -import { Database } from "../Database"; + +import { IconImages } from "../IconImages"; export class Users extends Effect.Service()("Users", { effect: Effect.gen(function* () { - yield* Database; + const iconImages = yield* IconImages; + + const update = Effect.fn("Users.update")(function* ( + payload: User.UserUpdate, + ) { + const user = yield* CurrentUser; + + if (payload.image) { + yield* iconImages.applyUpdate({ + payload: payload.image, + existing: user.iconUrlOrKey, + keyPrefix: `users/${user.id}`, + update: (db, urlOrKey) => + db + .update(Db.users) + .set({ image: urlOrKey }) + .where(Dz.eq(Db.users.id, user.id)), + }); + } + }); - return {}; + return { update }; }), + dependencies: [IconImages.Default], }) {} diff --git a/packages/web-backend/src/index.ts b/packages/web-backend/src/index.ts index 2a5e1f0037..76da0e7244 100644 --- a/packages/web-backend/src/index.ts +++ b/packages/web-backend/src/index.ts @@ -4,11 +4,13 @@ export * from "./Database.ts"; export { Folders } from "./Folders/index.ts"; export { HttpLive } from "./Http/Live.ts"; export * from "./Loom/index.ts"; +export { Organisations } from "./Organisations/index.ts"; export { OrganisationsPolicy } from "./Organisations/OrganisationsPolicy.ts"; export * from "./Rpcs.ts"; export { S3Buckets } from "./S3Buckets/index.ts"; export { Spaces } from "./Spaces/index.ts"; export { SpacesPolicy } from "./Spaces/SpacesPolicy.ts"; +export { Users } from "./Users/index.ts"; export { Videos } from "./Videos/index.ts"; export { VideosPolicy } from "./Videos/VideosPolicy.ts"; export { VideosRepo } from "./Videos/VideosRepo.ts"; diff --git a/packages/web-domain/src/Authentication.ts b/packages/web-domain/src/Authentication.ts index 66d1578281..dfe39d6251 100644 --- a/packages/web-domain/src/Authentication.ts +++ b/packages/web-domain/src/Authentication.ts @@ -1,9 +1,9 @@ import { HttpApiError, HttpApiMiddleware } from "@effect/platform"; import { RpcMiddleware } from "@effect/rpc"; -import { Context, Schema } from "effect"; +import { Context, type Option, Schema } from "effect"; import { InternalError } from "./Errors.ts"; -import type { Organisation, User } from "./index.ts"; +import type { IconImage, Organisation, User } from "./index.ts"; export class CurrentUser extends Context.Tag("CurrentUser")< CurrentUser, @@ -11,6 +11,7 @@ export class CurrentUser extends Context.Tag("CurrentUser")< id: User.UserId; email: string; activeOrganizationId: Organisation.OrganisationId; + iconUrlOrKey: Option.Option; } >() {} diff --git a/packages/web-domain/src/IconImage.ts b/packages/web-domain/src/IconImage.ts new file mode 100644 index 0000000000..a84300ef52 --- /dev/null +++ b/packages/web-domain/src/IconImage.ts @@ -0,0 +1,45 @@ +import { Option, Schema } from "effect"; + +export const ImageKey = Schema.String.pipe(Schema.brand("ImageKey")); +export type ImageKey = typeof ImageKey.Type; + +export const ImageUrl = Schema.String.pipe(Schema.brand("ImageUrl")); +export type ImageUrl = typeof ImageUrl.Type; + +export type ImageUrlOrKey = ImageUrl | ImageKey; + +/** + * Extracts an S3 file key from an image key or URL. + * In some cases we can have image URLs from Google, so these need to be filtered out. + */ +export const extractFileKey = ( + iconKeyOrURL: ImageUrlOrKey, + urlIsPathStyle: boolean, +): Option.Option => { + try { + const { pathname, origin } = new URL(iconKeyOrURL); + + if (origin === "https://lh3.googleusercontent.com") return Option.none(); + + let key = pathname.slice(1); + + if (urlIsPathStyle) { + key = key.split("/").slice(1).join("/"); + } + + if (!key.trim()) return Option.none(); + + return Option.some(ImageKey.make(key)); + } catch { + return Option.some(ImageKey.make(iconKeyOrURL)); + } +}; + +export const ImageUpdatePayload = Schema.Option( + Schema.Struct({ + data: Schema.Uint8ArrayFromBase64, + contentType: Schema.String, + fileName: Schema.String, + }), +); +export type ImageUpdatePayload = typeof ImageUpdatePayload.Type; diff --git a/packages/web-domain/src/Organisation.ts b/packages/web-domain/src/Organisation.ts index c2c74e8257..140928f0ab 100644 --- a/packages/web-domain/src/Organisation.ts +++ b/packages/web-domain/src/Organisation.ts @@ -1,4 +1,16 @@ +import { HttpApiSchema } from "@effect/platform"; +import { Rpc, RpcGroup } from "@effect/rpc"; import { Schema } from "effect"; +import { RpcAuthMiddleware } from "./Authentication"; +import { InternalError } from "./Errors"; +import { ImageUpdatePayload } from "./IconImage"; +import { PolicyDeniedError } from "./Policy"; + +export class NotFoundError extends Schema.TaggedError()( + "OrgNotFoundError", + {}, + HttpApiSchema.annotations({ status: 404 }), +) {} export const OrganisationId = Schema.String.pipe( Schema.brand("OrganisationId"), @@ -9,3 +21,16 @@ export class Organisation extends Schema.Class("Organisation")({ id: OrganisationId, name: Schema.String, }) {} + +export const OrganisationUpdate = Schema.Struct({ + id: OrganisationId, + image: Schema.optional(ImageUpdatePayload), +}); +export type OrganisationUpdate = Schema.Schema.Type; + +export class OrganisationRpcs extends RpcGroup.make( + Rpc.make("OrganisationUpdate", { + payload: OrganisationUpdate, + error: Schema.Union(InternalError, PolicyDeniedError, NotFoundError), + }).middleware(RpcAuthMiddleware), +) {} diff --git a/packages/web-domain/src/Rpcs.ts b/packages/web-domain/src/Rpcs.ts index 1d890286d6..f4106430c1 100644 --- a/packages/web-domain/src/Rpcs.ts +++ b/packages/web-domain/src/Rpcs.ts @@ -1,7 +1,13 @@ import { RpcGroup } from "@effect/rpc"; import { FolderRpcs } from "./Folder.ts"; +import { OrganisationRpcs } from "./Organisation.ts"; import { UserRpcs } from "./User.ts"; import { VideoRpcs } from "./Video.ts"; -export const Rpcs = RpcGroup.make().merge(VideoRpcs, FolderRpcs, UserRpcs); +export const Rpcs = RpcGroup.make().merge( + VideoRpcs, + FolderRpcs, + UserRpcs, + OrganisationRpcs, +); diff --git a/packages/web-domain/src/User.ts b/packages/web-domain/src/User.ts index 5b88c828ee..03d67254ab 100644 --- a/packages/web-domain/src/User.ts +++ b/packages/web-domain/src/User.ts @@ -3,11 +3,19 @@ import { Schema } from "effect"; import { RpcAuthMiddleware } from "./Authentication.ts"; import { InternalError } from "./Errors.ts"; +import { ImageUpdatePayload } from "./IconImage.ts"; import { OrganisationId } from "./Organisation.ts"; +import { PolicyDeniedError } from "./Policy.ts"; export const UserId = Schema.String.pipe(Schema.brand("UserId")); export type UserId = typeof UserId.Type; +export const UserUpdate = Schema.Struct({ + id: UserId, + image: Schema.optional(ImageUpdatePayload), +}); +export type UserUpdate = Schema.Schema.Type; + export const OnboardingStepPayload = Schema.Union( Schema.Struct({ step: Schema.Literal("welcome"), @@ -68,38 +76,6 @@ export const OnboardingStepResult = Schema.Union( }), ); -export const GetSignedImageUrlPayload = Schema.Struct({ - key: Schema.String, - type: Schema.Literal("user", "organization"), -}); - -export const GetSignedImageUrlResult = Schema.Struct({ - url: Schema.String, -}); - -export const UploadImagePayload = Schema.Struct({ - data: Schema.Uint8Array, - contentType: Schema.String, - fileName: Schema.String, - type: Schema.Literal("user", "organization"), - entityId: Schema.String, - oldImageKey: Schema.optional(Schema.NullOr(Schema.String)), -}); - -export const UploadImageResult = Schema.Struct({ - key: Schema.String, -}); - -export const RemoveImagePayload = Schema.Struct({ - imageKey: Schema.String, - type: Schema.Literal("user", "organization"), - entityId: Schema.String, -}); - -export const RemoveImageResult = Schema.Struct({ - success: Schema.Literal(true), -}); - export class UserRpcs extends RpcGroup.make( Rpc.make("UserCompleteOnboardingStep", { payload: OnboardingStepPayload, @@ -107,18 +83,17 @@ export class UserRpcs extends RpcGroup.make( error: InternalError, }).middleware(RpcAuthMiddleware), Rpc.make("GetSignedImageUrl", { - payload: GetSignedImageUrlPayload, - success: GetSignedImageUrlResult, - error: InternalError, - }).middleware(RpcAuthMiddleware), - Rpc.make("UploadImage", { - payload: UploadImagePayload, - success: UploadImageResult, + payload: Schema.Struct({ + key: Schema.String, + type: Schema.Literal("user", "organization"), + }), + success: Schema.Struct({ + url: Schema.String, + }), error: InternalError, }).middleware(RpcAuthMiddleware), - Rpc.make("RemoveImage", { - payload: RemoveImagePayload, - success: RemoveImageResult, - error: InternalError, + Rpc.make("UserUpdate", { + payload: UserUpdate, + error: Schema.Union(InternalError, PolicyDeniedError), }).middleware(RpcAuthMiddleware), ) {} diff --git a/packages/web-domain/src/index.ts b/packages/web-domain/src/index.ts index e3ca891547..435aa5984a 100644 --- a/packages/web-domain/src/index.ts +++ b/packages/web-domain/src/index.ts @@ -5,6 +5,7 @@ export * from "./Errors.ts"; export * from "./Errors.ts"; export * as Folder from "./Folder.ts"; export * as Http from "./Http/index.ts"; +export * as IconImage from "./IconImage.ts"; export * as Loom from "./Loom.ts"; export * as Organisation from "./Organisation.ts"; export * from "./Organisation.ts"; From 70baf78f458485934f81f0ad2a7e93c7d48e2bd5 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 22 Oct 2025 19:46:23 +0800 Subject: [PATCH 02/11] fix staging access from preview branches --- infra/sst.config.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/infra/sst.config.ts b/infra/sst.config.ts index 6747adb5ca..0437ff74b3 100644 --- a/infra/sst.config.ts +++ b/infra/sst.config.ts @@ -135,6 +135,8 @@ export default $config({ }; })(); + const oidcSub = (environment: "production" | "preview" | "staging") => + `owner:${VERCEL_TEAM_SLUG}:project:${VERCEL_PROJECT_NAME}:environment:${environment}`; const vercelAwsAccessRole = new aws.iam.Role("VercelAWSAccessRole", { assumeRolePolicy: { Version: "2012-10-17", @@ -150,9 +152,10 @@ export default $config({ [`${oidc.url}:aud`]: oidc.aud, }, StringLike: { - [`${oidc.url}:sub`]: [ - `owner:${VERCEL_TEAM_SLUG}:project:*:environment:${stage.variant === "git-branch" ? "preview" : stage.variant}`, - ], + [`${oidc.url}:sub`]: + stage.variant === "production" + ? [oidcSub("production")] + : [oidcSub("preview"), oidcSub("staging")], }, }, }, From 73c5ca6f03c6c118f66ae7f9b29587f5eeba757e Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 23 Oct 2025 01:27:38 +0800 Subject: [PATCH 03/11] refactor all uploaded icon image handling --- .../actions/organization/upload-space-icon.ts | 20 +- apps/web/app/(org)/dashboard/caps/page.tsx | 88 ++++-- .../web/app/(org)/dashboard/dashboard-data.ts | 251 +++++++++++------- apps/web/app/(site)/Navbar.tsx | 8 +- apps/web/app/Layout/AuthContext.tsx | 18 +- apps/web/app/Layout/Intercom/Client.tsx | 6 +- apps/web/app/Layout/PosthogIdentify.tsx | 4 +- apps/web/app/layout.tsx | 174 ++++++------ apps/web/app/s/[videoId]/Share.tsx | 12 +- .../[videoId]/_components/CapVideoPlayer.tsx | 1 - .../s/[videoId]/_components/ImageViewer.tsx | 6 +- .../s/[videoId]/_components/ShareHeader.tsx | 4 +- .../s/[videoId]/_components/ShareVideo.tsx | 6 +- .../app/s/[videoId]/_components/Sidebar.tsx | 21 +- .../app/s/[videoId]/_components/Toolbar.tsx | 19 +- .../_components/tabs/Activity/Comment.tsx | 8 +- .../tabs/Activity/CommentInput.tsx | 2 - .../_components/tabs/Activity/Comments.tsx | 90 ++++--- .../_components/tabs/Activity/index.tsx | 20 +- apps/web/app/s/[videoId]/page.tsx | 100 ++++--- apps/web/components/forms/server.ts | 9 +- .../pages/_components/ComparePlans.tsx | 11 +- apps/web/lib/server.ts | 2 + packages/database/auth/drizzle-adapter.ts | 9 +- packages/database/schema.ts | 12 +- packages/web-backend/src/Auth.ts | 4 +- packages/web-backend/src/IconImages/index.ts | 51 ---- .../web-backend/src/ImageUploads/index.ts | 67 +++++ .../web-backend/src/Organisations/index.ts | 12 +- .../web-backend/src/Users/UsersOnboarding.ts | 40 +-- packages/web-backend/src/Users/index.ts | 8 +- packages/web-backend/src/index.ts | 1 + packages/web-domain/src/Authentication.ts | 4 +- .../src/{IconImage.ts => ImageUpload.ts} | 0 packages/web-domain/src/Organisation.ts | 9 +- packages/web-domain/src/User.ts | 2 +- packages/web-domain/src/index.ts | 2 +- 37 files changed, 636 insertions(+), 465 deletions(-) delete mode 100644 packages/web-backend/src/IconImages/index.ts create mode 100644 packages/web-backend/src/ImageUploads/index.ts rename packages/web-domain/src/{IconImage.ts => ImageUpload.ts} (100%) diff --git a/apps/web/actions/organization/upload-space-icon.ts b/apps/web/actions/organization/upload-space-icon.ts index 8918859144..b2c5325747 100644 --- a/apps/web/actions/organization/upload-space-icon.ts +++ b/apps/web/actions/organization/upload-space-icon.ts @@ -3,9 +3,8 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { spaces } from "@cap/database/schema"; -import { serverEnv } from "@cap/env"; import { S3Buckets } from "@cap/web-backend"; -import type { Space } from "@cap/web-domain"; +import { ImageUpload, type Space } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { Effect, Option } from "effect"; import { revalidatePath } from "next/cache"; @@ -54,9 +53,11 @@ export async function uploadSpaceIcon( // Prepare new file key const fileExtension = file.name.split(".").pop(); - const fileKey = `organizations/${ - space.organizationId - }/spaces/${spaceId}/icon-${Date.now()}.${fileExtension}`; + const fileKey = ImageUpload.ImageKey.make( + `organizations/${ + space.organizationId + }/spaces/${spaceId}/icon-${Date.now()}.${fileExtension}`, + ); const [bucket] = await S3Buckets.getBucketAccess(Option.none()).pipe( runPromise, @@ -89,12 +90,13 @@ export async function uploadSpaceIcon( ) .pipe(runPromise); - const iconUrl = fileKey; - - await db().update(spaces).set({ iconUrl }).where(eq(spaces.id, spaceId)); + await db() + .update(spaces) + .set({ iconUrl: fileKey }) + .where(eq(spaces.id, spaceId)); revalidatePath("/dashboard"); - return { success: true, iconUrl }; + return { success: true, iconUrl: fileKey }; } catch (error) { console.error("Error uploading space icon:", error); throw new Error(error instanceof Error ? error.message : "Upload failed"); diff --git a/apps/web/app/(org)/dashboard/caps/page.tsx b/apps/web/app/(org)/dashboard/caps/page.tsx index f86da3da91..bd4afb305f 100644 --- a/apps/web/app/(org)/dashboard/caps/page.tsx +++ b/apps/web/app/(org)/dashboard/caps/page.tsx @@ -12,10 +12,13 @@ import { videoUploads, } from "@cap/database/schema"; import { serverEnv } from "@cap/env"; -import { Video } from "@cap/web-domain"; +import { Database, ImageUploads } from "@cap/web-backend"; +import { type ImageUpload, Video } from "@cap/web-domain"; import { and, count, desc, eq, inArray, isNull, sql } from "drizzle-orm"; +import { Array, Effect } from "effect"; import type { Metadata } from "next"; import { redirect } from "next/navigation"; +import { runPromise } from "@/lib/server"; import { Caps } from "./Caps"; export const metadata: Metadata = { @@ -23,35 +26,65 @@ export const metadata: Metadata = { }; // Helper function to fetch shared spaces data for videos -async function getSharedSpacesForVideos(videoIds: Video.VideoId[]) { +const getSharedSpacesForVideos = Effect.fn(function* ( + videoIds: Video.VideoId[], +) { if (videoIds.length === 0) return {}; + const db = yield* Database; + const imageUploads = yield* ImageUploads; + // Fetch space-level sharing - const spaceSharing = await db() - .select({ - videoId: spaceVideos.videoId, - id: spaces.id, - name: spaces.name, - organizationId: spaces.organizationId, - iconUrl: organizations.iconUrl, - }) - .from(spaceVideos) - .innerJoin(spaces, eq(spaceVideos.spaceId, spaces.id)) - .innerJoin(organizations, eq(spaces.organizationId, organizations.id)) - .where(inArray(spaceVideos.videoId, videoIds)); + const spaceSharing = yield* db + .use((db) => + db + .select({ + videoId: spaceVideos.videoId, + id: spaces.id, + name: spaces.name, + organizationId: spaces.organizationId, + iconUrl: organizations.iconUrl, + }) + .from(spaceVideos) + .innerJoin(spaces, eq(spaceVideos.spaceId, spaces.id)) + .innerJoin(organizations, eq(spaces.organizationId, organizations.id)) + .where(inArray(spaceVideos.videoId, videoIds)), + ) + .pipe( + Effect.map((v) => + v.map( + Effect.fn(function* (v) { + return { + ...v, + iconUrl: v.iconUrl + ? yield* imageUploads.resolveImageUrl( + v.iconUrl as ImageUpload.ImageUrlOrKey, + ) + : null, + }; + }), + ), + ), + Effect.flatMap(Effect.all), + ); // Fetch organization-level sharing - const orgSharing = await db() - .select({ - videoId: sharedVideos.videoId, - id: organizations.id, - name: organizations.name, - organizationId: organizations.id, - iconUrl: organizations.iconUrl, - }) - .from(sharedVideos) - .innerJoin(organizations, eq(sharedVideos.organizationId, organizations.id)) - .where(inArray(sharedVideos.videoId, videoIds)); + const orgSharing = yield* db.use((db) => + db + .select({ + videoId: sharedVideos.videoId, + id: organizations.id, + name: organizations.name, + organizationId: organizations.id, + iconUrl: organizations.iconUrl, + }) + .from(sharedVideos) + .innerJoin( + organizations, + eq(sharedVideos.organizationId, organizations.id), + ) + .where(inArray(sharedVideos.videoId, videoIds)), + ); // Combine and group by videoId const sharedSpacesMap: Record< @@ -94,7 +127,7 @@ async function getSharedSpacesForVideos(videoIds: Video.VideoId[]) { }); return sharedSpacesMap; -} +}); export default async function CapsPage(props: PageProps<"/dashboard/caps">) { const searchParams = await props.searchParams; @@ -211,7 +244,8 @@ export default async function CapsPage(props: PageProps<"/dashboard/caps">) { // Fetch shared spaces data for all videos const videoIds = videoData.map((video) => video.id); - const sharedSpacesMap = await getSharedSpacesForVideos(videoIds); + const sharedSpacesMap = + await getSharedSpacesForVideos(videoIds).pipe(runPromise); const processedVideoData = videoData.map((video) => { const { effectiveDate, ...videoWithoutEffectiveDate } = video; diff --git a/apps/web/app/(org)/dashboard/dashboard-data.ts b/apps/web/app/(org)/dashboard/dashboard-data.ts index 0bebb01e33..2e1eacff3e 100644 --- a/apps/web/app/(org)/dashboard/dashboard-data.ts +++ b/apps/web/app/(org)/dashboard/dashboard-data.ts @@ -11,10 +11,16 @@ import { users, videos, } from "@cap/database/schema"; +import { Database, ImageUploads } from "@cap/web-backend"; +import type { ImageUpload } from "@cap/web-domain"; import { and, count, eq, inArray, isNull, or, sql } from "drizzle-orm"; +import { Effect } from "effect"; +import { runPromise } from "@/lib/server"; export type Organization = { - organization: typeof organizations.$inferSelect; + organization: Omit & { + iconUrl: ImageUpload.ImageUrl | null; + }; members: (typeof organizationMembers.$inferSelect & { user: Pick< typeof users.$inferSelect, @@ -32,10 +38,11 @@ export type OrganizationSettings = NonNullable< export type Spaces = Omit< typeof spaces.$inferSelect, - "createdAt" | "updatedAt" + "createdAt" | "updatedAt" | "iconUrl" > & { memberCount: number; videoCount: number; + iconUrl: ImageUpload.ImageUrl | null; }; export type UserPreferences = (typeof users.$inferSelect)["preferences"]; @@ -121,40 +128,65 @@ export async function getDashboardData(user: typeof userSelectProps) { .where(eq(organizations.id, activeOrganizationId)); organizationSettings = organizationSetting?.settings || null; - spacesData = await db() - .selectDistinct({ - id: spaces.id, - primary: spaces.primary, - privacy: spaces.privacy, - name: spaces.name, - description: spaces.description, - organizationId: spaces.organizationId, - createdById: spaces.createdById, - iconUrl: spaces.iconUrl, - memberImage: users.image, - memberCount: sql`( + spacesData = await Effect.gen(function* () { + const db = yield* Database; + const imageUploads = yield* ImageUploads; + + return yield* db + .use((db) => + db + .selectDistinct({ + id: spaces.id, + primary: spaces.primary, + privacy: spaces.privacy, + name: spaces.name, + description: spaces.description, + organizationId: spaces.organizationId, + createdById: spaces.createdById, + iconUrl: spaces.iconUrl, + memberImage: users.image, + memberCount: sql`( SELECT COUNT(*) FROM space_members WHERE space_members.spaceId = spaces.id )`, - videoCount: sql`( + videoCount: sql`( SELECT COUNT(*) FROM space_videos WHERE space_videos.spaceId = spaces.id )`, - }) - .from(spaces) - .leftJoin(spaceMembers, eq(spaces.id, spaceMembers.spaceId)) - .leftJoin(users, eq(spaceMembers.userId, users.id)) - .where( - and( - eq(spaces.organizationId, activeOrganizationId), - or( - // User is the space creator - eq(spaces.createdById, user.id), - // User is a member of the space - eq(spaceMembers.userId, user.id), - // Space is public within the organization - eq(spaces.privacy, "Public"), + }) + .from(spaces) + .leftJoin(spaceMembers, eq(spaces.id, spaceMembers.spaceId)) + .leftJoin(users, eq(spaceMembers.userId, users.id)) + .where( + and( + eq(spaces.organizationId, activeOrganizationId), + or( + // User is the space creator + eq(spaces.createdById, user.id), + // User is a member of the space + eq(spaceMembers.userId, user.id), + // Space is public within the organization + eq(spaces.privacy, "Public"), + ), + ), + ), + ) + .pipe( + Effect.map((rows) => + rows.map( + Effect.fn(function* (row) { + return { + ...row, + iconUrl: row.iconUrl + ? yield* imageUploads.resolveImageUrl( + row.iconUrl as ImageUpload.ImageUrlOrKey, + ) + : null, + }; + }), + ), ), - ), - ); + Effect.flatMap(Effect.all), + ); + }).pipe(runPromise); // Add a single 'All spaces' entry for the active organization const activeOrgInfo = organizationsWithMembers.find( @@ -198,18 +230,28 @@ export async function getDashboardData(user: typeof userSelectProps) { userCapsCount = userCapsCountResult[0]?.value || 0; - const allSpacesEntry = { - id: activeOrgInfo.organization.id, - primary: true, - privacy: "Public", - name: `All ${activeOrgInfo.organization.name}`, - description: `View all content in ${activeOrgInfo.organization.name}`, - organizationId: activeOrgInfo.organization.id, - iconUrl: activeOrgInfo.organization.iconUrl, - memberCount: orgMemberCount, - createdById: activeOrgInfo.organization.ownerId, - videoCount: orgVideoCount, - } as const; + const allSpacesEntry = await Effect.gen(function* () { + const imageUploads = yield* ImageUploads; + + const iconUrl = activeOrgInfo.organization + .iconUrl as ImageUpload.ImageUrlOrKey; + + return { + id: activeOrgInfo.organization.id, + primary: true, + privacy: "Public", + name: `All ${activeOrgInfo.organization.name}`, + description: `View all content in ${activeOrgInfo.organization.name}`, + organizationId: activeOrgInfo.organization.id, + iconUrl: iconUrl + ? yield* imageUploads.resolveImageUrl(iconUrl) + : null, + memberCount: orgMemberCount, + createdById: activeOrgInfo.organization.ownerId, + videoCount: orgVideoCount, + } as const; + }).pipe(runPromise); + spacesData = [allSpacesEntry, ...spacesData]; } } @@ -222,7 +264,7 @@ export async function getDashboardData(user: typeof userSelectProps) { .where(eq(users.id, user.id)) .limit(1); - const organizationSelect: Organization[] = await Promise.all( + const organizationSelect: Organization[] = await Effect.all( organizationsWithMembers .reduce((acc: (typeof organizations.$inferSelect)[], row) => { const existingOrganization = acc.find( @@ -233,62 +275,81 @@ export async function getDashboardData(user: typeof userSelectProps) { } return acc; }, []) - .map(async (organization) => { - const allMembers = await db() - .select({ - member: organizationMembers, - user: { - id: users.id, - name: users.name, - lastName: users.lastName, - email: users.email, - image: users.image, - }, - }) - .from(organizationMembers) - .leftJoin(users, eq(organizationMembers.userId, users.id)) - .where(eq(organizationMembers.organizationId, organization.id)); - - const owner = await db() - .select({ - inviteQuota: users.inviteQuota, - }) - .from(users) - .where(eq(users.id, organization.ownerId)) - .then((result) => result[0]); - - const totalInvitesResult = await db() - .select({ - value: sql` + .map( + Effect.fn(function* (organization) { + const db = yield* Database; + const iconImages = yield* ImageUploads; + + const allMembers = yield* db.use((db) => + db + .select({ + member: organizationMembers, + user: { + id: users.id, + name: users.name, + lastName: users.lastName, + email: users.email, + image: users.image, + }, + }) + .from(organizationMembers) + .leftJoin(users, eq(organizationMembers.userId, users.id)) + .where(eq(organizationMembers.organizationId, organization.id)), + ); + + const owner = yield* db.use((db) => + db + .select({ + inviteQuota: users.inviteQuota, + }) + .from(users) + .where(eq(users.id, organization.ownerId)) + .then((result) => result[0]), + ); + + const totalInvitesResult = yield* db.use((db) => + db + .select({ + value: sql` ${count(organizationMembers.id)} + ${count( organizationInvites.id, )} `, - }) - .from(organizations) - .leftJoin( - organizationMembers, - eq(organizations.id, organizationMembers.organizationId), - ) - .leftJoin( - organizationInvites, - eq(organizations.id, organizationInvites.organizationId), - ) - .where(eq(organizations.ownerId, organization.ownerId)); - - const totalInvites = totalInvitesResult[0]?.value || 0; + }) + .from(organizations) + .leftJoin( + organizationMembers, + eq(organizations.id, organizationMembers.organizationId), + ) + .leftJoin( + organizationInvites, + eq(organizations.id, organizationInvites.organizationId), + ) + .where(eq(organizations.ownerId, organization.ownerId)), + ); - return { - organization, - members: allMembers.map((m) => ({ ...m.member, user: m.user! })), - invites: organizationInvitesData.filter( - (invite) => invite.organizationId === organization.id, - ), - inviteQuota: owner?.inviteQuota || 1, - totalInvites, - }; - }), - ); + const totalInvites = totalInvitesResult[0]?.value || 0; + + return { + organization: { + ...organization, + iconUrl: organization.iconUrl + ? yield* iconImages.resolveImageUrl( + organization.iconUrl as ImageUpload.ImageUrlOrKey, + ) + : null, + }, + members: allMembers.map((m) => ({ ...m.member, user: m.user! })), + invites: organizationInvitesData.filter( + (invite) => invite.organizationId === organization.id, + ), + inviteQuota: owner?.inviteQuota || 1, + totalInvites, + }; + }), + ), + { concurrency: 3 }, + ).pipe(runPromise); return { organizationSelect, diff --git a/apps/web/app/(site)/Navbar.tsx b/apps/web/app/(site)/Navbar.tsx index cbeac9a586..18a7ddc6e3 100644 --- a/apps/web/app/(site)/Navbar.tsx +++ b/apps/web/app/(site)/Navbar.tsx @@ -17,9 +17,9 @@ import { motion } from "motion/react"; import Image from "next/image"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { Suspense, use, useEffect, useState } from "react"; +import { Suspense, useEffect, useState } from "react"; import MobileMenu from "@/components/ui/MobileMenu"; -import { useAuthContext } from "../Layout/AuthContext"; +import { useCurrentUser } from "../Layout/AuthContext"; const Links = [ { @@ -115,7 +115,7 @@ const Links = [ export const Navbar = () => { const pathname = usePathname(); const [showMobileMenu, setShowMobileMenu] = useState(false); - const auth = use(useAuthContext().user); + const auth = useCurrentUser(); const [hideLogoName, setHideLogoName] = useState(false); @@ -295,7 +295,7 @@ export const Navbar = () => { }; function LoginOrDashboard() { - const auth = use(useAuthContext().user); + const auth = useCurrentUser(); return (