Skip to content

Commit 750b7e0

Browse files
authored
web: fix spaces (#1288)
* fix spaces * cleanup deadcode * move away from distinct and use branded types * fix editing spaces and member count display
1 parent 5e16d50 commit 750b7e0

File tree

12 files changed

+190
-179
lines changed

12 files changed

+190
-179
lines changed

apps/web/actions/organization/create-space.ts

Lines changed: 56 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,18 @@
22

33
import { db } from "@cap/database";
44
import { getCurrentUser } from "@cap/database/auth/session";
5-
import { nanoId, nanoIdLength } from "@cap/database/helpers";
6-
import { spaceMembers, spaces, users } from "@cap/database/schema";
7-
import { S3Buckets } from "@cap/web-backend";
8-
import { Space } from "@cap/web-domain";
9-
import { and, eq, inArray } from "drizzle-orm";
10-
import { Effect, Option } from "effect";
5+
import { nanoId } from "@cap/database/helpers";
6+
import { spaceMembers, spaces } from "@cap/database/schema";
7+
import {
8+
type ImageUpload,
9+
Space,
10+
SpaceMemberId,
11+
type SpaceMemberRole,
12+
User,
13+
} from "@cap/web-domain";
14+
import { and, eq } from "drizzle-orm";
1115
import { revalidatePath } from "next/cache";
12-
import { v4 as uuidv4 } from "uuid";
13-
import { runPromise } from "@/lib/server";
16+
import { uploadSpaceIcon } from "./upload-space-icon";
1417

1518
interface CreateSpaceResponse {
1619
success: boolean;
@@ -63,115 +66,59 @@ export async function createSpace(
6366

6467
// Generate the space ID early so we can use it in the file path
6568
const spaceId = Space.SpaceId.make(nanoId());
66-
67-
const iconFile = formData.get("icon") as File | null;
68-
let iconUrl = null;
69-
70-
if (iconFile) {
71-
// Validate file type
72-
if (!iconFile.type.startsWith("image/")) {
73-
return {
74-
success: false,
75-
error: "File must be an image",
76-
};
69+
let iconUrl: ImageUpload.ImageUrlOrKey | null = null;
70+
71+
await db().transaction(async (tx) => {
72+
// Create the space first
73+
await tx.insert(spaces).values({
74+
id: spaceId,
75+
name,
76+
organizationId: user.activeOrganizationId,
77+
createdById: user.id,
78+
iconUrl: null,
79+
});
80+
81+
// --- Member Management Logic ---
82+
// Collect member user IDs from formData
83+
const memberUserIds: string[] = [];
84+
for (const entry of formData.getAll("members[]")) {
85+
if (typeof entry === "string" && entry.length > 0) {
86+
memberUserIds.push(entry);
87+
}
7788
}
7889

79-
// Validate file size (limit to 2MB)
80-
if (iconFile.size > 2 * 1024 * 1024) {
81-
return {
82-
success: false,
83-
error: "File size must be less than 2MB",
84-
};
85-
}
86-
87-
try {
88-
// Create a unique file key
89-
const fileExtension = iconFile.name.split(".").pop();
90-
const fileKey = `organizations/${
91-
user.activeOrganizationId
92-
}/spaces/${spaceId}/icon-${Date.now()}.${fileExtension}`;
93-
94-
await Effect.gen(function* () {
95-
const [bucket] = yield* S3Buckets.getBucketAccess(Option.none());
96-
97-
yield* bucket.putObject(
98-
fileKey,
99-
yield* Effect.promise(() => iconFile.bytes()),
100-
{ contentType: iconFile.type },
101-
);
102-
iconUrl = fileKey;
103-
}).pipe(runPromise);
104-
} catch (error) {
105-
console.error("Error uploading space icon:", error);
106-
return {
107-
success: false,
108-
error: "Failed to upload space icon",
109-
};
110-
}
111-
}
112-
113-
await db().insert(spaces).values({
114-
id: spaceId,
115-
name,
116-
organizationId: user.activeOrganizationId,
117-
createdById: user.id,
118-
iconUrl,
119-
createdAt: new Date(),
120-
updatedAt: new Date(),
121-
});
122-
123-
// --- Member Management Logic ---
124-
// Collect member emails from formData
125-
const members: string[] = [];
126-
for (const entry of formData.getAll("members[]")) {
127-
if (typeof entry === "string" && entry.length > 0) {
128-
members.push(entry);
90+
// Always add the creator as Admin (if not already in the list)
91+
if (!memberUserIds.includes(user.id)) {
92+
memberUserIds.push(user.id);
12993
}
130-
}
131-
132-
// Always add the creator as Owner (if not already in the list)
133-
const memberEmailsSet = new Set(members.map((e) => e.toLowerCase()));
134-
const creatorEmail = user.email.toLowerCase();
135-
if (!memberEmailsSet.has(creatorEmail)) {
136-
members.push(user.email);
137-
}
13894

139-
// Look up user IDs for each email
140-
if (members.length > 0) {
141-
// Fetch all users with these emails
142-
const usersFound = await db()
143-
.select({ id: users.id, email: users.email })
144-
.from(users)
145-
.where(inArray(users.email, members));
146-
147-
// Map email to userId
148-
const emailToUserId = Object.fromEntries(
149-
usersFound.map((u) => [u.email.toLowerCase(), u.id]),
150-
);
151-
152-
// Prepare spaceMembers insertions
153-
const spaceMembersToInsert = members
154-
.map((email) => {
155-
const userId = emailToUserId[email.toLowerCase()];
156-
if (!userId) return null;
157-
// Creator is always Owner, others are Member
158-
const role =
159-
email.toLowerCase() === creatorEmail
160-
? ("Admin" as const)
161-
: ("member" as const);
95+
// Create space members
96+
if (memberUserIds.length > 0) {
97+
const spaceMembersToInsert = memberUserIds.map((userId) => {
98+
// Creator is always Admin, others are member
99+
const role: SpaceMemberRole = userId === user.id ? "Admin" : "member";
162100
return {
163-
id: uuidv4().substring(0, nanoIdLength),
101+
id: SpaceMemberId.make(nanoId()),
164102
spaceId,
165-
userId,
103+
userId: User.UserId.make(userId),
166104
role,
167-
createdAt: new Date(),
168-
updatedAt: new Date(),
169105
};
170-
})
171-
.filter((v): v is NonNullable<typeof v> => Boolean(v));
106+
});
172107

173-
if (spaceMembersToInsert.length > 0) {
174-
await db().insert(spaceMembers).values(spaceMembersToInsert);
108+
await tx.insert(spaceMembers).values(spaceMembersToInsert);
109+
}
110+
});
111+
112+
const iconFile = formData.get("icon") as File | null;
113+
114+
if (iconFile) {
115+
try {
116+
const iconFormData = new FormData();
117+
iconFormData.append("icon", iconFile);
118+
const result = await uploadSpaceIcon(iconFormData, spaceId);
119+
iconUrl = result.iconUrl;
120+
} catch (error) {
121+
console.error("Error uploading space icon:", error);
175122
}
176123
}
177124

@@ -180,8 +127,8 @@ export async function createSpace(
180127
return {
181128
success: true,
182129
spaceId,
183-
name,
184130
iconUrl,
131+
name,
185132
};
186133
} catch (error) {
187134
console.error("Error creating space:", error);

apps/web/actions/organization/update-space.ts

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,18 @@
22

33
import { db } from "@cap/database";
44
import { getCurrentUser } from "@cap/database/auth/session";
5-
import { nanoIdLength } from "@cap/database/helpers";
5+
import { nanoId } from "@cap/database/helpers";
66
import { spaceMembers, spaces } from "@cap/database/schema";
77
import { S3Buckets } from "@cap/web-backend";
8-
import { Space, type User } from "@cap/web-domain";
8+
import {
9+
Space,
10+
SpaceMemberId,
11+
type SpaceMemberRole,
12+
type User,
13+
} from "@cap/web-domain";
914
import { and, eq } from "drizzle-orm";
1015
import { Effect, Option } from "effect";
1116
import { revalidatePath } from "next/cache";
12-
import { v4 as uuidv4 } from "uuid";
1317
import { runPromise } from "@/lib/server";
1418
import { uploadSpaceIcon } from "./upload-space-icon";
1519

@@ -22,40 +26,64 @@ export async function updateSpace(formData: FormData) {
2226
const members = formData.getAll("members[]") as User.UserId[];
2327
const iconFile = formData.get("icon") as File | null;
2428

29+
// Get the space to check authorization
30+
const [space] = await db()
31+
.select({
32+
createdById: spaces.createdById,
33+
organizationId: spaces.organizationId,
34+
})
35+
.from(spaces)
36+
.where(eq(spaces.id, id))
37+
.limit(1);
38+
39+
if (!space) {
40+
return { success: false, error: "Space not found" };
41+
}
42+
43+
// Check if user is the creator or a member of the space
44+
const isCreator = space.createdById === user.id;
2545
const [membership] = await db()
2646
.select()
2747
.from(spaceMembers)
28-
.where(and(eq(spaceMembers.spaceId, id), eq(spaceMembers.userId, user.id)));
48+
.where(and(eq(spaceMembers.spaceId, id), eq(spaceMembers.userId, user.id)))
49+
.limit(1);
2950

30-
if (!membership) return { success: false, error: "Unauthorized" };
51+
if (!isCreator && !membership) {
52+
return { success: false, error: "Unauthorized" };
53+
}
3154

3255
// Update space name
3356
await db().update(spaces).set({ name }).where(eq(spaces.id, id));
3457

35-
// Update members (simple replace for now)
58+
// Update members - ensure creator is always included
59+
const memberIds = Array.from(new Set([...members, space.createdById]));
60+
3661
await db().delete(spaceMembers).where(eq(spaceMembers.spaceId, id));
37-
if (members.length > 0) {
38-
await db()
39-
.insert(spaceMembers)
40-
.values(
41-
members.map((userId) => ({
42-
id: uuidv4().substring(0, nanoIdLength),
62+
await db()
63+
.insert(spaceMembers)
64+
.values(
65+
memberIds.map((userId) => {
66+
const role: SpaceMemberRole =
67+
userId === space.createdById ? "Admin" : "member";
68+
return {
69+
id: SpaceMemberId.make(nanoId()),
4370
spaceId: id,
4471
userId,
45-
})),
46-
);
47-
}
72+
role,
73+
};
74+
}),
75+
);
4876

4977
// Handle icon removal if requested
5078
if (formData.get("removeIcon") === "true") {
5179
// Remove icon from S3 and set iconUrl to null
5280
const spaceArr = await db().select().from(spaces).where(eq(spaces.id, id));
53-
const space = spaceArr[0];
54-
if (space?.iconUrl) {
81+
const spaceData = spaceArr[0];
82+
if (spaceData?.iconUrl) {
5583
// Extract the S3 key (it might already be a key or could be a legacy URL)
56-
const key = space.iconUrl.startsWith("organizations/")
57-
? space.iconUrl
58-
: space.iconUrl.match(/organizations\/.+/)?.[0];
84+
const key = spaceData.iconUrl.startsWith("organizations/")
85+
? spaceData.iconUrl
86+
: spaceData.iconUrl.match(/organizations\/.+/)?.[0];
5987

6088
if (key) {
6189
try {
@@ -74,5 +102,6 @@ export async function updateSpace(formData: FormData) {
74102
}
75103

76104
revalidatePath("/dashboard");
105+
revalidatePath(`/dashboard/spaces/${id}`);
77106
return { success: true };
78107
}

apps/web/actions/organization/upload-space-icon.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { spaces } from "@cap/database/schema";
66
import { S3Buckets } from "@cap/web-backend";
77
import { ImageUpload, type Space } from "@cap/web-domain";
88
import { eq } from "drizzle-orm";
9-
import { Effect, Option } from "effect";
9+
import { Option } from "effect";
1010
import { revalidatePath } from "next/cache";
1111
import { sanitizeFile } from "@/lib/sanitizeFile";
1212
import { runPromise } from "@/lib/server";
@@ -47,8 +47,8 @@ export async function uploadSpaceIcon(
4747
if (!file.type.startsWith("image/")) {
4848
throw new Error("File must be an image");
4949
}
50-
if (file.size > 2 * 1024 * 1024) {
51-
throw new Error("File size must be less than 2MB");
50+
if (file.size > 1024 * 1024) {
51+
throw new Error("File size must be less than 1MB");
5252
}
5353

5454
// Prepare new file key
@@ -81,13 +81,12 @@ export async function uploadSpaceIcon(
8181
}
8282

8383
const sanitizedFile = await sanitizeFile(file);
84+
const arrayBuffer = await sanitizedFile.arrayBuffer();
8485

8586
await bucket
86-
.putObject(
87-
fileKey,
88-
Effect.promise(() => sanitizedFile.bytes()),
89-
{ contentType: file.type },
90-
)
87+
.putObject(fileKey, new Uint8Array(arrayBuffer), {
88+
contentType: file.type,
89+
})
9190
.pipe(runPromise);
9291

9392
await db()
Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,11 @@
11
"use client";
2-
3-
import { usePathname } from "next/navigation";
4-
import { useState } from "react";
5-
import { useDashboardContext } from "../Contexts";
6-
import { MembersDialog } from "../spaces/[spaceId]/components/MembersDialog";
72
import Top from "./Navbar/Top";
83

94
export default function DashboardInner({
105
children,
116
}: {
127
children: React.ReactNode;
138
}) {
14-
const { activeOrganization } = useDashboardContext();
15-
const [membersDialogOpen, setMembersDialogOpen] = useState(false);
16-
const isSharedCapsPage = usePathname() === "/dashboard/shared-caps";
17-
189
return (
1910
<div className="flex overflow-hidden w-full flex-col flex-1 md:mt-0 mt-[126px]">
2011
<Top />
@@ -33,14 +24,6 @@ export default function DashboardInner({
3324
<div className="flex flex-col flex-1 gap-4 min-h-fit">{children}</div>
3425
</div>
3526
</main>
36-
{isSharedCapsPage && activeOrganization?.members && (
37-
<MembersDialog
38-
open={membersDialogOpen}
39-
onOpenChange={setMembersDialogOpen}
40-
members={activeOrganization.members}
41-
organizationName={activeOrganization.organization.name || ""}
42-
/>
43-
)}
4427
</div>
4528
);
4629
}

0 commit comments

Comments
 (0)