Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 45 additions & 97 deletions apps/web/actions/organization/create-space.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,12 @@
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 { spaceMembers, spaces } from "@cap/database/schema";
import { Space, 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;
Expand Down Expand Up @@ -64,115 +62,65 @@ export async function createSpace(
// Generate the space ID early so we can use it in the file path
const spaceId = Space.SpaceId.make(nanoId());

// Create the space first
await db().insert(spaces).values({
id: spaceId,
name,
organizationId: user.activeOrganizationId,
createdById: user.id,
iconUrl: null,
createdAt: new Date(),
updatedAt: new Date(),
});
Comment thread
ameer2468 marked this conversation as resolved.
Outdated

// Upload icon if provided
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",
};
}

// 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);
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);
return {
success: false,
error: "Failed to upload space icon",
};
// Don't fail the space creation if icon upload fails
// The space is already created, just without an 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[] = [];
// Collect member user IDs from formData
const memberUserIds: string[] = [];
for (const entry of formData.getAll("members[]")) {
if (typeof entry === "string" && entry.length > 0) {
members.push(entry);
memberUserIds.push(entry);
}
}

// 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);
// Always add the creator as Admin (if not already in the list)
if (!memberUserIds.includes(user.id)) {
memberUserIds.push(user.id);
}

// 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);
return {
id: uuidv4().substring(0, nanoIdLength),
spaceId,
userId,
role,
createdAt: new Date(),
updatedAt: new Date(),
};
})
.filter((v): v is NonNullable<typeof v> => Boolean(v));

if (spaceMembersToInsert.length > 0) {
await db().insert(spaceMembers).values(spaceMembersToInsert);
}
// Create space members
if (memberUserIds.length > 0) {
const spaceMembersToInsert = memberUserIds.map((userId) => {
// Creator is always Admin, others are member
const role =
userId === user.id ? ("Admin" as const) : ("member" as const);
return {
id: uuidv4().substring(0, nanoIdLength),
spaceId,
userId: User.UserId.make(userId),
role,
createdAt: new Date(),
updatedAt: new Date(),
};
});

await db().insert(spaceMembers).values(spaceMembersToInsert);
}
Comment thread
ameer2468 marked this conversation as resolved.
Outdated
Comment thread
ameer2468 marked this conversation as resolved.
Outdated

revalidatePath("/dashboard");
Expand Down
15 changes: 7 additions & 8 deletions apps/web/actions/organization/upload-space-icon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
17 changes: 0 additions & 17 deletions apps/web/app/(org)/dashboard/_components/DashboardInner.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,11 @@
"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({
children,
}: {
children: React.ReactNode;
}) {
const { activeOrganization } = useDashboardContext();
const [membersDialogOpen, setMembersDialogOpen] = useState(false);
const isSharedCapsPage = usePathname() === "/dashboard/shared-caps";

return (
<div className="flex overflow-hidden w-full flex-col flex-1 md:mt-0 mt-[126px]">
<Top />
Expand All @@ -33,14 +24,6 @@ export default function DashboardInner({
<div className="flex flex-col flex-1 gap-4 min-h-fit">{children}</div>
</div>
</main>
{isSharedCapsPage && activeOrganization?.members && (
<MembersDialog
open={membersDialogOpen}
onOpenChange={setMembersDialogOpen}
members={activeOrganization.members}
organizationName={activeOrganization.organization.name || ""}
/>
)}
</div>
);
}
21 changes: 18 additions & 3 deletions apps/web/app/(org)/dashboard/_components/Navbar/SpaceDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,6 @@ const SpaceDialog = ({
disabled={isSubmitting || !spaceName.trim().length}
spinner={isSubmitting}
onClick={() => formRef.current?.requestSubmit()}
type="submit"
>
{isSubmitting
? edit
Expand Down Expand Up @@ -157,6 +156,22 @@ export const NewSpaceForm: React.FC<NewSpaceFormProps> = (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 (
<Form {...form}>
<form
Expand Down Expand Up @@ -270,7 +285,7 @@ export const NewSpaceForm: React.FC<NewSpaceFormProps> = (props) => {
<div className="space-y-1">
<Label htmlFor="icon">Space Icon</Label>
<CardDescription className="w-full max-w-[400px]">
Upload a custom logo or icon for your space.
Upload a custom logo or icon for your space (max 1MB).
</CardDescription>
</div>

Expand All @@ -280,7 +295,7 @@ export const NewSpaceForm: React.FC<NewSpaceFormProps> = (props) => {
name="icon"
initialPreviewUrl={space?.iconUrl || null}
notDraggingClassName="hover:bg-gray-3"
onChange={setSelectedFile}
onChange={handleFileChange}
disabled={isUploading}
isLoading={isUploading}
/>
Expand Down
1 change: 0 additions & 1 deletion apps/web/app/(org)/dashboard/dashboard-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,6 @@ export async function getDashboardData(user: typeof userSelectProps) {
organizationId: spaces.organizationId,
createdById: spaces.createdById,
iconUrl: spaces.iconUrl,
memberImage: users.image,
memberCount: sql<number>`(
SELECT COUNT(*) FROM space_members WHERE space_members.spaceId = spaces.id
)`,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -45,7 +44,7 @@ export const MembersDialog = ({
className="flex items-center p-2 rounded-lg hover:bg-gray-3"
>
<SignedImageUrl
image={member.user?.memberImage || undefined}
image={member.user?.memberImage}
name={member.user?.name || "User"}
className="mr-3 size-8"
letterClass="text-md"
Expand Down
2 changes: 1 addition & 1 deletion apps/web/components/forms/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down