Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
165 changes: 56 additions & 109 deletions apps/web/actions/organization/create-space.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<typeof v> => 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);
}
}

Expand All @@ -180,8 +127,8 @@ export async function createSpace(
return {
success: true,
spaceId,
name,
iconUrl,
name,
};
} catch (error) {
console.error("Error creating space:", error);
Expand Down
69 changes: 49 additions & 20 deletions apps/web/actions/organization/update-space.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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 {
Expand All @@ -74,5 +102,6 @@ export async function updateSpace(formData: FormData) {
}

revalidatePath("/dashboard");
revalidatePath(`/dashboard/spaces/${id}`);
return { success: true };
}
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>
);
}
Loading