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/Contexts.tsx b/apps/web/app/(org)/dashboard/Contexts.tsx index ae9459ea42..6caeee6adc 100644 --- a/apps/web/app/(org)/dashboard/Contexts.tsx +++ b/apps/web/app/(org)/dashboard/Contexts.tsx @@ -1,10 +1,10 @@ "use client"; -import type { users } from "@cap/database/schema"; import { buildEnv } from "@cap/env"; import Cookies from "js-cookie"; -import { usePathname } from "next/navigation"; +import { redirect, usePathname } from "next/navigation"; import { createContext, useContext, useEffect, useState } from "react"; +import { type CurrentUser, useCurrentUser } from "@/app/Layout/AuthContext"; import { UpgradeModal } from "@/components/UpgradeModal"; import type { Organization, @@ -21,7 +21,7 @@ type SharedContext = { userSpaces: Spaces[] | null; sharedSpaces: Spaces[] | null; activeSpace: Spaces | null; - user: typeof users.$inferSelect; + user: CurrentUser; userCapsCount: number | null; isSubscribed: boolean; toggleSidebarCollapsed: () => void; @@ -56,7 +56,6 @@ export function DashboardContexts({ activeOrganization, spacesData, userCapsCount, - user, isSubscribed, organizationSettings, userPreferences, @@ -70,7 +69,6 @@ export function DashboardContexts({ activeOrganization: SharedContext["activeOrganization"]; spacesData: SharedContext["spacesData"]; userCapsCount: SharedContext["userCapsCount"]; - user: SharedContext["user"]; isSubscribed: SharedContext["isSubscribed"]; organizationSettings: SharedContext["organizationSettings"]; userPreferences: SharedContext["userPreferences"]; @@ -79,6 +77,9 @@ export function DashboardContexts({ initialSidebarCollapsed: boolean; referClicked: boolean; }) { + const user = useCurrentUser(); + if (!user) redirect("/login"); + const [theme, setTheme] = useState(initialTheme); const [sidebarCollapsed, setSidebarCollapsed] = useState( initialSidebarCollapsed, diff --git a/apps/web/app/(org)/dashboard/_components/MobileTab.tsx b/apps/web/app/(org)/dashboard/_components/MobileTab.tsx index ce1b469a9a..7ff0c88474 100644 --- a/apps/web/app/(org)/dashboard/_components/MobileTab.tsx +++ b/apps/web/app/(org)/dashboard/_components/MobileTab.tsx @@ -78,7 +78,6 @@ const Orgs = ({ @@ -138,7 +137,6 @@ const OrgsMenu = ({ diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx index 4515df3fe4..420e56ec6a 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx @@ -127,7 +127,6 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { name={ activeOrg?.organization.name ?? "No organization found" } - type="organization" letterClass={clsx( sidebarCollapsed ? "text-sm" : "text-[13px]", )} @@ -216,7 +215,6 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/SpaceDialog.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/SpaceDialog.tsx index 2de90da958..668da19c0c 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/SpaceDialog.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/SpaceDialog.tsx @@ -14,6 +14,7 @@ import { Input, Label, } from "@cap/ui"; +import type { ImageUpload } from "@cap/web-domain"; import { faLayerGroup } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -36,7 +37,7 @@ interface SpaceDialogProps { id: string; name: string; members: string[]; - iconUrl?: string; + iconUrl?: ImageUpload.ImageUrl; } | null; onSpaceUpdated?: () => void; } @@ -117,7 +118,7 @@ export interface NewSpaceFormProps { id: string; name: string; members: string[]; - iconUrl?: string; + iconUrl?: ImageUpload.ImageUrl; } | null; } @@ -277,7 +278,6 @@ export const NewSpaceForm: React.FC = (props) => { void }) => { { @@ -262,9 +261,8 @@ const User = () => { >
diff --git a/apps/web/app/(org)/dashboard/caps/Caps.tsx b/apps/web/app/(org)/dashboard/caps/Caps.tsx index bc0ff17e6b..6d27c4b0da 100644 --- a/apps/web/app/(org)/dashboard/caps/Caps.tsx +++ b/apps/web/app/(org)/dashboard/caps/Caps.tsx @@ -2,17 +2,15 @@ import type { VideoMetadata } from "@cap/database/types"; import { Button } from "@cap/ui"; -import type { Video } from "@cap/web-domain"; +import type { ImageUpload, Video } from "@cap/web-domain"; import { faFolderPlus, faInfoCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 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, @@ -36,11 +34,14 @@ export type VideoData = { totalComments: number; totalReactions: number; foldersData: FolderDataType[]; - sharedOrganizations: { id: string; name: string; iconUrl?: string }[]; + sharedOrganizations: { + id: string; + name: string; + iconUrl?: ImageUpload.ImageUrl | null; + }[]; sharedSpaces?: { id: string; name: string; - iconUrl: string; isOrg: boolean; organizationId: string; }[]; @@ -147,12 +148,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 +204,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..3f52725599 100644 --- a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx @@ -8,7 +8,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@cap/ui"; -import type { Video } from "@cap/web-domain"; +import type { ImageUpload, Video } from "@cap/web-domain"; import { HttpClient } from "@effect/platform"; import { faCheck, @@ -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"; @@ -63,12 +63,12 @@ export interface CapCardProps extends PropsWithChildren { sharedOrganizations?: { id: string; name: string; - iconUrl?: string | null; + iconUrl?: ImageUpload.ImageUrl | null; }[]; sharedSpaces?: { id: string; name: string; - iconUrl?: string | null; + iconUrl?: ImageUpload.ImageUrl | null; organizationId: string; }[]; ownerName: string | null; @@ -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/caps/components/SettingsDialog.tsx b/apps/web/app/(org)/dashboard/caps/components/SettingsDialog.tsx index 06226dd17e..14372dac83 100644 --- a/apps/web/app/(org)/dashboard/caps/components/SettingsDialog.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/SettingsDialog.tsx @@ -96,8 +96,6 @@ export const SettingsDialog = ({ } }, [buildSettings, isOpen, settingsData]); - const isUserPro = userIsPro(user); - const saveHandler = async () => { if (!settings) return; setSaveLoading(true); @@ -193,7 +191,7 @@ export const SettingsDialog = ({
; @@ -419,7 +419,6 @@ const SpaceCard = ({ diff --git a/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx b/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx index 8449a5f731..885642e06c 100644 --- a/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx @@ -38,9 +38,7 @@ export const UploadCapButton = ({ const handleClick = () => { if (!user) return; - const isCapPro = userIsPro(user); - - if (!isCapPro) { + if (!user.isPro) { setUpgradeModalOpen(true); return; } diff --git a/apps/web/app/(org)/dashboard/caps/page.tsx b/apps/web/app/(org)/dashboard/caps/page.tsx index f86da3da91..a4da232f39 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 { type 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,45 @@ 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; + // 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, + }) + .from(spaceVideos) + .innerJoin(spaces, eq(spaceVideos.spaceId, spaces.id)) + .innerJoin(organizations, eq(spaces.organizationId, organizations.id)) + .where(inArray(spaceVideos.videoId, videoIds)), + ); // 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< @@ -60,7 +73,6 @@ async function getSharedSpacesForVideos(videoIds: Video.VideoId[]) { id: string; name: string; organizationId: string; - iconUrl: string; isOrg: boolean; }> > = {}; @@ -74,7 +86,6 @@ async function getSharedSpacesForVideos(videoIds: Video.VideoId[]) { id: space.id, name: space.name, organizationId: space.organizationId, - iconUrl: space.iconUrl || "", isOrg: false, }); }); @@ -88,13 +99,12 @@ async function getSharedSpacesForVideos(videoIds: Video.VideoId[]) { id: org.id, name: org.name, organizationId: org.organizationId, - iconUrl: org.iconUrl || "", isOrg: true, }); }); return sharedSpacesMap; -} +}); export default async function CapsPage(props: PageProps<"/dashboard/caps">) { const searchParams = await props.searchParams; @@ -134,7 +144,13 @@ export default async function CapsPage(props: PageProps<"/dashboard/caps">) { public: videos.public, totalComments: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'text' THEN ${comments.id} END)`, totalReactions: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'emoji' THEN ${comments.id} END)`, - sharedOrganizations: sql<{ id: string; name: string; iconUrl: string }[]>` + sharedOrganizations: sql< + { + id: string; + name: string; + iconUrl: ImageUpload.ImageUrlOrKey | null; + }[] + >` COALESCE( JSON_ARRAYAGG( JSON_OBJECT( @@ -211,33 +227,47 @@ 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 processedVideoData = videoData.map((video) => { - const { effectiveDate, ...videoWithoutEffectiveDate } = video; - - return { - ...videoWithoutEffectiveDate, - id: Video.VideoId.make(video.id), - foldersData, - settings: video.settings, - sharedOrganizations: Array.isArray(video.sharedOrganizations) - ? video.sharedOrganizations.filter( - (organization) => organization.id !== null, - ) - : [], - sharedSpaces: Array.isArray(sharedSpacesMap[video.id]) - ? sharedSpacesMap[video.id] - : [], - ownerName: video.ownerName ?? "", - metadata: video.metadata as - | { - customCreatedAt?: string; - [key: string]: any; - } - | undefined, - }; - }); + const sharedSpacesMap = + await getSharedSpacesForVideos(videoIds).pipe(runPromise); + + const processedVideoData = await Effect.all( + videoData.map( + Effect.fn(function* (video) { + const imageUploads = yield* ImageUploads; + + const { effectiveDate, ...videoWithoutEffectiveDate } = video; + + return { + ...videoWithoutEffectiveDate, + id: Video.VideoId.make(video.id), + foldersData, + settings: video.settings, + sharedOrganizations: yield* Effect.all( + (video.sharedOrganizations ?? []) + .filter((organization) => organization.id !== null) + .map( + Effect.fn(function* (org) { + return { + ...org, + iconUrl: org.iconUrl + ? yield* imageUploads.resolveImageUrl(org.iconUrl) + : null, + }; + }), + ), + ), + sharedSpaces: sharedSpacesMap[video.id] ?? [], + ownerName: video.ownerName ?? "", + metadata: video.metadata as + | { + customCreatedAt?: string; + [key: string]: any; + } + | undefined, + }; + }), + ), + ).pipe(runPromise); return ( & { + iconUrl: ImageUpload.ImageUrl | null; + }; members: (typeof organizationMembers.$inferSelect & { user: Pick< typeof users.$inferSelect, - "id" | "name" | "email" | "lastName" | "image" - >; + "id" | "name" | "email" | "lastName" + > & { image?: ImageUpload.ImageUrl | null }; })[]; invites: (typeof organizationInvites.$inferSelect)[]; inviteQuota: number; @@ -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,63 @@ 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) + : null, + }; + }), + ), ), - ), - ); + Effect.flatMap(Effect.all), + ); + }).pipe(runPromise); // Add a single 'All spaces' entry for the active organization const activeOrgInfo = organizationsWithMembers.find( @@ -198,18 +228,27 @@ 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; + + 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 +261,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 +272,94 @@ 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) + : null, + }, + members: yield* Effect.all( + allMembers.map( + Effect.fn(function* (m) { + const imageUploads = yield* ImageUploads; + return { + ...m.member, + user: { + ...m.user!, + image: m.user!.image + ? yield* imageUploads.resolveImageUrl(m.user!.image) + : null, + }, + }; + }), + ), + ), + 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/(org)/dashboard/folder/[id]/components/ClientMyCapsLink.tsx b/apps/web/app/(org)/dashboard/folder/[id]/components/ClientMyCapsLink.tsx index ca6c2de19a..dd8488b899 100644 --- a/apps/web/app/(org)/dashboard/folder/[id]/components/ClientMyCapsLink.tsx +++ b/apps/web/app/(org)/dashboard/folder/[id]/components/ClientMyCapsLink.tsx @@ -128,7 +128,6 @@ export function ClientMyCapsLink({ 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..d6a001f31c 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 { makeCurrentUserLayer } from "@cap/web-backend"; +import { Folder } from "@cap/web-domain"; 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/layout.tsx b/apps/web/app/(org)/dashboard/layout.tsx index 74d611c361..18be46405a 100644 --- a/apps/web/app/(org)/dashboard/layout.tsx +++ b/apps/web/app/(org)/dashboard/layout.tsx @@ -24,9 +24,7 @@ export default async function DashboardLayout({ }) { const user = await getCurrentUser(); - if (!user || !user.id) { - redirect("/login"); - } + if (!user) redirect("/login"); if (!user.name || user.name.length === 0) { redirect("/onboarding/welcome"); @@ -82,7 +80,6 @@ export default async function DashboardLayout({ organizationData={organizationSelect} activeOrganization={activeOrganization || null} spacesData={spacesData} - user={user} isSubscribed={isSubscribed} initialTheme={theme as "light" | "dark"} initialSidebarCollapsed={sidebar === "true"} diff --git a/apps/web/app/(org)/dashboard/settings/account/Settings.tsx b/apps/web/app/(org)/dashboard/settings/account/Settings.tsx index 96d61bc111..e9df809f13 100644 --- a/apps/web/app/(org)/dashboard/settings/account/Settings.tsx +++ b/apps/web/app/(org)/dashboard/settings/account/Settings.tsx @@ -9,26 +9,22 @@ import { Input, Select, } from "@cap/ui"; -import { Organisation } from "@cap/web-domain"; +import { type ImageUpload, 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 = () => { const router = useRouter(); - const { organizationData } = useDashboardContext(); + const { organizationData, user } = useDashboardContext(); const [firstName, setFirstName] = useState(user?.name || ""); const [lastName, setLastName] = useState(user?.lastName || ""); const [defaultOrgId, setDefaultOrgId] = useState< @@ -37,9 +33,9 @@ export const Settings = ({ const firstNameId = useId(); const lastNameId = useId(); const contactEmailId = useId(); - const initialProfileImage = user?.image ?? null; + const initialProfileImage = user?.imageUrl ?? null; const [profileImageOverride, setProfileImageOverride] = useState< - string | null | undefined + ImageUpload.ImageUrl | null | undefined >(undefined); const profileImagePreviewUrl = profileImageOverride !== undefined @@ -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", ); }, }); @@ -285,7 +250,6 @@ export const Settings = ({ className="size-5" image={org.organization.iconUrl} name={org.organization.name} - type="organization" /> ), }))} diff --git a/apps/web/app/(org)/dashboard/settings/account/components/ProfileImage.tsx b/apps/web/app/(org)/dashboard/settings/account/components/ProfileImage.tsx index d494ab89a6..360c0d29a9 100644 --- a/apps/web/app/(org)/dashboard/settings/account/components/ProfileImage.tsx +++ b/apps/web/app/(org)/dashboard/settings/account/components/ProfileImage.tsx @@ -1,6 +1,7 @@ "use client"; import { Button } from "@cap/ui"; +import { ImageUpload } from "@cap/web-domain"; import { faImage, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import clsx from "clsx"; @@ -10,7 +11,7 @@ import { SignedImageUrl } from "@/components/SignedImageUrl"; import { Tooltip } from "@/components/Tooltip"; interface ProfileImageProps { - initialPreviewUrl?: string | null; + initialPreviewUrl?: ImageUpload.ImageUrl | null; onChange?: (file: File | null) => void; onRemove?: () => void; disabled?: boolean; @@ -28,7 +29,7 @@ export function ProfileImage({ isRemoving = false, userName, }: ProfileImageProps) { - const [previewUrl, setPreviewUrl] = useState( + const [previewUrl, setPreviewUrl] = useState( initialPreviewUrl || null, ); const [isLocalPreview, setIsLocalPreview] = useState(false); @@ -54,7 +55,7 @@ export function ProfileImage({ URL.revokeObjectURL(previewUrl); } const objectUrl = URL.createObjectURL(file); - setPreviewUrl(objectUrl); + setPreviewUrl(ImageUpload.ImageUrl.make(objectUrl)); setIsLocalPreview(true); onChange?.(file); }; @@ -92,7 +93,6 @@ export function ProfileImage({ diff --git a/apps/web/app/(org)/dashboard/settings/account/page.tsx b/apps/web/app/(org)/dashboard/settings/account/page.tsx index daed483fd5..1408e3b9d7 100644 --- a/apps/web/app/(org)/dashboard/settings/account/page.tsx +++ b/apps/web/app/(org)/dashboard/settings/account/page.tsx @@ -1,4 +1,3 @@ -import { getCurrentUser } from "@cap/database/auth/session"; import type { Metadata } from "next"; import { Settings } from "./Settings"; @@ -7,7 +6,5 @@ export const metadata: Metadata = { }; export default async function SettingsPage() { - const user = await getCurrentUser(); - - return ; + return ; } diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/CapSettingsCard.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/CapSettingsCard.tsx index ff94679ec0..edb9410652 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/components/CapSettingsCard.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/components/CapSettingsCard.tsx @@ -63,8 +63,6 @@ const CapSettingsCard = () => { organizationSettings || settings, ); - const isUserPro = userIsPro(user); - const debouncedUpdateSettings = useDebounce(settings, 1000); useEffect(() => { @@ -179,7 +177,7 @@ const CapSettingsCard = () => { { 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 (
@@ -99,12 +81,17 @@ export const OrganizationIcon = () => { previewIconSize={20} 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]/components/MemberSelect.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MemberSelect.tsx index 0f372b7c30..4d83760b83 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MemberSelect.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MemberSelect.tsx @@ -7,6 +7,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@cap/ui"; +import type { ImageUpload } from "@cap/web-domain"; import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import clsx from "clsx"; @@ -20,7 +21,7 @@ type UserObject = { id: string; email: string; name: string | null; - image: string | null; + image: ImageUpload.ImageUrl | null; }; type OrganizationMember = { @@ -31,7 +32,7 @@ type OrganizationMember = { export interface TagOption { value: string; label: string; - image?: string; + image?: ImageUpload.ImageUrl; } interface MemberSelectProps { @@ -198,7 +199,6 @@ export const MemberSelect = forwardRef( @@ -222,7 +222,6 @@ export const MemberSelect = forwardRef( diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MembersDialog.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MembersDialog.tsx index c6bec9528f..7f37a995e0 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MembersDialog.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MembersDialog.tsx @@ -1,4 +1,6 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@cap/ui"; +import type { ImageUpload } from "@cap/web-domain"; +import { ImageUpdatePayload } from "@cap/web-domain/src/ImageUpload"; import { SignedImageUrl } from "@/components/SignedImageUrl"; interface OrganizationMember { @@ -14,7 +16,7 @@ interface OrganizationMember { email: string; firstName?: string | null; lastName?: string | null; - memberImage?: string | null; + memberImage?: ImageUpload.ImageUrl | null; }; } @@ -45,7 +47,6 @@ export const MembersDialog = ({ diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MembersIndicator.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MembersIndicator.tsx index 885d7c7c02..7aeb2af63b 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MembersIndicator.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MembersIndicator.tsx @@ -168,7 +168,6 @@ export const MembersIndicator = ({ diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/OrganizationIndicator.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/OrganizationIndicator.tsx index 33d57ce70f..729d96a134 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/OrganizationIndicator.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/OrganizationIndicator.tsx @@ -9,6 +9,7 @@ import { DialogTitle, DialogTrigger, } from "@cap/ui"; +import type { ImageUpload } from "@cap/web-domain"; import { faBuilding, faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import clsx from "clsx"; @@ -20,7 +21,7 @@ export type OrganizationMemberData = { id: string; userId: string; role: string; - image?: string | null; + image?: ImageUpload.ImageUrl | null; name: string | null; email: string; }; @@ -73,9 +74,8 @@ export const OrganizationIndicator = ({ className="flex gap-3 items-center px-3 py-2 rounded-lg border transition-colors bg-gray-3 border-gray-4" > 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..f90bd86501 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx @@ -12,8 +12,18 @@ 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 { + Database, + ImageUploads, + makeCurrentUserLayer, + Spaces, +} from "@cap/web-backend"; +import { + type ImageUpload, + 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"; @@ -29,7 +39,7 @@ export type SpaceMemberData = { id: string; userId: string; role: string; - image?: string | null; + image?: ImageUpload.ImageUrl | null; name: string | null; email: string; }; @@ -55,35 +65,81 @@ async function fetchFolders( .where(and(eq(folders.spaceId, spaceId), isNull(folders.parentId))); } -async function fetchSpaceMembers(spaceId: Space.SpaceIdOrOrganisationId) { - return db() - .select({ - id: spaceMembers.id, - userId: spaceMembers.userId, - role: sql`'member'`, - name: users.name, - email: users.email, - image: users.image, - }) - .from(spaceMembers) - .innerJoin(users, eq(spaceMembers.userId, users.id)) - .where(eq(spaceMembers.spaceId, spaceId)); -} +const fetchSpaceMembers = Effect.fn(function* ( + spaceId: Space.SpaceIdOrOrganisationId, +) { + const db = yield* Database; + const imageUploads = yield* ImageUploads; -async function fetchOrganizationMembers(orgId: Organisation.OrganisationId) { - return db() - .select({ - id: organizationMembers.id, - userId: organizationMembers.userId, - role: organizationMembers.role, - name: users.name, - email: users.email, - image: users.image, - }) - .from(organizationMembers) - .innerJoin(users, eq(organizationMembers.userId, users.id)) - .where(eq(organizationMembers.organizationId, orgId)); -} + return yield* db + .use((db) => + db + .select({ + id: spaceMembers.id, + userId: spaceMembers.userId, + role: sql`'member'`, + name: users.name, + email: users.email, + image: users.image, + }) + .from(spaceMembers) + .innerJoin(users, eq(spaceMembers.userId, users.id)) + .where(eq(spaceMembers.spaceId, spaceId)), + ) + .pipe( + Effect.map((v) => + v.map( + Effect.fn(function* (v) { + return { + ...v, + image: v.image + ? yield* imageUploads.resolveImageUrl(v.image) + : null, + }; + }), + ), + ), + Effect.flatMap(Effect.all), + ); +}); + +const fetchOrganizationMembers = Effect.fn(function* ( + orgId: Organisation.OrganisationId, +) { + const db = yield* Database; + const imageUploads = yield* ImageUploads; + + return yield* db + .use((db) => + db + .select({ + id: organizationMembers.id, + userId: organizationMembers.userId, + role: organizationMembers.role, + name: users.name, + email: users.email, + image: users.image, + }) + .from(organizationMembers) + .innerJoin(users, eq(organizationMembers.userId, users.id)) + .where(eq(organizationMembers.organizationId, orgId)), + ) + .pipe( + Effect.map((v) => + v.map( + Effect.fn(function* (v) { + return { + ...v, + image: v.image + ? yield* imageUploads.resolveImageUrl(v.image) + : null, + }; + }), + ), + ), + Effect.flatMap(Effect.all), + ); +}); export default async function SharedCapsPage(props: { params: Promise<{ spaceId: string }>; @@ -100,7 +156,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, ); @@ -112,8 +168,8 @@ export default async function SharedCapsPage(props: { // Fetch members in parallel const [spaceMembersData, organizationMembersData, foldersData] = await Promise.all([ - fetchSpaceMembers(space.id), - fetchOrganizationMembers(space.organizationId), + fetchSpaceMembers(space.id).pipe(runPromise), + fetchOrganizationMembers(space.organizationId).pipe(runPromise), fetchFolders(space.id, false), ]); @@ -284,7 +340,7 @@ export default async function SharedCapsPage(props: { const [organizationVideos, organizationMembersData, foldersData] = await Promise.all([ fetchOrganizationVideos(organization.id, page, limit), - fetchOrganizationMembers(organization.id), + fetchOrganizationMembers(organization.id).pipe(runPromise), fetchFolders(organization.id, true), ]); diff --git a/apps/web/app/(org)/dashboard/spaces/browse/page.tsx b/apps/web/app/(org)/dashboard/spaces/browse/page.tsx index b34d20368d..d4ed2ca05a 100644 --- a/apps/web/app/(org)/dashboard/spaces/browse/page.tsx +++ b/apps/web/app/(org)/dashboard/spaces/browse/page.tsx @@ -135,7 +135,6 @@ export default function BrowseSpacesPage() { 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/(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 (