From d8feb5ef5b1c2c10d638c8588af9ec77fbf0cedc Mon Sep 17 00:00:00 2001 From: ameer2468 <33054370+ameer2468@users.noreply.github.com> Date: Tue, 21 Oct 2025 16:43:39 +0300 Subject: [PATCH 1/6] cleanup image actions --- .../actions/account/remove-profile-image.ts | 66 ---------- .../actions/account/upload-profile-image.ts | 119 ----------------- apps/web/actions/images/remove-image.ts | 70 ++++++++++ apps/web/actions/images/upload-image.ts | 83 ++++++++++++ apps/web/actions/organization/remove-icon.ts | 79 ------------ .../organization/upload-organization-icon.ts | 120 ------------------ .../dashboard/settings/account/Settings.tsx | 13 +- .../components/OrganizationIcon.tsx | 19 ++- packages/web-domain/src/index.ts | 1 + 9 files changed, 174 insertions(+), 396 deletions(-) delete mode 100644 apps/web/actions/account/remove-profile-image.ts delete mode 100644 apps/web/actions/account/upload-profile-image.ts create mode 100644 apps/web/actions/images/remove-image.ts create mode 100644 apps/web/actions/images/upload-image.ts delete mode 100644 apps/web/actions/organization/remove-icon.ts delete mode 100644 apps/web/actions/organization/upload-organization-icon.ts diff --git a/apps/web/actions/account/remove-profile-image.ts b/apps/web/actions/account/remove-profile-image.ts deleted file mode 100644 index 6689ab9dc9..0000000000 --- a/apps/web/actions/account/remove-profile-image.ts +++ /dev/null @@ -1,66 +0,0 @@ -"use server"; - -import path from "node:path"; -import { db } from "@cap/database"; -import { getCurrentUser } from "@cap/database/auth/session"; -import { users } from "@cap/database/schema"; -import { S3Buckets } from "@cap/web-backend"; -import { eq } from "drizzle-orm"; -import { Effect, Option } from "effect"; -import { revalidatePath } from "next/cache"; -import { runPromise } from "@/lib/server"; - -export async function removeProfileImage() { - const user = await getCurrentUser(); - - if (!user) { - throw new Error("Unauthorized"); - } - - const image = user.image; - - // Delete the profile image from S3 if it exists - if (image) { - try { - // Extract the S3 key - handle both old URL format and new key format - let s3Key = image; - if (image.startsWith("http://") || image.startsWith("https://")) { - const url = new URL(image); - // Only extract key from URLs with amazonaws.com hostname - if ( - url.hostname.endsWith(".amazonaws.com") || - url.hostname === "amazonaws.com" - ) { - const raw = url.pathname.startsWith("/") - ? url.pathname.slice(1) - : url.pathname; - const decoded = decodeURIComponent(raw); - const normalized = path.posix.normalize(decoded); - if (normalized.includes("..")) { - throw new Error("Invalid S3 key path"); - } - s3Key = normalized; - } else { - // Not an S3 URL, skip deletion of S3 object; continue with DB update below - } - } - - // Only delete if it looks like a user profile image key - if (s3Key.startsWith("users/")) { - await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess(Option.none()); - yield* bucket.deleteObject(s3Key); - }).pipe(runPromise); - } - } catch (error) { - console.error("Error deleting profile image from S3:", error); - // Continue with database update even if S3 deletion fails - } - } - - await db().update(users).set({ image: null }).where(eq(users.id, user.id)); - - revalidatePath("/dashboard/settings/account"); - - return { success: true } as const; -} diff --git a/apps/web/actions/account/upload-profile-image.ts b/apps/web/actions/account/upload-profile-image.ts deleted file mode 100644 index 74c2f6c603..0000000000 --- a/apps/web/actions/account/upload-profile-image.ts +++ /dev/null @@ -1,119 +0,0 @@ -"use server"; - -import { randomUUID } from "node:crypto"; -import { db } from "@cap/database"; -import { getCurrentUser } from "@cap/database/auth/session"; -import { users } from "@cap/database/schema"; -import { S3Buckets } from "@cap/web-backend"; -import { eq } from "drizzle-orm"; -import { Effect, Option } from "effect"; -import { revalidatePath } from "next/cache"; -import { sanitizeFile } from "@/lib/sanitizeFile"; -import { runPromise } from "@/lib/server"; - -const MAX_FILE_SIZE_BYTES = 3 * 1024 * 1024; // 3MB -const ALLOWED_IMAGE_TYPES = new Map([ - ["image/png", "png"], - ["image/jpeg", "jpg"], - ["image/jpg", "jpg"], -]); - -export async function uploadProfileImage(formData: FormData) { - const user = await getCurrentUser(); - - if (!user) { - throw new Error("Unauthorized"); - } - - const file = formData.get("image") as File | null; - - if (!file) { - throw new Error("No file provided"); - } - - const normalizedType = file.type.toLowerCase(); - const fileExtension = ALLOWED_IMAGE_TYPES.get(normalizedType); - - if (!fileExtension) { - throw new Error("Only PNG or JPEG images are supported"); - } - - if (file.size > MAX_FILE_SIZE_BYTES) { - throw new Error("File size must be 3MB or less"); - } - - // Get the old profile image to delete it later - const oldImageUrlOrKey = user.image; - - const fileKey = `users/${user.id}/profile-${Date.now()}-${randomUUID()}.${fileExtension}`; - - try { - const sanitizedFile = await sanitizeFile(file); - let image: string | null = null; - - await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess(Option.none()); - - // Delete old profile image if it exists - if (oldImageUrlOrKey) { - try { - // Extract the S3 key - handle both old URL format and new key format - let oldS3Key = oldImageUrlOrKey; - if ( - oldImageUrlOrKey.startsWith("http://") || - oldImageUrlOrKey.startsWith("https://") - ) { - const url = new URL(oldImageUrlOrKey); - // Only extract key from URLs with amazonaws.com hostname - if ( - url.hostname.endsWith(".amazonaws.com") || - url.hostname === "amazonaws.com" - ) { - oldS3Key = url.pathname.substring(1); // Remove leading slash - } else { - // Not an S3 URL, skip deletion - return; - } - } - - // Only delete if it looks like a user profile image key - if (oldS3Key.startsWith("users/")) { - yield* bucket.deleteObject(oldS3Key); - } - } catch (error) { - console.error("Error deleting old profile image from S3:", error); - // Continue with upload even if deletion fails - } - } - - const bodyBytes = yield* Effect.promise(async () => { - const buf = await sanitizedFile.arrayBuffer(); - return new Uint8Array(buf); - }); - - yield* bucket.putObject(fileKey, bodyBytes, { - contentType: file.type, - }); - - image = fileKey; - }).pipe(runPromise); - - if (!image) { - throw new Error("Failed to resolve uploaded profile image key"); - } - - const finalImageUrlOrKey = image; - - await db() - .update(users) - .set({ image: finalImageUrlOrKey }) - .where(eq(users.id, user.id)); - - revalidatePath("/dashboard/settings/account"); - - return { success: true, image: finalImageUrlOrKey } as const; - } catch (error) { - console.error("Error uploading profile image:", error); - throw new Error(error instanceof Error ? error.message : "Upload failed"); - } -} diff --git a/apps/web/actions/images/remove-image.ts b/apps/web/actions/images/remove-image.ts new file mode 100644 index 0000000000..573a6bc357 --- /dev/null +++ b/apps/web/actions/images/remove-image.ts @@ -0,0 +1,70 @@ +"use server"; + +import { db } from "@cap/database"; +import { organizations, users } from "@cap/database/schema"; +import { S3Buckets } from "@cap/web-backend"; +import { OrganisationId, UserId } from "@cap/web-domain"; +import { eq } from "drizzle-orm"; +import { Effect, Option } from "effect"; +import { revalidatePath } from "next/cache"; +import * as path from "path"; +import { runPromise } from "@/lib/server"; + +export async function removeImage( + imageUrlOrKey: string, + type: "user" | "organization", + entityId: string, +) { + try { + await Effect.gen(function* () { + const [bucket] = yield* S3Buckets.getBucketAccess(Option.none()); + + // Extract the S3 key - handle both old URL format and new key format + let s3Key = imageUrlOrKey; + if ( + imageUrlOrKey.startsWith("http://") || + imageUrlOrKey.startsWith("https://") + ) { + const url = new URL(imageUrlOrKey); + const raw = url.pathname.startsWith("/") + ? url.pathname.slice(1) + : url.pathname; + const decoded = decodeURIComponent(raw); + const normalized = path.posix.normalize(decoded); + if (normalized.includes("..")) { + throw new Error("Invalid S3 key path"); + } + s3Key = normalized; + } + + // Only delete if it looks like the correct type of image key + const expectedPrefix = type === "user" ? "users/" : "organizations/"; + if (s3Key.startsWith(expectedPrefix)) { + yield* bucket.deleteObject(s3Key); + } + }).pipe(runPromise); + + // Update database + if (type === "user") { + await db() + .update(users) + .set({ image: null }) + .where(eq(users.id, UserId.make(entityId))); + } else { + await db() + .update(organizations) + .set({ iconUrl: null }) + .where(eq(organizations.id, OrganisationId.make(entityId))); + } + + revalidatePath("/dashboard/settings/account"); + if (type === "organization") { + revalidatePath("/dashboard/settings/organization"); + } + + return { success: true } as const; + } catch (error) { + console.error(`Error removing ${type} image:`, error); + throw new Error(error instanceof Error ? error.message : "Remove failed"); + } +} diff --git a/apps/web/actions/images/upload-image.ts b/apps/web/actions/images/upload-image.ts new file mode 100644 index 0000000000..6fcb335e13 --- /dev/null +++ b/apps/web/actions/images/upload-image.ts @@ -0,0 +1,83 @@ +"use server"; + +import { db } from "@cap/database"; +import { organizations, users } from "@cap/database/schema"; +import { S3Buckets } from "@cap/web-backend"; +import { OrganisationId, UserId } from "@cap/web-domain"; +import { eq } from "drizzle-orm"; +import { Effect, Option } from "effect"; +import { revalidatePath } from "next/cache"; +import { runPromise } from "@/lib/server"; + +export async function uploadImage( + file: File, + type: "user" | "organization", + entityId: string, + oldImageUrlOrKey?: string | null, +) { + try { + const s3Key = await Effect.gen(function* () { + const [bucket] = yield* S3Buckets.getBucketAccess(Option.none()); + + // Delete old image if it exists + if (oldImageUrlOrKey) { + try { + // Extract the S3 key - handle both old URL format and new key format + let oldS3Key = oldImageUrlOrKey; + if ( + oldImageUrlOrKey.startsWith("http://") || + oldImageUrlOrKey.startsWith("https://") + ) { + const url = new URL(oldImageUrlOrKey); + oldS3Key = url.pathname.substring(1); + } + + // Only delete if it looks like the correct type of image key + const expectedPrefix = type === "user" ? "users/" : "organizations/"; + if (oldS3Key.startsWith(expectedPrefix)) { + yield* bucket.deleteObject(oldS3Key); + } + } catch (error) { + console.error(`Error deleting old ${type} image from S3:`, error); + } + } + + // Generate new S3 key + const timestamp = Date.now(); + const fileExtension = file.name.split(".").pop() || "jpg"; + const s3Key = `${type}s/${entityId}/${timestamp}.${fileExtension}`; + + // Upload new image + const arrayBuffer = yield* Effect.promise(() => file.arrayBuffer()); + const buffer = Buffer.from(arrayBuffer); + yield* bucket.putObject(s3Key, buffer, { + contentType: file.type, + }); + + return s3Key; + }).pipe(runPromise); + + // Update database + if (type === "user") { + await db() + .update(users) + .set({ image: s3Key }) + .where(eq(users.id, UserId.make(entityId))); + } else { + await db() + .update(organizations) + .set({ iconUrl: s3Key }) + .where(eq(organizations.id, OrganisationId.make(entityId))); + } + + revalidatePath("/dashboard/settings/account"); + if (type === "organization") { + revalidatePath("/dashboard/settings/organization"); + } + + return { success: true, image: s3Key } as const; + } catch (error) { + console.error(`Error uploading ${type} image:`, error); + throw new Error(error instanceof Error ? error.message : "Upload failed"); + } +} diff --git a/apps/web/actions/organization/remove-icon.ts b/apps/web/actions/organization/remove-icon.ts deleted file mode 100644 index b0c54b7423..0000000000 --- a/apps/web/actions/organization/remove-icon.ts +++ /dev/null @@ -1,79 +0,0 @@ -"use server"; - -import { db } from "@cap/database"; -import { getCurrentUser } from "@cap/database/auth/session"; -import { organizations } from "@cap/database/schema"; -import { S3Buckets } from "@cap/web-backend"; -import type { Organisation } from "@cap/web-domain"; -import { eq } from "drizzle-orm"; -import { Effect, Option } from "effect"; -import { revalidatePath } from "next/cache"; -import { runPromise } from "@/lib/server"; - -export async function removeOrganizationIcon( - organizationId: Organisation.OrganisationId, -) { - const user = await getCurrentUser(); - - if (!user) { - throw new Error("Unauthorized"); - } - - const organization = await db() - .select() - .from(organizations) - .where(eq(organizations.id, organizationId)); - - if (!organization || organization.length === 0) { - throw new Error("Organization not found"); - } - - if (organization[0]?.ownerId !== user.id) { - throw new Error("Only the owner can remove the organization icon"); - } - - const iconUrl = organization[0]?.iconUrl; - - // Delete the icon from S3 if it exists - if (iconUrl) { - try { - // Extract the S3 key - handle both old URL format and new key format - let s3Key = iconUrl; - if (iconUrl.startsWith("http://") || iconUrl.startsWith("https://")) { - const url = new URL(iconUrl); - // Only extract key from URLs with amazonaws.com hostname - if ( - url.hostname.endsWith(".amazonaws.com") || - url.hostname === "amazonaws.com" - ) { - s3Key = url.pathname.substring(1); // Remove leading slash - } else { - s3Key = ""; - } - } - - // Only delete if it looks like an organization icon key - if (s3Key.startsWith("organizations/")) { - await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess(Option.none()); - yield* bucket.deleteObject(s3Key); - }).pipe(runPromise); - } - } catch (error) { - console.error("Error deleting organization icon from S3:", error); - // Continue with database update even if S3 deletion fails - } - } - - // Update organization to remove icon URL - await db() - .update(organizations) - .set({ - iconUrl: null, - }) - .where(eq(organizations.id, organizationId)); - - revalidatePath("/dashboard/settings/organization"); - - return { success: true }; -} diff --git a/apps/web/actions/organization/upload-organization-icon.ts b/apps/web/actions/organization/upload-organization-icon.ts deleted file mode 100644 index 77b4685176..0000000000 --- a/apps/web/actions/organization/upload-organization-icon.ts +++ /dev/null @@ -1,120 +0,0 @@ -"use server"; - -import { db } from "@cap/database"; -import { getCurrentUser } from "@cap/database/auth/session"; -import { organizations } from "@cap/database/schema"; -import { S3Buckets } from "@cap/web-backend"; -import type { Organisation } from "@cap/web-domain"; -import { eq } from "drizzle-orm"; -import { Effect, Option } from "effect"; -import { revalidatePath } from "next/cache"; -import { sanitizeFile } from "@/lib/sanitizeFile"; -import { runPromise } from "@/lib/server"; - -const MAX_FILE_SIZE_BYTES = 1 * 1024 * 1024; // 1MB - -export async function uploadOrganizationIcon( - formData: FormData, - organizationId: Organisation.OrganisationId, -) { - const user = await getCurrentUser(); - - if (!user) { - throw new Error("Unauthorized"); - } - - const organization = await db() - .select() - .from(organizations) - .where(eq(organizations.id, organizationId)); - - if (!organization || organization.length === 0) { - throw new Error("Organization not found"); - } - - if (organization[0]?.ownerId !== user.id) { - throw new Error("Only the owner can update organization icon"); - } - - const file = formData.get("icon") as File | null; - - if (!file) { - throw new Error("No file provided"); - } - - // Validate file type - if (!file.type.startsWith("image/")) { - throw new Error("File must be an image"); - } - - if (file.size > MAX_FILE_SIZE_BYTES) { - throw new Error("File size must be less than 1MB"); - } - - // Get the old icon to delete it later - const oldIconUrlOrKey = organization[0]?.iconUrl; - - // Create a unique file key - const fileExtension = file.name.split(".").pop(); - const fileKey = `organizations/${organizationId}/icon-${Date.now()}.${fileExtension}`; - - try { - const sanitizedFile = await sanitizeFile(file); - - await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess(Option.none()); - - // Delete old icon if it exists - if (oldIconUrlOrKey) { - try { - // Extract the S3 key - handle both old URL format and new key format - let oldS3Key = oldIconUrlOrKey; - if ( - oldIconUrlOrKey.startsWith("http://") || - oldIconUrlOrKey.startsWith("https://") - ) { - const url = new URL(oldIconUrlOrKey); - // Only extract key from URLs with amazonaws.com hostname - if ( - url.hostname.endsWith(".amazonaws.com") || - url.hostname === "amazonaws.com" - ) { - oldS3Key = url.pathname.substring(1); // Remove leading slash - } else { - return; - } - } - - // Only delete if it looks like an organization icon key - if (oldS3Key.startsWith("organizations/")) { - yield* bucket.deleteObject(oldS3Key); - } - } catch (error) { - console.error("Error deleting old organization icon from S3:", error); - // Continue with upload even if deletion fails - } - } - - const bodyBytes = yield* Effect.promise(async () => { - const buf = await sanitizedFile.arrayBuffer(); - return new Uint8Array(buf); - }); - - yield* bucket.putObject(fileKey, bodyBytes, { contentType: file.type }); - }).pipe(runPromise); - - const iconUrl = fileKey; - - await db() - .update(organizations) - .set({ iconUrl }) - .where(eq(organizations.id, organizationId)); - - revalidatePath("/dashboard/settings/organization"); - - return { success: true, iconUrl }; - } catch (error) { - console.error("Error uploading organization icon:", error); - throw new Error(error instanceof Error ? error.message : "Upload failed"); - } -} diff --git a/apps/web/app/(org)/dashboard/settings/account/Settings.tsx b/apps/web/app/(org)/dashboard/settings/account/Settings.tsx index 0de4164e29..34b218e571 100644 --- a/apps/web/app/(org)/dashboard/settings/account/Settings.tsx +++ b/apps/web/app/(org)/dashboard/settings/account/Settings.tsx @@ -14,8 +14,8 @@ import { useMutation } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { useEffect, useId, useState } from "react"; import { toast } from "sonner"; -import { removeProfileImage } from "@/actions/account/remove-profile-image"; -import { uploadProfileImage } from "@/actions/account/upload-profile-image"; +import { removeImage } from "@/actions/images/remove-image"; +import { uploadImage } from "@/actions/images/upload-image"; import { SignedImageUrl } from "@/components/SignedImageUrl"; import { useDashboardContext } from "../../Contexts"; import { ProfileImage } from "./components/ProfileImage"; @@ -92,9 +92,10 @@ export const Settings = ({ const uploadProfileImageMutation = useMutation({ mutationFn: async (file: File) => { - const formData = new FormData(); - formData.append("image", file); - return uploadProfileImage(formData); + if (!user?.id) { + throw new Error("User ID is required"); + } + return uploadImage(file, "user", user.id, user.image); }, onSuccess: (result) => { if (result.success) { @@ -115,7 +116,7 @@ export const Settings = ({ }); const removeProfileImageMutation = useMutation({ - mutationFn: removeProfileImage, + mutationFn: () => removeImage(user?.image || "", "user", user?.id || ""), onSuccess: (result) => { if (result?.success) { setProfileImageOverride(null); diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationIcon.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationIcon.tsx index 7b7813aa83..0b7264d3bd 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationIcon.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationIcon.tsx @@ -4,8 +4,8 @@ import { CardDescription, Label } from "@cap/ui"; import { useRouter } from "next/navigation"; import { useId, useState } from "react"; import { toast } from "sonner"; -import { removeOrganizationIcon } from "@/actions/organization/remove-icon"; -import { uploadOrganizationIcon } from "@/actions/organization/upload-organization-icon"; +import { removeImage } from "@/actions/images/remove-image"; +import { uploadImage } from "@/actions/images/upload-image"; import { FileInput } from "@/components/FileInput"; import { useDashboardContext } from "../../../Contexts"; @@ -25,9 +25,12 @@ export const OrganizationIcon = () => { // Upload the file to the server immediately try { setIsUploading(true); - const fd = new FormData(); - fd.append("icon", file); - const result = await uploadOrganizationIcon(fd, organizationId); + const result = await uploadImage( + file, + "organization", + organizationId, + existingIconUrl, + ); if (result.success) { toast.success("Organization icon updated successfully"); @@ -46,7 +49,11 @@ export const OrganizationIcon = () => { if (!organizationId) return; try { - const result = await removeOrganizationIcon(organizationId); + const result = await removeImage( + existingIconUrl || "", + "organization", + organizationId, + ); if (result?.success) { toast.success("Organization icon removed successfully"); diff --git a/packages/web-domain/src/index.ts b/packages/web-domain/src/index.ts index 560c124a52..e3ca891547 100644 --- a/packages/web-domain/src/index.ts +++ b/packages/web-domain/src/index.ts @@ -14,4 +14,5 @@ export * as S3Bucket from "./S3Bucket.ts"; export { S3Error } from "./S3Bucket.ts"; export * as Space from "./Space.ts"; export * as User from "./User.ts"; +export { UserId } from "./User.ts"; export * as Video from "./Video.ts"; From b3142c9c685c12ab4c39574fc5745095789487c6 Mon Sep 17 00:00:00 2001 From: ameer2468 <33054370+ameer2468@users.noreply.github.com> Date: Tue, 21 Oct 2025 16:52:51 +0300 Subject: [PATCH 2/6] cleanup --- apps/web/actions/images/remove-image.ts | 2 +- apps/web/actions/images/upload-image.ts | 15 ++++++++++++--- .../dashboard/_components/Navbar/SpaceDialog.tsx | 3 +-- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/apps/web/actions/images/remove-image.ts b/apps/web/actions/images/remove-image.ts index 573a6bc357..7e4790c6e2 100644 --- a/apps/web/actions/images/remove-image.ts +++ b/apps/web/actions/images/remove-image.ts @@ -64,7 +64,7 @@ export async function removeImage( return { success: true } as const; } catch (error) { - console.error(`Error removing ${type} image:`, error); + console.error(`Error removing %s image:`, type, error); throw new Error(error instanceof Error ? error.message : "Remove failed"); } } diff --git a/apps/web/actions/images/upload-image.ts b/apps/web/actions/images/upload-image.ts index 6fcb335e13..8e694a9ade 100644 --- a/apps/web/actions/images/upload-image.ts +++ b/apps/web/actions/images/upload-image.ts @@ -7,6 +7,7 @@ import { OrganisationId, UserId } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { Effect, Option } from "effect"; import { revalidatePath } from "next/cache"; +import * as path from "path"; import { runPromise } from "@/lib/server"; export async function uploadImage( @@ -29,7 +30,15 @@ export async function uploadImage( oldImageUrlOrKey.startsWith("https://") ) { const url = new URL(oldImageUrlOrKey); - oldS3Key = url.pathname.substring(1); + const raw = url.pathname.startsWith("/") + ? url.pathname.slice(1) + : url.pathname; + const decoded = decodeURIComponent(raw); + const normalized = path.posix.normalize(decoded); + if (normalized.includes("..")) { + throw new Error("Invalid S3 key path"); + } + oldS3Key = normalized; } // Only delete if it looks like the correct type of image key @@ -38,7 +47,7 @@ export async function uploadImage( yield* bucket.deleteObject(oldS3Key); } } catch (error) { - console.error(`Error deleting old ${type} image from S3:`, error); + console.error(`Error deleting old %s image from S3:`, type, error); } } @@ -77,7 +86,7 @@ export async function uploadImage( return { success: true, image: s3Key } as const; } catch (error) { - console.error(`Error uploading ${type} image:`, error); + console.error(`Error uploading %s image:`, type, error); throw new Error(error instanceof Error ? error.message : "Upload failed"); } } diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/SpaceDialog.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/SpaceDialog.tsx index a8fb79804c..2de90da958 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/SpaceDialog.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/SpaceDialog.tsx @@ -18,13 +18,12 @@ import { faLayerGroup } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import type React from "react"; -import { useEffect, useId, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import * as z from "zod"; import { updateSpace } from "@/actions/organization/update-space"; import { FileInput } from "@/components/FileInput"; -import { SignedImageUrl } from "@/components/SignedImageUrl"; import { useDashboardContext } from "../../Contexts"; import { MemberSelect } from "../../spaces/[spaceId]/components/MemberSelect"; import { createSpace } from "./server"; From 5a996cd4a9e18a05f2c2ca4827935d4f57b29eee Mon Sep 17 00:00:00 2001 From: ameer2468 <33054370+ameer2468@users.noreply.github.com> Date: Tue, 21 Oct 2025 16:55:00 +0300 Subject: [PATCH 3/6] Update Settings.tsx --- .../(org)/dashboard/settings/account/Settings.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/web/app/(org)/dashboard/settings/account/Settings.tsx b/apps/web/app/(org)/dashboard/settings/account/Settings.tsx index 34b218e571..2540d0b7d7 100644 --- a/apps/web/app/(org)/dashboard/settings/account/Settings.tsx +++ b/apps/web/app/(org)/dashboard/settings/account/Settings.tsx @@ -116,13 +116,16 @@ export const Settings = ({ }); const removeProfileImageMutation = useMutation({ - mutationFn: () => removeImage(user?.image || "", "user", user?.id || ""), - onSuccess: (result) => { - if (result?.success) { - setProfileImageOverride(null); - toast.success("Profile image removed"); - router.refresh(); + mutationFn: () => { + if (!user?.id) { + throw new Error("User ID is required"); } + return removeImage(user.image || "", "user", user.id); + }, + onSuccess: () => { + setProfileImageOverride(null); + toast.success("Profile image removed"); + router.refresh(); }, onError: (error) => { console.error("Error removing profile image:", error); From db5ac617c463a12d6b766cff52968008dfe42975 Mon Sep 17 00:00:00 2001 From: ameer2468 <33054370+ameer2468@users.noreply.github.com> Date: Tue, 21 Oct 2025 17:26:07 +0300 Subject: [PATCH 4/6] move to rpc --- apps/web/actions/images/remove-image.ts | 70 -------- apps/web/actions/images/upload-image.ts | 92 ----------- .../dashboard/settings/account/Settings.tsx | 106 +++++++----- .../components/OrganizationIcon.tsx | 61 ++++--- packages/web-backend/src/Users/UsersRpcs.ts | 151 +++++++++++++++++- packages/web-domain/src/User.ts | 33 ++++ 6 files changed, 291 insertions(+), 222 deletions(-) delete mode 100644 apps/web/actions/images/remove-image.ts delete mode 100644 apps/web/actions/images/upload-image.ts diff --git a/apps/web/actions/images/remove-image.ts b/apps/web/actions/images/remove-image.ts deleted file mode 100644 index 7e4790c6e2..0000000000 --- a/apps/web/actions/images/remove-image.ts +++ /dev/null @@ -1,70 +0,0 @@ -"use server"; - -import { db } from "@cap/database"; -import { organizations, users } from "@cap/database/schema"; -import { S3Buckets } from "@cap/web-backend"; -import { OrganisationId, UserId } from "@cap/web-domain"; -import { eq } from "drizzle-orm"; -import { Effect, Option } from "effect"; -import { revalidatePath } from "next/cache"; -import * as path from "path"; -import { runPromise } from "@/lib/server"; - -export async function removeImage( - imageUrlOrKey: string, - type: "user" | "organization", - entityId: string, -) { - try { - await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess(Option.none()); - - // Extract the S3 key - handle both old URL format and new key format - let s3Key = imageUrlOrKey; - if ( - imageUrlOrKey.startsWith("http://") || - imageUrlOrKey.startsWith("https://") - ) { - const url = new URL(imageUrlOrKey); - const raw = url.pathname.startsWith("/") - ? url.pathname.slice(1) - : url.pathname; - const decoded = decodeURIComponent(raw); - const normalized = path.posix.normalize(decoded); - if (normalized.includes("..")) { - throw new Error("Invalid S3 key path"); - } - s3Key = normalized; - } - - // Only delete if it looks like the correct type of image key - const expectedPrefix = type === "user" ? "users/" : "organizations/"; - if (s3Key.startsWith(expectedPrefix)) { - yield* bucket.deleteObject(s3Key); - } - }).pipe(runPromise); - - // Update database - if (type === "user") { - await db() - .update(users) - .set({ image: null }) - .where(eq(users.id, UserId.make(entityId))); - } else { - await db() - .update(organizations) - .set({ iconUrl: null }) - .where(eq(organizations.id, OrganisationId.make(entityId))); - } - - revalidatePath("/dashboard/settings/account"); - if (type === "organization") { - revalidatePath("/dashboard/settings/organization"); - } - - return { success: true } as const; - } catch (error) { - console.error(`Error removing %s image:`, type, error); - throw new Error(error instanceof Error ? error.message : "Remove failed"); - } -} diff --git a/apps/web/actions/images/upload-image.ts b/apps/web/actions/images/upload-image.ts deleted file mode 100644 index 8e694a9ade..0000000000 --- a/apps/web/actions/images/upload-image.ts +++ /dev/null @@ -1,92 +0,0 @@ -"use server"; - -import { db } from "@cap/database"; -import { organizations, users } from "@cap/database/schema"; -import { S3Buckets } from "@cap/web-backend"; -import { OrganisationId, UserId } from "@cap/web-domain"; -import { eq } from "drizzle-orm"; -import { Effect, Option } from "effect"; -import { revalidatePath } from "next/cache"; -import * as path from "path"; -import { runPromise } from "@/lib/server"; - -export async function uploadImage( - file: File, - type: "user" | "organization", - entityId: string, - oldImageUrlOrKey?: string | null, -) { - try { - const s3Key = await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess(Option.none()); - - // Delete old image if it exists - if (oldImageUrlOrKey) { - try { - // Extract the S3 key - handle both old URL format and new key format - let oldS3Key = oldImageUrlOrKey; - if ( - oldImageUrlOrKey.startsWith("http://") || - oldImageUrlOrKey.startsWith("https://") - ) { - const url = new URL(oldImageUrlOrKey); - const raw = url.pathname.startsWith("/") - ? url.pathname.slice(1) - : url.pathname; - const decoded = decodeURIComponent(raw); - const normalized = path.posix.normalize(decoded); - if (normalized.includes("..")) { - throw new Error("Invalid S3 key path"); - } - oldS3Key = normalized; - } - - // Only delete if it looks like the correct type of image key - const expectedPrefix = type === "user" ? "users/" : "organizations/"; - if (oldS3Key.startsWith(expectedPrefix)) { - yield* bucket.deleteObject(oldS3Key); - } - } catch (error) { - console.error(`Error deleting old %s image from S3:`, type, error); - } - } - - // Generate new S3 key - const timestamp = Date.now(); - const fileExtension = file.name.split(".").pop() || "jpg"; - const s3Key = `${type}s/${entityId}/${timestamp}.${fileExtension}`; - - // Upload new image - const arrayBuffer = yield* Effect.promise(() => file.arrayBuffer()); - const buffer = Buffer.from(arrayBuffer); - yield* bucket.putObject(s3Key, buffer, { - contentType: file.type, - }); - - return s3Key; - }).pipe(runPromise); - - // Update database - if (type === "user") { - await db() - .update(users) - .set({ image: s3Key }) - .where(eq(users.id, UserId.make(entityId))); - } else { - await db() - .update(organizations) - .set({ iconUrl: s3Key }) - .where(eq(organizations.id, OrganisationId.make(entityId))); - } - - revalidatePath("/dashboard/settings/account"); - if (type === "organization") { - revalidatePath("/dashboard/settings/organization"); - } - - return { success: true, image: s3Key } as const; - } catch (error) { - console.error(`Error uploading %s image:`, type, error); - throw new Error(error instanceof Error ? error.message : "Upload failed"); - } -} diff --git a/apps/web/app/(org)/dashboard/settings/account/Settings.tsx b/apps/web/app/(org)/dashboard/settings/account/Settings.tsx index 2540d0b7d7..96d61bc111 100644 --- a/apps/web/app/(org)/dashboard/settings/account/Settings.tsx +++ b/apps/web/app/(org)/dashboard/settings/account/Settings.tsx @@ -11,12 +11,13 @@ import { } from "@cap/ui"; import { Organisation } from "@cap/web-domain"; import { useMutation } from "@tanstack/react-query"; +import { Effect } from "effect"; import { useRouter } from "next/navigation"; import { useEffect, useId, useState } from "react"; import { toast } from "sonner"; -import { removeImage } from "@/actions/images/remove-image"; -import { uploadImage } from "@/actions/images/upload-image"; import { SignedImageUrl } from "@/components/SignedImageUrl"; +import { useEffectMutation } from "@/lib/EffectRuntime"; +import { withRpc } from "@/lib/Rpcs"; import { useDashboardContext } from "../../Contexts"; import { ProfileImage } from "./components/ProfileImage"; import { patchAccountSettings } from "./server"; @@ -90,50 +91,81 @@ export const Settings = ({ return () => window.removeEventListener("beforeunload", handleBeforeUnload); }, [hasChanges]); - const uploadProfileImageMutation = useMutation({ - mutationFn: async (file: File) => { + const uploadProfileImageMutation = useEffectMutation({ + mutationFn: (file: File) => { if (!user?.id) { - throw new Error("User ID is required"); + return Effect.fail(new Error("User ID is required")); } - return uploadImage(file, "user", user.id, user.image); - }, - onSuccess: (result) => { - if (result.success) { - 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", + + 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 removeProfileImageMutation = useMutation({ + const removeProfileImageMutation = useEffectMutation({ mutationFn: () => { if (!user?.id) { - throw new Error("User ID is required"); + return Effect.fail(new Error("User ID is required")); } - return removeImage(user.image || "", "user", user.id); - }, - 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", + + 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; + }), + ), ); }, }); diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationIcon.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationIcon.tsx index 0b7264d3bd..32f7947eed 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationIcon.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationIcon.tsx @@ -1,12 +1,13 @@ "use client"; import { CardDescription, Label } from "@cap/ui"; +import { Effect } from "effect"; import { useRouter } from "next/navigation"; import { useId, useState } from "react"; import { toast } from "sonner"; -import { removeImage } from "@/actions/images/remove-image"; -import { uploadImage } from "@/actions/images/upload-image"; import { FileInput } from "@/components/FileInput"; +import * as EffectRuntime from "@/lib/EffectRuntime"; +import { withRpc } from "@/lib/Rpcs"; import { useDashboardContext } from "../../../Contexts"; export const OrganizationIcon = () => { @@ -25,17 +26,29 @@ export const OrganizationIcon = () => { // Upload the file to the server immediately try { setIsUploading(true); - const result = await uploadImage( - file, - "organization", - organizationId, - existingIconUrl, - ); - if (result.success) { - toast.success("Organization icon updated successfully"); - router.refresh(); - } + 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) { toast.error( error instanceof Error ? error.message : "Failed to upload icon", @@ -49,16 +62,22 @@ export const OrganizationIcon = () => { if (!organizationId) return; try { - const result = await removeImage( - existingIconUrl || "", - "organization", - organizationId, + 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(); + }), + ), + ), ); - - if (result?.success) { - toast.success("Organization icon removed successfully"); - router.refresh(); - } } catch (error) { console.error("Error removing organization icon:", error); toast.error( diff --git a/packages/web-backend/src/Users/UsersRpcs.ts b/packages/web-backend/src/Users/UsersRpcs.ts index 41a6d80540..cf9272a762 100644 --- a/packages/web-backend/src/Users/UsersRpcs.ts +++ b/packages/web-backend/src/Users/UsersRpcs.ts @@ -1,5 +1,9 @@ -import { InternalError, User } from "@cap/web-domain"; +import * as Db from "@cap/database/schema"; +import { InternalError, Organisation, User } from "@cap/web-domain"; +import * as Dz from "drizzle-orm"; import { Effect, Layer, Option } from "effect"; +import * as path from "path"; +import { Database } from "../Database"; import { S3Buckets } from "../S3Buckets"; import { UsersOnboarding } from "./UsersOnboarding"; @@ -7,6 +11,7 @@ export const UsersRpcsLive = User.UserRpcs.toLayer( Effect.gen(function* () { const onboarding = yield* UsersOnboarding; const s3Buckets = yield* S3Buckets; + const db = yield* Database; return { UserCompleteOnboardingStep: (payload) => Effect.gen(function* () { @@ -55,6 +60,148 @@ export const UsersRpcsLive = User.UserRpcs.toLayer( ), Effect.catchAll(() => new InternalError({ type: "unknown" })), ), + UploadImage: (payload) => + Effect.gen(function* () { + const [bucket] = yield* s3Buckets.getBucketAccess(Option.none()); + + // Delete old image if it exists + if (payload.oldImageKey) { + try { + // Extract the S3 key - handle both old URL format and new key format + let oldS3Key = payload.oldImageKey; + if ( + payload.oldImageKey.startsWith("http://") || + payload.oldImageKey.startsWith("https://") + ) { + const url = new URL(payload.oldImageKey); + const raw = url.pathname.startsWith("/") + ? url.pathname.slice(1) + : url.pathname; + const decoded = decodeURIComponent(raw); + const normalized = path.posix.normalize(decoded); + if (normalized.includes("..")) { + yield* Effect.fail(new InternalError({ type: "unknown" })); + } + oldS3Key = normalized; + } + + // Only delete if it looks like the correct type of image key + const expectedPrefix = + payload.type === "user" ? "users/" : "organizations/"; + if (oldS3Key.startsWith(expectedPrefix)) { + yield* bucket.deleteObject(oldS3Key); + } + } catch (error) { + // Continue with upload even if deletion fails + console.error( + `Error deleting old ${payload.type} image from S3:`, + error, + ); + } + } + + // Generate new S3 key + const timestamp = Date.now(); + const fileExtension = payload.fileName.split(".").pop() || "jpg"; + const s3Key = `${payload.type}s/${payload.entityId}/${timestamp}.${fileExtension}`; + + // Upload new image + const buffer = Buffer.from(payload.data); + yield* bucket.putObject(s3Key, buffer, { + contentType: payload.contentType, + }); + + // Update database + if (payload.type === "user") { + yield* db.use((db) => + db + .update(Db.users) + .set({ image: s3Key }) + .where(Dz.eq(Db.users.id, User.UserId.make(payload.entityId))), + ); + } else { + yield* db.use((db) => + db + .update(Db.organizations) + .set({ iconUrl: s3Key }) + .where( + Dz.eq( + Db.organizations.id, + Organisation.OrganisationId.make(payload.entityId), + ), + ), + ); + } + + return { key: s3Key }; + }).pipe( + Effect.catchTag("S3Error", () => new InternalError({ type: "s3" })), + Effect.catchTag( + "DatabaseError", + () => new InternalError({ type: "database" }), + ), + Effect.catchAll(() => new InternalError({ type: "unknown" })), + ), + RemoveImage: (payload) => + Effect.gen(function* () { + const [bucket] = yield* s3Buckets.getBucketAccess(Option.none()); + + // Extract the S3 key - handle both old URL format and new key format + let s3Key = payload.imageKey; + if ( + payload.imageKey.startsWith("http://") || + payload.imageKey.startsWith("https://") + ) { + const url = new URL(payload.imageKey); + const raw = url.pathname.startsWith("/") + ? url.pathname.slice(1) + : url.pathname; + const decoded = decodeURIComponent(raw); + const normalized = path.posix.normalize(decoded); + if (normalized.includes("..")) { + yield* Effect.fail(new InternalError({ type: "unknown" })); + } + s3Key = normalized; + } + + // Only delete if it looks like the correct type of image key + const expectedPrefix = + payload.type === "user" ? "users/" : "organizations/"; + if (s3Key.startsWith(expectedPrefix)) { + yield* bucket.deleteObject(s3Key); + } + + // Update database + if (payload.type === "user") { + yield* db.use((db) => + db + .update(Db.users) + .set({ image: null }) + .where(Dz.eq(Db.users.id, User.UserId.make(payload.entityId))), + ); + } else { + yield* db.use((db) => + db + .update(Db.organizations) + .set({ iconUrl: null }) + .where( + Dz.eq( + Db.organizations.id, + Organisation.OrganisationId.make(payload.entityId), + ), + ), + ); + } + + return { success: true as const }; + }).pipe( + Effect.catchTag("S3Error", () => new InternalError({ type: "s3" })), + Effect.catchTag( + "DatabaseError", + () => new InternalError({ type: "database" }), + ), + Effect.catchAll(() => new InternalError({ type: "unknown" })), + ), }; }), -).pipe(Layer.provide(UsersOnboarding.Default)); +).pipe(Layer.provide([UsersOnboarding.Default, Database.Default])); diff --git a/packages/web-domain/src/User.ts b/packages/web-domain/src/User.ts index 7a0a1b2816..5b88c828ee 100644 --- a/packages/web-domain/src/User.ts +++ b/packages/web-domain/src/User.ts @@ -77,6 +77,29 @@ export const GetSignedImageUrlResult = Schema.Struct({ url: Schema.String, }); +export const UploadImagePayload = Schema.Struct({ + data: Schema.Uint8Array, + contentType: Schema.String, + fileName: Schema.String, + type: Schema.Literal("user", "organization"), + entityId: Schema.String, + oldImageKey: Schema.optional(Schema.NullOr(Schema.String)), +}); + +export const UploadImageResult = Schema.Struct({ + key: Schema.String, +}); + +export const RemoveImagePayload = Schema.Struct({ + imageKey: Schema.String, + type: Schema.Literal("user", "organization"), + entityId: Schema.String, +}); + +export const RemoveImageResult = Schema.Struct({ + success: Schema.Literal(true), +}); + export class UserRpcs extends RpcGroup.make( Rpc.make("UserCompleteOnboardingStep", { payload: OnboardingStepPayload, @@ -88,4 +111,14 @@ export class UserRpcs extends RpcGroup.make( success: GetSignedImageUrlResult, error: InternalError, }).middleware(RpcAuthMiddleware), + Rpc.make("UploadImage", { + payload: UploadImagePayload, + success: UploadImageResult, + error: InternalError, + }).middleware(RpcAuthMiddleware), + Rpc.make("RemoveImage", { + payload: RemoveImagePayload, + success: RemoveImageResult, + error: InternalError, + }).middleware(RpcAuthMiddleware), ) {} From 415103f4532db73300e6ac984518955c1becb1bb Mon Sep 17 00:00:00 2001 From: ameer2468 <33054370+ameer2468@users.noreply.github.com> Date: Tue, 21 Oct 2025 17:43:20 +0300 Subject: [PATCH 5/6] cleanup --- packages/web-backend/src/Users/UsersRpcs.ts | 73 +++++---------------- packages/web-backend/src/Users/helpers.ts | 36 ++++++++++ 2 files changed, 51 insertions(+), 58 deletions(-) create mode 100644 packages/web-backend/src/Users/helpers.ts diff --git a/packages/web-backend/src/Users/UsersRpcs.ts b/packages/web-backend/src/Users/UsersRpcs.ts index cf9272a762..f041827183 100644 --- a/packages/web-backend/src/Users/UsersRpcs.ts +++ b/packages/web-backend/src/Users/UsersRpcs.ts @@ -2,9 +2,9 @@ import * as Db from "@cap/database/schema"; import { InternalError, Organisation, User } from "@cap/web-domain"; import * as Dz from "drizzle-orm"; import { Effect, Layer, Option } from "effect"; -import * as path from "path"; import { Database } from "../Database"; import { S3Buckets } from "../S3Buckets"; +import { parseImageKey } from "./helpers"; import { UsersOnboarding } from "./UsersOnboarding"; export const UsersRpcsLive = User.UserRpcs.toLayer( @@ -62,42 +62,15 @@ export const UsersRpcsLive = User.UserRpcs.toLayer( ), UploadImage: (payload) => Effect.gen(function* () { + const oldS3KeyOption = yield* parseImageKey( + payload.oldImageKey, + payload.type, + ); const [bucket] = yield* s3Buckets.getBucketAccess(Option.none()); - // Delete old image if it exists - if (payload.oldImageKey) { - try { - // Extract the S3 key - handle both old URL format and new key format - let oldS3Key = payload.oldImageKey; - if ( - payload.oldImageKey.startsWith("http://") || - payload.oldImageKey.startsWith("https://") - ) { - const url = new URL(payload.oldImageKey); - const raw = url.pathname.startsWith("/") - ? url.pathname.slice(1) - : url.pathname; - const decoded = decodeURIComponent(raw); - const normalized = path.posix.normalize(decoded); - if (normalized.includes("..")) { - yield* Effect.fail(new InternalError({ type: "unknown" })); - } - oldS3Key = normalized; - } - - // Only delete if it looks like the correct type of image key - const expectedPrefix = - payload.type === "user" ? "users/" : "organizations/"; - if (oldS3Key.startsWith(expectedPrefix)) { - yield* bucket.deleteObject(oldS3Key); - } - } catch (error) { - // Continue with upload even if deletion fails - console.error( - `Error deleting old ${payload.type} image from S3:`, - error, - ); - } + // Delete old image if it exists and is valid + if (Option.isSome(oldS3KeyOption)) { + yield* bucket.deleteObject(oldS3KeyOption.value); } // Generate new S3 key @@ -144,31 +117,15 @@ export const UsersRpcsLive = User.UserRpcs.toLayer( ), RemoveImage: (payload) => Effect.gen(function* () { + const s3KeyOption = yield* parseImageKey( + payload.imageKey, + payload.type, + ); const [bucket] = yield* s3Buckets.getBucketAccess(Option.none()); - // Extract the S3 key - handle both old URL format and new key format - let s3Key = payload.imageKey; - if ( - payload.imageKey.startsWith("http://") || - payload.imageKey.startsWith("https://") - ) { - const url = new URL(payload.imageKey); - const raw = url.pathname.startsWith("/") - ? url.pathname.slice(1) - : url.pathname; - const decoded = decodeURIComponent(raw); - const normalized = path.posix.normalize(decoded); - if (normalized.includes("..")) { - yield* Effect.fail(new InternalError({ type: "unknown" })); - } - s3Key = normalized; - } - - // Only delete if it looks like the correct type of image key - const expectedPrefix = - payload.type === "user" ? "users/" : "organizations/"; - if (s3Key.startsWith(expectedPrefix)) { - yield* bucket.deleteObject(s3Key); + // Only delete if we have a valid S3 key + if (Option.isSome(s3KeyOption)) { + yield* bucket.deleteObject(s3KeyOption.value); } // Update database diff --git a/packages/web-backend/src/Users/helpers.ts b/packages/web-backend/src/Users/helpers.ts new file mode 100644 index 0000000000..4bb71240cf --- /dev/null +++ b/packages/web-backend/src/Users/helpers.ts @@ -0,0 +1,36 @@ +import { InternalError } from "@cap/web-domain"; +import { Effect, Option } from "effect"; +import * as path from "path"; + +export const parseImageKey = ( + imageKey: string | null | undefined, + expectedType: "user" | "organization", +): Effect.Effect, InternalError> => + Effect.gen(function* () { + // Return None if no image key provided + if (!imageKey || imageKey.trim() === "") { + return Option.none(); + } + + let s3Key = imageKey; + if (imageKey.startsWith("http://") || imageKey.startsWith("https://")) { + const url = new URL(imageKey); + const raw = url.pathname.startsWith("/") + ? url.pathname.slice(1) + : url.pathname; + const decoded = decodeURIComponent(raw); + const normalized = path.posix.normalize(decoded); + if (normalized.includes("..")) { + return yield* Effect.fail(new InternalError({ type: "unknown" })); + } + s3Key = normalized; + } + + const expectedPrefix = + expectedType === "user" ? "users/" : "organizations/"; + if (!s3Key.startsWith(expectedPrefix)) { + return Option.none(); + } + + return Option.some(s3Key); + }); From ce86b90d49149d3050fe236aa5f7b8a470a503a5 Mon Sep 17 00:00:00 2001 From: ameer2468 <33054370+ameer2468@users.noreply.github.com> Date: Tue, 21 Oct 2025 18:05:25 +0300 Subject: [PATCH 6/6] improve ui of FIleInput when uploading --- apps/web/components/FileInput.tsx | 37 ++++++++----------- packages/ui/src/components/LoadingSpinner.tsx | 8 +++- 2 files changed, 21 insertions(+), 24 deletions(-) diff --git a/apps/web/components/FileInput.tsx b/apps/web/components/FileInput.tsx index 0c0c1b0e25..25cdfe55d6 100644 --- a/apps/web/components/FileInput.tsx +++ b/apps/web/components/FileInput.tsx @@ -1,6 +1,6 @@ "use client"; -import { Button, Input } from "@cap/ui"; +import { Button, Input, LoadingSpinner } from "@cap/ui"; import { faCloudUpload, faSpinner, @@ -12,6 +12,7 @@ import type React from "react"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { useSignedImageUrl } from "@/lib/use-signed-image-url"; +import { SignedImageUrl } from "./SignedImageUrl"; import { Tooltip } from "./Tooltip"; const ALLOWED_IMAGE_TYPES = new Set(["image/jpeg", "image/png"]); @@ -61,13 +62,6 @@ export const FileInput: React.FC = ({ // Get signed URL for S3 keys const { data: signedUrl } = useSignedImageUrl(previewUrl, type); - function isS3Key(imageKeyOrUrl: string | null | undefined): boolean { - if (!imageKeyOrUrl) return false; - return ( - imageKeyOrUrl.startsWith("users/") || - imageKeyOrUrl.startsWith("organizations/") - ); - } const previousPreviewRef = useRef<{ url: string | null; isLocal: boolean; @@ -235,7 +229,12 @@ export const FileInput: React.FC = ({ }} > {/* Fixed height container to prevent resizing */} - {previewUrl ? ( + {isLoading ? ( +
+ +

Uploading...

+
+ ) : previewUrl ? (
@@ -250,19 +249,13 @@ export const FileInput: React.FC = ({ }} className="flex overflow-hidden relative flex-shrink-0 justify-center items-center rounded-full" > - {previewUrl && ( - File preview - )} +
diff --git a/packages/ui/src/components/LoadingSpinner.tsx b/packages/ui/src/components/LoadingSpinner.tsx index 4b03bae671..0168f1af66 100644 --- a/packages/ui/src/components/LoadingSpinner.tsx +++ b/packages/ui/src/components/LoadingSpinner.tsx @@ -2,6 +2,7 @@ export const LoadingSpinner = ({ size = 36, color = "white", borderColor = "rgba(255, 255, 255, 0.2)", + themeColors = false, thickness = 3, speed = 1.5, className, @@ -9,17 +10,20 @@ export const LoadingSpinner = ({ size?: number; color?: string; borderColor?: string; + themeColors?: boolean; thickness?: number; speed?: number; className?: string; }) => { + const borderColorValue = themeColors ? "var(--gray-4)" : borderColor; + const colorValue = themeColors ? "var(--gray-12)" : color; const spinnerStyle = { width: `${size}px`, minWidth: `${size}px`, height: `${size}px`, minHeight: `${size}px`, - border: `${thickness}px solid ${borderColor}`, - borderTop: `${thickness}px solid ${color}`, + border: `${thickness}px solid ${borderColorValue}`, + borderTop: `${thickness}px solid ${colorValue}`, borderRadius: "50%", animation: `spin ${1 / speed}s linear infinite`, };