From 10a01ac46c2ebdda0610ae2bf1028d9c615a4c4e Mon Sep 17 00:00:00 2001 From: Guillem Date: Mon, 11 May 2026 19:58:36 +0200 Subject: [PATCH 1/2] feat: persist workspace creation and invites --- .../app/api/workspaces/[id]/members/route.ts | 69 +++++++ apps/web/app/api/workspaces/route.ts | 47 +++++ apps/web/components/mail/components/mail.tsx | 186 ++++++++++++++---- .../mail/components/workspace-sidebar.tsx | 106 ++++++++-- apps/web/utils/auth.ts | 5 + apps/web/utils/store.ts | 2 + apps/web/utils/workspace.ts | 153 ++++++++++++++ 7 files changed, 512 insertions(+), 56 deletions(-) create mode 100644 apps/web/app/api/workspaces/[id]/members/route.ts create mode 100644 apps/web/app/api/workspaces/route.ts create mode 100644 apps/web/utils/workspace.ts diff --git a/apps/web/app/api/workspaces/[id]/members/route.ts b/apps/web/app/api/workspaces/[id]/members/route.ts new file mode 100644 index 0000000..a871f38 --- /dev/null +++ b/apps/web/app/api/workspaces/[id]/members/route.ts @@ -0,0 +1,69 @@ +import { MembershipRole } from "@prisma/client"; +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; + +import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import { withError } from "@/utils/middleware"; +import prisma from "@/utils/prisma"; +import { canManageWorkspace, getWorkspacesForUser } from "@/utils/workspace"; + +const inviteMemberBody = z.object({ + email: z.string().trim().email().max(255), + name: z.string().trim().max(80).optional(), + role: z.nativeEnum(MembershipRole).default(MembershipRole.USER), +}); + +export const POST = withError( + async ( + request: NextRequest, + { params }: { params: { id?: string | undefined } }, + ) => { + const session = await auth(); + if (!session?.user.id) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + const organizationId = params.id; + if (!organizationId) { + return NextResponse.json( + { error: "Missing workspace id" }, + { status: 400 }, + ); + } + + const canManage = await canManageWorkspace({ + userId: session.user.id, + organizationId, + }); + if (!canManage) { + return NextResponse.json( + { error: "You do not have permission to manage this workspace" }, + { status: 403 }, + ); + } + + const body = inviteMemberBody.parse(await request.json()); + await prisma.membership.upsert({ + where: { + organizationId_invitedEmail: { + organizationId, + invitedEmail: body.email, + }, + }, + update: { + invitedName: body.name, + role: body.role, + }, + create: { + organizationId, + invitedEmail: body.email, + invitedName: body.name, + role: body.role, + }, + }); + + return NextResponse.json({ + workspaces: await getWorkspacesForUser(session.user.id), + }); + }, +); diff --git a/apps/web/app/api/workspaces/route.ts b/apps/web/app/api/workspaces/route.ts new file mode 100644 index 0000000..40c7d60 --- /dev/null +++ b/apps/web/app/api/workspaces/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; + +import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import { withError } from "@/utils/middleware"; +import { + createWorkspaceForUser, + getWorkspacesForUser, +} from "@/utils/workspace"; + +const createWorkspaceBody = z.object({ + name: z.string().trim().min(1).max(80), + image: z.string().url().nullish(), +}); + +export type WorkspacesResponse = Awaited>; + +async function workspacesResponse(userId: string, workspaceId?: string) { + return { workspaces: await getWorkspacesForUser(userId), workspaceId }; +} + +export const GET = withError(async () => { + const session = await auth(); + if (!session?.user.id) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + return NextResponse.json(await workspacesResponse(session.user.id)); +}); + +export const POST = withError(async (request: NextRequest) => { + const session = await auth(); + if (!session?.user.id) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + const body = createWorkspaceBody.parse(await request.json()); + const workspaceId = await createWorkspaceForUser({ + userId: session.user.id, + name: body.name, + image: body.image, + }); + + return NextResponse.json( + await workspacesResponse(session.user.id, workspaceId), + ); +}); diff --git a/apps/web/components/mail/components/mail.tsx b/apps/web/components/mail/components/mail.tsx index 19cac9b..d0d2a48 100644 --- a/apps/web/components/mail/components/mail.tsx +++ b/apps/web/components/mail/components/mail.tsx @@ -1,7 +1,6 @@ "use client"; import * as React from "react"; import { - AlertCircle, Archive, ArchiveX, Building, @@ -11,15 +10,12 @@ import { Gauge, Inbox as InboxIcon, Loader2, - MessagesSquare, Newspaper, Pencil, Plus, Send, - ShoppingCart, Star, Trash2, - Users2, } from "lucide-react"; import { AccountSwitcher } from "./account-switcher"; @@ -32,38 +28,43 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; +import { useAtom } from "jotai"; +import useSWR, { useSWRConfig } from "swr"; import { - ResizableHandle, - ResizablePanel, - ResizablePanelGroup, -} from "@/components/ui/resizable"; -import { useAtom, useAtomValue, useSetAtom } from "jotai"; -import useSWR from "swr"; -import { - configAtom, openComposeAtom, openCreateWorkspaceOpenAtom, + selectedWorkspaceIdAtom, tabAtom, threadsAtom, } from "@/utils/store"; -import { ProfileDropdown } from "@/components/TopNav"; import { Inbox } from "@/components/mail/components/inbox"; import { Newsletters } from "@/components/mail/components/newsletters"; import { MailStats } from "@/components/mail/components/mail-stats"; -import { Button, ButtonLoader } from "@/components/ui/button"; +import { Button } from "@/components/ui/button"; import { WorkspaceSidebar } from "./workspace-sidebar"; import { Dialog, DialogContent, DialogFooter, DialogHeader, + DialogTitle, } from "@/components/ui/dialog"; -import { DialogDescription, DialogTitle } from "@radix-ui/react-dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { SingleImageDropzone } from "@/components/ui/single-image-dropzone"; import { useEdgeStore } from "@/utils/edgestore"; import { ArrowLeftIcon, ArrowRightIcon } from "@radix-ui/react-icons"; +import type { WorkspacesResponse } from "@/app/api/workspaces/route"; + +type InviteRole = "ADMIN" | "USER"; interface MailProps { accounts: { @@ -125,16 +126,22 @@ export function Mail({ const [isCollapsed, setIsCollapsed] = React.useState(defaultCollapsed); const [file, setFile] = React.useState(); const [isUploading, setIsUploading] = React.useState(false); + const [workspaceName, setWorkspaceName] = React.useState(""); + const [workspaceError, setWorkspaceError] = React.useState( + null, + ); + const [inviteEmail, setInviteEmail] = React.useState(""); + const [inviteRole, setInviteRole] = React.useState("USER"); const [selectedTab, setSelectedTab] = useAtom(tabAtom); - const [composeOpen, setComposeOpen] = useAtom(openComposeAtom); + const [, setComposeOpen] = useAtom(openComposeAtom); const [stateThreadsData, setStateThreadsData] = useAtom(threadsAtom); + const [, setSelectedWorkspaceId] = useAtom(selectedWorkspaceIdAtom); const [createWorkspaceOpen, setCreateWorkspaceOpen] = useAtom( openCreateWorkspaceOpenAtom, ); const { edgestore } = useEdgeStore(); - - const mail = useAtomValue(configAtom); + const { mutate } = useSWRConfig(); React.useEffect(() => { if (threadsData) { @@ -149,6 +156,81 @@ export function Mail({ setIsCollapsed(!isCollapsed); }; + const resetWorkspaceDialog = () => { + setFile(undefined); + setWorkspaceName(""); + setInviteEmail(""); + setInviteRole("USER"); + setWorkspaceError(null); + }; + + const createWorkspace = async () => { + const name = workspaceName.trim(); + if (!name) return; + + try { + setIsUploading(true); + setWorkspaceError(null); + + let image: string | null = null; + if (file) { + const upload = await edgestore.publicFiles.upload({ file }); + image = upload.url; + } + + const response = await fetch("/api/workspaces", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, image }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error?.error || "Could not create workspace"); + } + + let data = (await response.json()) as WorkspacesResponse; + const createdWorkspace = data.workspaces?.find( + (workspace: { name: string }) => workspace.name === name, + ); + const workspaceId = data.workspaceId ?? createdWorkspace?.id; + + if (workspaceId && inviteEmail.trim()) { + const inviteResponse = await fetch( + `/api/workspaces/${encodeURIComponent(workspaceId)}/members`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: inviteEmail.trim(), + role: inviteRole, + }), + }, + ); + + if (!inviteResponse.ok) { + const error = await inviteResponse.json(); + throw new Error(error?.error || "Could not invite teammate"); + } + + data = (await inviteResponse.json()) as WorkspacesResponse; + } + + if (workspaceId) { + setSelectedWorkspaceId(workspaceId); + window.localStorage.setItem("caley:selected-workspace", workspaceId); + } + + await mutate("/api/workspaces", data, { revalidate: false }); + setCreateWorkspaceOpen(false); + resetWorkspaceDialog(); + } catch (error) { + setWorkspaceError((error as Error).message); + } finally { + setIsUploading(false); + } + }; + const returnTab = () => { switch (selectedTab) { case "Inbox": @@ -246,7 +328,10 @@ export function Mail({ setCreateWorkspaceOpen(!createWorkspaceOpen)} + onOpenChange={(open) => { + setCreateWorkspaceOpen(open); + if (!open) resetWorkspaceDialog(); + }} > @@ -267,12 +352,47 @@ export function Mail({
- + setWorkspaceName(event.target.value)} + placeholder="Acme Support" + />
-
- - +
+
+ + setInviteEmail(event.target.value)} + placeholder="teammate@example.com" + /> +
+
+ + +
+ {workspaceError && ( +

{workspaceError}

+ )}
@@ -283,24 +403,8 @@ export function Mail({ Cancel - + {isLoading && ( +
+ +
+ )} + {workspaces.map((workspace) => { + const initials = workspace.name + .split(" ") + .map((part) => part[0]) + .join("") + .slice(0, 2) + .toUpperCase(); + const isSelected = workspace.id === selectedWorkspaceId; + + return ( + + ); + })} + + + If you were not expecting this invitation, you can ignore this + email. + + + + + + + ); +} + +const paragraph = { + fontSize: "14px", + lineHeight: "24px", +}; diff --git a/packages/resend/src/index.tsx b/packages/resend/src/index.tsx index 4f4b82a..d961b1b 100644 --- a/packages/resend/src/index.tsx +++ b/packages/resend/src/index.tsx @@ -2,6 +2,9 @@ import { JSXElementConstructor, ReactElement } from "react"; import { Resend } from "resend"; import { nanoid } from "nanoid"; import StatsUpdateEmail, { StatsUpdateEmailProps } from "../emails/stats"; +import WorkspaceInviteEmail, { + WorkspaceInviteEmailProps, +} from "../emails/workspace-invite"; const resend = process.env.RESEND_API_KEY ? new Resend(process.env.RESEND_API_KEY) @@ -71,3 +74,26 @@ export const sendStatsEmail = async ({ ], }); }; + +export const sendWorkspaceInviteEmail = async ({ + to, + test, + emailProps, +}: { + to: string; + test?: boolean; + emailProps: WorkspaceInviteEmailProps; +}) => { + return sendEmail({ + to, + subject: `Join ${emailProps.workspaceName} on Caley.io`, + react: , + test, + tags: [ + { + name: "category", + value: "workspace_invite", + }, + ], + }); +};