diff --git a/apps/web/actions/organization/create-space.ts b/apps/web/actions/organization/create-space.ts index 940763e444..439619535d 100644 --- a/apps/web/actions/organization/create-space.ts +++ b/apps/web/actions/organization/create-space.ts @@ -2,15 +2,18 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; -import { nanoId, nanoIdLength } from "@cap/database/helpers"; -import { spaceMembers, spaces, users } from "@cap/database/schema"; -import { S3Buckets } from "@cap/web-backend"; -import { Space } from "@cap/web-domain"; -import { and, eq, inArray } from "drizzle-orm"; -import { Effect, Option } from "effect"; +import { nanoId } from "@cap/database/helpers"; +import { spaceMembers, spaces } from "@cap/database/schema"; +import { + type ImageUpload, + Space, + SpaceMemberId, + type SpaceMemberRole, + User, +} from "@cap/web-domain"; +import { and, eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; -import { v4 as uuidv4 } from "uuid"; -import { runPromise } from "@/lib/server"; +import { uploadSpaceIcon } from "./upload-space-icon"; interface CreateSpaceResponse { success: boolean; @@ -63,115 +66,59 @@ export async function createSpace( // Generate the space ID early so we can use it in the file path const spaceId = Space.SpaceId.make(nanoId()); - - const iconFile = formData.get("icon") as File | null; - let iconUrl = null; - - if (iconFile) { - // Validate file type - if (!iconFile.type.startsWith("image/")) { - return { - success: false, - error: "File must be an image", - }; + let iconUrl: ImageUpload.ImageUrlOrKey | null = null; + + await db().transaction(async (tx) => { + // Create the space first + await tx.insert(spaces).values({ + id: spaceId, + name, + organizationId: user.activeOrganizationId, + createdById: user.id, + iconUrl: null, + }); + + // --- Member Management Logic --- + // Collect member user IDs from formData + const memberUserIds: string[] = []; + for (const entry of formData.getAll("members[]")) { + if (typeof entry === "string" && entry.length > 0) { + memberUserIds.push(entry); + } } - // Validate file size (limit to 2MB) - if (iconFile.size > 2 * 1024 * 1024) { - return { - success: false, - error: "File size must be less than 2MB", - }; - } - - try { - // Create a unique file key - const fileExtension = iconFile.name.split(".").pop(); - const fileKey = `organizations/${ - user.activeOrganizationId - }/spaces/${spaceId}/icon-${Date.now()}.${fileExtension}`; - - await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess(Option.none()); - - yield* bucket.putObject( - fileKey, - yield* Effect.promise(() => iconFile.bytes()), - { contentType: iconFile.type }, - ); - iconUrl = fileKey; - }).pipe(runPromise); - } catch (error) { - console.error("Error uploading space icon:", error); - return { - success: false, - error: "Failed to upload space icon", - }; - } - } - - await db().insert(spaces).values({ - id: spaceId, - name, - organizationId: user.activeOrganizationId, - createdById: user.id, - iconUrl, - createdAt: new Date(), - updatedAt: new Date(), - }); - - // --- Member Management Logic --- - // Collect member emails from formData - const members: string[] = []; - for (const entry of formData.getAll("members[]")) { - if (typeof entry === "string" && entry.length > 0) { - members.push(entry); + // Always add the creator as Admin (if not already in the list) + if (!memberUserIds.includes(user.id)) { + memberUserIds.push(user.id); } - } - - // Always add the creator as Owner (if not already in the list) - const memberEmailsSet = new Set(members.map((e) => e.toLowerCase())); - const creatorEmail = user.email.toLowerCase(); - if (!memberEmailsSet.has(creatorEmail)) { - members.push(user.email); - } - // Look up user IDs for each email - if (members.length > 0) { - // Fetch all users with these emails - const usersFound = await db() - .select({ id: users.id, email: users.email }) - .from(users) - .where(inArray(users.email, members)); - - // Map email to userId - const emailToUserId = Object.fromEntries( - usersFound.map((u) => [u.email.toLowerCase(), u.id]), - ); - - // Prepare spaceMembers insertions - const spaceMembersToInsert = members - .map((email) => { - const userId = emailToUserId[email.toLowerCase()]; - if (!userId) return null; - // Creator is always Owner, others are Member - const role = - email.toLowerCase() === creatorEmail - ? ("Admin" as const) - : ("member" as const); + // Create space members + if (memberUserIds.length > 0) { + const spaceMembersToInsert = memberUserIds.map((userId) => { + // Creator is always Admin, others are member + const role: SpaceMemberRole = userId === user.id ? "Admin" : "member"; return { - id: uuidv4().substring(0, nanoIdLength), + id: SpaceMemberId.make(nanoId()), spaceId, - userId, + userId: User.UserId.make(userId), role, - createdAt: new Date(), - updatedAt: new Date(), }; - }) - .filter((v): v is NonNullable => Boolean(v)); + }); - if (spaceMembersToInsert.length > 0) { - await db().insert(spaceMembers).values(spaceMembersToInsert); + await tx.insert(spaceMembers).values(spaceMembersToInsert); + } + }); + + const iconFile = formData.get("icon") as File | null; + + if (iconFile) { + try { + const iconFormData = new FormData(); + iconFormData.append("icon", iconFile); + const result = await uploadSpaceIcon(iconFormData, spaceId); + iconUrl = result.iconUrl; + } catch (error) { + console.error("Error uploading space icon:", error); } } @@ -180,8 +127,8 @@ export async function createSpace( return { success: true, spaceId, - name, iconUrl, + name, }; } catch (error) { console.error("Error creating space:", error); diff --git a/apps/web/actions/organization/update-space.ts b/apps/web/actions/organization/update-space.ts index bc4805b2ef..09afab8fea 100644 --- a/apps/web/actions/organization/update-space.ts +++ b/apps/web/actions/organization/update-space.ts @@ -2,14 +2,18 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; -import { nanoIdLength } from "@cap/database/helpers"; +import { nanoId } from "@cap/database/helpers"; import { spaceMembers, spaces } from "@cap/database/schema"; import { S3Buckets } from "@cap/web-backend"; -import { Space, type User } from "@cap/web-domain"; +import { + Space, + SpaceMemberId, + type SpaceMemberRole, + type User, +} from "@cap/web-domain"; import { and, eq } from "drizzle-orm"; import { Effect, Option } from "effect"; import { revalidatePath } from "next/cache"; -import { v4 as uuidv4 } from "uuid"; import { runPromise } from "@/lib/server"; import { uploadSpaceIcon } from "./upload-space-icon"; @@ -22,40 +26,64 @@ export async function updateSpace(formData: FormData) { const members = formData.getAll("members[]") as User.UserId[]; const iconFile = formData.get("icon") as File | null; + // Get the space to check authorization + const [space] = await db() + .select({ + createdById: spaces.createdById, + organizationId: spaces.organizationId, + }) + .from(spaces) + .where(eq(spaces.id, id)) + .limit(1); + + if (!space) { + return { success: false, error: "Space not found" }; + } + + // Check if user is the creator or a member of the space + const isCreator = space.createdById === user.id; const [membership] = await db() .select() .from(spaceMembers) - .where(and(eq(spaceMembers.spaceId, id), eq(spaceMembers.userId, user.id))); + .where(and(eq(spaceMembers.spaceId, id), eq(spaceMembers.userId, user.id))) + .limit(1); - if (!membership) return { success: false, error: "Unauthorized" }; + if (!isCreator && !membership) { + return { success: false, error: "Unauthorized" }; + } // Update space name await db().update(spaces).set({ name }).where(eq(spaces.id, id)); - // Update members (simple replace for now) + // Update members - ensure creator is always included + const memberIds = Array.from(new Set([...members, space.createdById])); + await db().delete(spaceMembers).where(eq(spaceMembers.spaceId, id)); - if (members.length > 0) { - await db() - .insert(spaceMembers) - .values( - members.map((userId) => ({ - id: uuidv4().substring(0, nanoIdLength), + await db() + .insert(spaceMembers) + .values( + memberIds.map((userId) => { + const role: SpaceMemberRole = + userId === space.createdById ? "Admin" : "member"; + return { + id: SpaceMemberId.make(nanoId()), spaceId: id, userId, - })), - ); - } + role, + }; + }), + ); // Handle icon removal if requested if (formData.get("removeIcon") === "true") { // Remove icon from S3 and set iconUrl to null const spaceArr = await db().select().from(spaces).where(eq(spaces.id, id)); - const space = spaceArr[0]; - if (space?.iconUrl) { + const spaceData = spaceArr[0]; + if (spaceData?.iconUrl) { // Extract the S3 key (it might already be a key or could be a legacy URL) - const key = space.iconUrl.startsWith("organizations/") - ? space.iconUrl - : space.iconUrl.match(/organizations\/.+/)?.[0]; + const key = spaceData.iconUrl.startsWith("organizations/") + ? spaceData.iconUrl + : spaceData.iconUrl.match(/organizations\/.+/)?.[0]; if (key) { try { @@ -74,5 +102,6 @@ export async function updateSpace(formData: FormData) { } revalidatePath("/dashboard"); + revalidatePath(`/dashboard/spaces/${id}`); return { success: true }; } diff --git a/apps/web/actions/organization/upload-space-icon.ts b/apps/web/actions/organization/upload-space-icon.ts index b2c5325747..30a431c8e9 100644 --- a/apps/web/actions/organization/upload-space-icon.ts +++ b/apps/web/actions/organization/upload-space-icon.ts @@ -6,7 +6,7 @@ import { spaces } from "@cap/database/schema"; import { S3Buckets } from "@cap/web-backend"; import { ImageUpload, type Space } from "@cap/web-domain"; import { eq } from "drizzle-orm"; -import { Effect, Option } from "effect"; +import { Option } from "effect"; import { revalidatePath } from "next/cache"; import { sanitizeFile } from "@/lib/sanitizeFile"; import { runPromise } from "@/lib/server"; @@ -47,8 +47,8 @@ export async function uploadSpaceIcon( if (!file.type.startsWith("image/")) { throw new Error("File must be an image"); } - if (file.size > 2 * 1024 * 1024) { - throw new Error("File size must be less than 2MB"); + if (file.size > 1024 * 1024) { + throw new Error("File size must be less than 1MB"); } // Prepare new file key @@ -81,13 +81,12 @@ export async function uploadSpaceIcon( } const sanitizedFile = await sanitizeFile(file); + const arrayBuffer = await sanitizedFile.arrayBuffer(); await bucket - .putObject( - fileKey, - Effect.promise(() => sanitizedFile.bytes()), - { contentType: file.type }, - ) + .putObject(fileKey, new Uint8Array(arrayBuffer), { + contentType: file.type, + }) .pipe(runPromise); await db() diff --git a/apps/web/app/(org)/dashboard/_components/DashboardInner.tsx b/apps/web/app/(org)/dashboard/_components/DashboardInner.tsx index baed0f2193..13c905a111 100644 --- a/apps/web/app/(org)/dashboard/_components/DashboardInner.tsx +++ b/apps/web/app/(org)/dashboard/_components/DashboardInner.tsx @@ -1,9 +1,4 @@ "use client"; - -import { usePathname } from "next/navigation"; -import { useState } from "react"; -import { useDashboardContext } from "../Contexts"; -import { MembersDialog } from "../spaces/[spaceId]/components/MembersDialog"; import Top from "./Navbar/Top"; export default function DashboardInner({ @@ -11,10 +6,6 @@ export default function DashboardInner({ }: { children: React.ReactNode; }) { - const { activeOrganization } = useDashboardContext(); - const [membersDialogOpen, setMembersDialogOpen] = useState(false); - const isSharedCapsPage = usePathname() === "/dashboard/shared-caps"; - return (
@@ -33,14 +24,6 @@ export default function DashboardInner({
{children}
- {isSharedCapsPage && activeOrganization?.members && ( - - )} ); } diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/SpaceDialog.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/SpaceDialog.tsx index 668da19c0c..b8bb2ae1b1 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/SpaceDialog.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/SpaceDialog.tsx @@ -18,6 +18,7 @@ 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"; +import { useRouter } from "next/navigation"; import type React from "react"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; @@ -92,7 +93,6 @@ const SpaceDialog = ({ disabled={isSubmitting || !spaceName.trim().length} spinner={isSubmitting} onClick={() => formRef.current?.requestSubmit()} - type="submit" > {isSubmitting ? edit @@ -132,6 +132,7 @@ const formSchema = z.object({ export const NewSpaceForm: React.FC = (props) => { const { edit = false, space } = props; + const router = useRouter(); const form = useForm>({ resolver: zodResolver(formSchema), @@ -157,6 +158,22 @@ export const NewSpaceForm: React.FC = (props) => { const [isUploading, setIsUploading] = useState(false); const { activeOrganization } = useDashboardContext(); + const handleFileChange = (file: File | null) => { + if (file) { + // Validate file size (1MB = 1024 * 1024 bytes) + if (file.size > 1024 * 1024) { + toast.error("File size must be less than 1MB"); + return; + } + // Validate file type + if (!file.type.startsWith("image/")) { + toast.error("File must be an image"); + return; + } + } + setSelectedFile(file); + }; + return (
= (props) => { if (selectedFile === null && space.iconUrl) { formData.append("removeIcon", "true"); } - await updateSpace(formData); + const result = await updateSpace(formData); + if (!result.success) { + throw new Error(result.error || "Failed to update space"); + } toast.success("Space updated successfully"); + router.refresh(); } else { - await createSpace(formData); + const result = await createSpace(formData); + if (!result.success) { + throw new Error(result.error || "Failed to create space"); + } toast.success("Space created successfully"); + router.refresh(); } form.reset(); @@ -270,7 +295,7 @@ export const NewSpaceForm: React.FC = (props) => {
- Upload a custom logo or icon for your space. + Upload a custom logo or icon for your space (max 1MB).
@@ -280,7 +305,7 @@ export const NewSpaceForm: React.FC = (props) => { name="icon" initialPreviewUrl={space?.iconUrl || null} notDraggingClassName="hover:bg-gray-3" - onChange={setSelectedFile} + onChange={handleFileChange} disabled={isUploading} isLoading={isUploading} /> diff --git a/apps/web/app/(org)/dashboard/dashboard-data.ts b/apps/web/app/(org)/dashboard/dashboard-data.ts index 5574dc73f4..b1e2f0a5bc 100644 --- a/apps/web/app/(org)/dashboard/dashboard-data.ts +++ b/apps/web/app/(org)/dashboard/dashboard-data.ts @@ -6,7 +6,6 @@ import { organizationMembers, organizations, sharedVideos, - spaceMembers, spaces, users, videos, @@ -135,7 +134,7 @@ export async function getDashboardData(user: typeof userSelectProps) { return yield* db .use((db) => db - .selectDistinct({ + .select({ id: spaces.id, primary: spaces.primary, privacy: spaces.privacy, @@ -144,7 +143,6 @@ export async function getDashboardData(user: typeof userSelectProps) { 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 )`, @@ -153,18 +151,20 @@ export async function getDashboardData(user: typeof userSelectProps) { )`, }) .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"), + // User is a member of the space + sql`EXISTS ( + SELECT 1 FROM space_members + WHERE space_members.spaceId = spaces.id + AND space_members.userId = ${user.id} + )`, ), ), ), diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/actions.ts b/apps/web/app/(org)/dashboard/spaces/[spaceId]/actions.ts index 847ef9dc40..fdff89eb5e 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/actions.ts +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/actions.ts @@ -3,7 +3,7 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { nanoIdLength } from "@cap/database/helpers"; -import { spaceMembers } from "@cap/database/schema"; +import { spaceMembers, spaces } from "@cap/database/schema"; import { Space, User } from "@cap/web-domain"; import { eq, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; @@ -170,25 +170,41 @@ export async function setSpaceMembers( } const { spaceId, userIds, role } = validation.data; + // Get the space creator to ensure they're always included + const [space] = await db() + .select({ createdById: spaces.createdById }) + .from(spaces) + .where(eq(spaces.id, spaceId)) + .limit(1); + + if (!space) { + throw new Error("Space not found"); + } + + // Ensure creator is always included in the member list + const allMemberIds = Array.from(new Set([...userIds, space.createdById])); + // Remove all current members await db().delete(spaceMembers).where(eq(spaceMembers.spaceId, spaceId)); - // Insert new members if any - if (userIds.length > 0) { - const now = new Date(); - const values = userIds.map((userId) => ({ + // Insert new members (always at least the creator) + const now = new Date(); + const values = allMemberIds.map((userId) => { + // Creator is always Admin, others get the specified role + const memberRole = userId === space.createdById ? "Admin" : role; + return { id: User.UserId.make(uuidv4().substring(0, nanoIdLength)), spaceId, userId, - role, + role: memberRole, createdAt: now, updatedAt: now, - })); - await db().insert(spaceMembers).values(values); - } + }; + }); + await db().insert(spaceMembers).values(values); revalidatePath(`/dashboard/spaces/${spaceId}`); - return { success: true, count: userIds.length }; + return { success: true, count: allMemberIds.length }; } const batchRemoveSpaceMembersSchema = z.object({ 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 7f37a995e0..c87c403162 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MembersDialog.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MembersDialog.tsx @@ -1,6 +1,5 @@ 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 { @@ -45,7 +44,7 @@ export const MembersDialog = ({ className="flex items-center p-2 rounded-lg hover:bg-gray-3" > { const { user } = useDashboardContext(); + const router = useRouter(); const [open, setOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); @@ -81,6 +83,7 @@ export const MembersIndicator = ({ role: "member", }); toast.success("Members updated!"); + router.refresh(); } catch (error) { console.error("Failed to update members:", error); toast.error("Failed to update members"); diff --git a/apps/web/components/forms/server.ts b/apps/web/components/forms/server.ts index 2121526405..3f6d352cc4 100644 --- a/apps/web/components/forms/server.ts +++ b/apps/web/components/forms/server.ts @@ -73,7 +73,7 @@ export async function createOrganization(formData: FormData) { yield* bucket.putObject( fileKey, - yield* Effect.promise(() => iconFile.bytes()), + yield* Effect.promise(() => iconFile.arrayBuffer()), { contentType: iconFile.type }, ); }).pipe(runPromise); diff --git a/packages/web-domain/src/Space.ts b/packages/web-domain/src/Space.ts index 2f7926249f..5ed4580d40 100644 --- a/packages/web-domain/src/Space.ts +++ b/packages/web-domain/src/Space.ts @@ -6,3 +6,12 @@ export type SpaceId = typeof SpaceIdOrOrganisationId.Type; export const SpaceIdOrOrganisationId = Schema.Union(SpaceId, OrganisationId); export type SpaceIdOrOrganisationId = typeof SpaceIdOrOrganisationId.Type; + +export const SpaceMemberId = Schema.String.pipe(Schema.brand("SpaceMemberId")); +export type SpaceMemberId = typeof SpaceMemberId.Type; + +export const SpaceMemberRole = Schema.Union( + Schema.Literal("Admin"), + Schema.Literal("member"), +); +export type SpaceMemberRole = typeof SpaceMemberRole.Type; diff --git a/packages/web-domain/src/index.ts b/packages/web-domain/src/index.ts index 1b1a553033..76c7755801 100644 --- a/packages/web-domain/src/index.ts +++ b/packages/web-domain/src/index.ts @@ -14,6 +14,7 @@ export { Rpcs } from "./Rpcs.ts"; export * as S3Bucket from "./S3Bucket.ts"; export { S3Error } from "./S3Bucket.ts"; export * as Space from "./Space.ts"; +export { SpaceMemberId, SpaceMemberRole } from "./Space.ts"; export * as User from "./User.ts"; export { UserId } from "./User.ts"; export * as Video from "./Video.ts";