diff --git a/apps/web/app/api/google/threads/route.ts b/apps/web/app/api/google/threads/route.ts index 37d7642..4ec8ab5 100644 --- a/apps/web/app/api/google/threads/route.ts +++ b/apps/web/app/api/google/threads/route.ts @@ -13,6 +13,7 @@ import { getThreadsBatch, parseGmailApiResponse } from "@/utils/gmail/thread"; import { withError } from "@/utils/middleware"; import { createGmailLabel } from "./archive/controller"; import { getEmailSummary } from "@/utils/langbase"; +import { getWorkspaceForUser } from "@/utils/workspace"; export const dynamic = "force-dynamic"; @@ -26,6 +27,8 @@ const threadsQuery = z.object({ isTeam: z.coerce.boolean().nullish(), isCalendar: z.coerce.boolean().nullish(), isSent: z.coerce.boolean().nullish(), + workspaceId: z.string().nullish(), + emailAccountId: z.string().nullish(), }); export type ThreadsQuery = z.infer; export type ThreadsResponse = Awaited>; @@ -37,6 +40,25 @@ async function getThreads(query: ThreadsQuery) { if (!email) throw new Error("Not authenticated"); + if (query.workspaceId) { + const membership = await getWorkspaceForUser({ + userId: session.user.id, + workspaceId: query.workspaceId, + }); + if (!membership) throw new Error("Workspace not found"); + + const emailAccount = query.emailAccountId + ? membership.organization.emailAccounts.find( + (account) => account.id === query.emailAccountId, + ) + : membership.organization.emailAccounts[0]; + + if (!emailAccount) return { threads: [] }; + if (emailAccount.email.toLowerCase() !== email.toLowerCase()) { + return { threads: [] }; + } + } + const gmail = getGmailClient(session); const token = await getGmailAccessToken(session); const accessToken = token?.token; @@ -134,6 +156,8 @@ export const GET = withError(async (request: Request) => { const isTeam = searchParams.get("isTeam"); const isCalendar = searchParams.get("isCalendar"); const isSent = searchParams.get("isSent"); + const workspaceId = searchParams.get("workspaceId"); + const emailAccountId = searchParams.get("emailAccountId"); const query = threadsQuery.parse({ limit, fromEmail, @@ -142,6 +166,8 @@ export const GET = withError(async (request: Request) => { isTeam, isCalendar, isSent, + workspaceId, + emailAccountId, }); const threads = await getThreads(query); 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..6d22b75 --- /dev/null +++ b/apps/web/app/api/workspaces/[id]/members/route.ts @@ -0,0 +1,49 @@ +import { MembershipRole } from "@prisma/client"; +import { sendWorkspaceInviteEmail } from "@inboxzero/resend"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import { env } from "@/env.mjs"; +import { withError } from "@/utils/middleware"; +import prisma from "@/utils/prisma"; +import { inviteWorkspaceMember } from "@/utils/workspace"; + +const inviteMemberBody = z.object({ + email: z.string().trim().email(), + name: z.string().trim().max(80).optional(), + role: z.nativeEnum(MembershipRole).default(MembershipRole.USER), +}); + +export const POST = withError(async (request, { params }) => { + const session = await auth(); + if (!session?.user.email) throw new Error("Not authenticated"); + if (!params.id) throw new Error("Missing workspace id"); + + const body = inviteMemberBody.parse(await request.json()); + const workspace = await prisma.organization.findUniqueOrThrow({ + where: { id: params.id }, + select: { id: true, name: true }, + }); + + const invite = await inviteWorkspaceMember({ + workspaceId: workspace.id, + inviterUserId: session.user.id, + invitedEmail: body.email, + invitedName: body.name, + role: body.role, + }); + + await sendWorkspaceInviteEmail({ + to: body.email, + emailProps: { + baseUrl: env.NEXT_PUBLIC_BASE_URL, + invitedByName: session.user.name || session.user.email, + workspaceName: workspace.name, + inviteUrl: invite.invitedEmail + ? `${env.NEXT_PUBLIC_BASE_URL}/api/workspaces/invitations/${invite.id}/accept` + : `${env.NEXT_PUBLIC_BASE_URL}/mail`, + }, + }); + + return NextResponse.json({ member: invite }); +}); diff --git a/apps/web/app/api/workspaces/invitations/[id]/accept/route.ts b/apps/web/app/api/workspaces/invitations/[id]/accept/route.ts new file mode 100644 index 0000000..fb9668b --- /dev/null +++ b/apps/web/app/api/workspaces/invitations/[id]/accept/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import { env } from "@/env.mjs"; +import { withError } from "@/utils/middleware"; +import { acceptWorkspaceInvitation } from "@/utils/workspace"; + +export const GET = withError(async (_request, { params }) => { + const session = await auth(); + if (!session?.user.email) { + return NextResponse.redirect(`${env.NEXT_PUBLIC_BASE_URL}/login`); + } + + if (!params.id) throw new Error("Missing invitation id"); + + await acceptWorkspaceInvitation({ + invitationId: params.id, + userId: session.user.id, + email: session.user.email, + }); + + return NextResponse.redirect(`${env.NEXT_PUBLIC_BASE_URL}/mail`); +}); diff --git a/apps/web/app/api/workspaces/route.ts b/apps/web/app/api/workspaces/route.ts new file mode 100644 index 0000000..8d84088 --- /dev/null +++ b/apps/web/app/api/workspaces/route.ts @@ -0,0 +1,99 @@ +import { MembershipRole } from "@prisma/client"; +import { sendWorkspaceInviteEmail } from "@inboxzero/resend"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import { env } from "@/env.mjs"; +import { withError } from "@/utils/middleware"; +import { + createWorkspaceForUser, + ensurePersonalWorkspaceForUser, + getWorkspacesForUser, + inviteWorkspaceMember, +} from "@/utils/workspace"; + +const createWorkspaceBody = z.object({ + name: z.string().trim().min(1).max(80), + image: z.string().url().nullish(), + inviteEmail: z.string().trim().email().optional().or(z.literal("")), + inviteName: z.string().trim().max(80).optional(), + inviteRole: z.nativeEnum(MembershipRole).optional(), +}); + +export type WorkspacesResponse = Awaited>; +export type CreateWorkspaceResponse = Awaited< + ReturnType +>; + +async function getWorkspaces() { + const session = await auth(); + if (!session?.user.email) throw new Error("Not authenticated"); + + await ensurePersonalWorkspaceForUser({ + userId: session.user.id, + email: session.user.email, + name: session.user.name, + image: session.user.image, + }); + + const workspaces = await getWorkspacesForUser(session.user.id); + return { workspaces }; +} + +async function createWorkspace(options: z.infer) { + const session = await auth(); + if (!session?.user.email) throw new Error("Not authenticated"); + + const workspace = await createWorkspaceForUser({ + userId: session.user.id, + email: session.user.email, + userName: session.user.name, + name: options.name, + image: options.image || null, + }); + + if (options.inviteEmail) { + const invite = await inviteWorkspaceMember({ + workspaceId: workspace.id, + inviterUserId: session.user.id, + invitedEmail: options.inviteEmail, + invitedName: options.inviteName, + role: options.inviteRole || MembershipRole.USER, + }); + + await sendWorkspaceInviteEmail({ + to: options.inviteEmail, + emailProps: { + baseUrl: env.NEXT_PUBLIC_BASE_URL, + invitedByName: session.user.name || session.user.email, + workspaceName: workspace.name, + inviteUrl: invite.invitedEmail + ? `${env.NEXT_PUBLIC_BASE_URL}/api/workspaces/invitations/${invite.id}/accept` + : `${env.NEXT_PUBLIC_BASE_URL}/mail`, + }, + }); + } + + return { + workspace: { + id: workspace.id, + name: workspace.name, + image: workspace.image, + role: MembershipRole.OWNER, + emailAccounts: workspace.emailAccounts.map((account) => ({ + id: account.id, + name: account.name, + email: account.email, + })), + }, + }; +} + +export const GET = withError(async () => { + return NextResponse.json(await getWorkspaces()); +}); + +export const POST = withError(async (request: Request) => { + const body = createWorkspaceBody.parse(await request.json()); + return NextResponse.json(await createWorkspace(body)); +}); diff --git a/apps/web/components/mail/components/account-switcher.tsx b/apps/web/components/mail/components/account-switcher.tsx index 3a94fb6..1854c70 100644 --- a/apps/web/components/mail/components/account-switcher.tsx +++ b/apps/web/components/mail/components/account-switcher.tsx @@ -14,22 +14,65 @@ import { interface AccountSwitcherProps { isCollapsed: boolean; accounts: { + id?: string; label: string; email: string; icon: React.ReactNode; }[]; + selectedAccountId?: string | null; + onSelectAccount?: (accountId: string) => void; } export function AccountSwitcher({ isCollapsed, accounts, + selectedAccountId, + onSelectAccount, }: AccountSwitcherProps) { - const [selectedAccount, setSelectedAccount] = React.useState( - accounts[0].email, + const fallbackAccountId = accounts[0]?.id || accounts[0]?.email; + const [localSelectedAccount, setLocalSelectedAccount] = React.useState< + string | undefined + >(fallbackAccountId); + const selectedAccount = selectedAccountId || localSelectedAccount; + + React.useEffect(() => { + if (!selectedAccount && fallbackAccountId) { + setLocalSelectedAccount(fallbackAccountId); + onSelectAccount?.(fallbackAccountId); + } + }, [fallbackAccountId, onSelectAccount, selectedAccount]); + + if (!accounts.length) { + return ( + + ); + } + + const selectedValue = + selectedAccount || fallbackAccountId || accounts[0].email; + const selectedAccountDetails = accounts.find( + (account) => (account.id || account.email) === selectedValue, ); return ( - { + setLocalSelectedAccount(accountId); + onSelectAccount?.(accountId); + }} + > span]:line-clamp-1 [&>span]:flex [&>span]:w-full [&>span]:items-center [&>span]:gap-1 [&>span]:truncate [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0", @@ -39,18 +82,18 @@ export function AccountSwitcher({ aria-label="Select account" > - {accounts.find((account) => account.email === selectedAccount)?.icon} + {selectedAccountDetails?.icon} - { - accounts.find((account) => account.email === selectedAccount) - ?.label - } + {selectedAccountDetails?.label} {accounts.map((account) => ( - +
{account.icon} {account.email} diff --git a/apps/web/components/mail/components/mail.tsx b/apps/web/components/mail/components/mail.tsx index 19cac9b..f31219f 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,12 +10,10 @@ import { Gauge, Inbox as InboxIcon, Loader2, - MessagesSquare, Newspaper, Pencil, Plus, Send, - ShoppingCart, Star, Trash2, Users2, @@ -32,25 +29,22 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import { - ResizableHandle, - ResizablePanel, - ResizablePanelGroup, -} from "@/components/ui/resizable"; -import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; +import { useAtom } from "jotai"; import useSWR from "swr"; import { - configAtom, openComposeAtom, openCreateWorkspaceOpenAtom, + selectedEmailAccountIdAtom, + selectedWorkspaceIdAtom, tabAtom, threadsAtom, } from "@/utils/store"; -import { ProfileDropdown } from "@/components/TopNav"; +import type { WorkspaceSummary } from "@/utils/workspace"; 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, @@ -58,7 +52,7 @@ import { DialogFooter, DialogHeader, } from "@/components/ui/dialog"; -import { DialogDescription, DialogTitle } from "@radix-ui/react-dialog"; +import { DialogTitle } from "@radix-ui/react-dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { SingleImageDropzone } from "@/components/ui/single-image-dropzone"; @@ -80,13 +74,51 @@ export function Mail({ accounts, defaultLayout = [225, 440, 655], defaultCollapsed = false, - navCollapsedSize, }: MailProps) { + const [selectedWorkspaceId, setSelectedWorkspaceId] = useAtom( + selectedWorkspaceIdAtom, + ); + const [selectedEmailAccountId, setSelectedEmailAccountId] = useAtom( + selectedEmailAccountIdAtom, + ); + const { data: workspacesData, mutate: mutateWorkspaces } = useSWR<{ + workspaces: WorkspaceSummary[]; + }>("/api/workspaces", { + keepPreviousData: true, + }); + const workspaces = React.useMemo( + () => workspacesData?.workspaces ?? [], + [workspacesData], + ); + const selectedWorkspace = + workspaces.find((workspace) => workspace.id === selectedWorkspaceId) ?? + null; + const selectedWorkspaceHasAccount = selectedWorkspace?.emailAccounts.some( + (account) => account.id === selectedEmailAccountId, + ); + const workspaceQuery = React.useMemo(() => { + const params = new URLSearchParams(); + if (selectedWorkspaceId) params.set("workspaceId", selectedWorkspaceId); + if (selectedEmailAccountId) + params.set("emailAccountId", selectedEmailAccountId); + return params; + }, [selectedEmailAccountId, selectedWorkspaceId]); + const threadsUrl = React.useCallback( + (filters: Record = {}) => { + const params = new URLSearchParams(workspaceQuery); + Object.entries(filters).forEach(([key, value]) => { + params.set(key, String(value)); + }); + const query = params.toString(); + return `/api/google/threads${query ? `?${query}` : ""}`; + }, + [workspaceQuery], + ); const { data: threadsData, error: threadsError, isLoading: threadsLoading, - } = useSWR("/api/google/threads", { + } = useSWR(threadsUrl(), { keepPreviousData: true, }); @@ -94,7 +126,7 @@ export function Mail({ data: doneEmailsData, error: doneEmailsError, isLoading: doneEmailsLoading, - } = useSWR("/api/google/threads?isDone=true", { + } = useSWR(threadsUrl({ isDone: true }), { keepPreviousData: true, }); @@ -102,7 +134,7 @@ export function Mail({ data: teamEmailsData, error: teamEmailsError, isLoading: teamEmailsLoading, - } = useSWR("/api/google/threads?isTeam=true", { + } = useSWR(threadsUrl({ isTeam: true }), { keepPreviousData: true, }); @@ -110,7 +142,7 @@ export function Mail({ data: calendarEmailsData, error: calendarEmailsError, isLoading: calendarEmailsLoading, - } = useSWR("/api/google/threads?isCalendar=true", { + } = useSWR(threadsUrl({ isCalendar: true }), { keepPreviousData: true, }); @@ -118,15 +150,22 @@ export function Mail({ data: sentEmailsData, error: sentEmailsError, isLoading: sentEmailsLoading, - } = useSWR("/api/google/threads?isSent=true", { + } = useSWR(threadsUrl({ isSent: true }), { keepPreviousData: true, }); const [isCollapsed, setIsCollapsed] = React.useState(defaultCollapsed); const [file, setFile] = React.useState(); const [isUploading, setIsUploading] = React.useState(false); + const [workspaceName, setWorkspaceName] = React.useState(""); + const [inviteEmail, setInviteEmail] = React.useState(""); + const [inviteName, setInviteName] = React.useState(""); + const [inviteRole, setInviteRole] = React.useState<"ADMIN" | "USER">("USER"); + const [createWorkspaceError, setCreateWorkspaceError] = React.useState< + string | null + >(null); const [selectedTab, setSelectedTab] = useAtom(tabAtom); - const [composeOpen, setComposeOpen] = useAtom(openComposeAtom); + const [, setComposeOpen] = useAtom(openComposeAtom); const [stateThreadsData, setStateThreadsData] = useAtom(threadsAtom); const [createWorkspaceOpen, setCreateWorkspaceOpen] = useAtom( openCreateWorkspaceOpenAtom, @@ -134,7 +173,96 @@ export function Mail({ const { edgestore } = useEdgeStore(); - const mail = useAtomValue(configAtom); + const workspaceAccounts = React.useMemo(() => { + if (!selectedWorkspace) return accounts; + + return selectedWorkspace.emailAccounts.map((account) => ({ + id: account.id, + label: account.name, + email: account.email, + icon: , + })); + }, [accounts, selectedWorkspace]); + + React.useEffect(() => { + if (!workspaces.length) { + setSelectedWorkspaceId(null); + return; + } + + if ( + !selectedWorkspaceId || + !workspaces.some((workspace) => workspace.id === selectedWorkspaceId) + ) { + setSelectedWorkspaceId(workspaces[0].id); + } + }, [selectedWorkspaceId, setSelectedWorkspaceId, workspaces]); + + React.useEffect(() => { + if (!selectedWorkspace) { + setSelectedEmailAccountId(null); + return; + } + + const firstAccountId = selectedWorkspace.emailAccounts[0]?.id ?? null; + if (!selectedEmailAccountId || !selectedWorkspaceHasAccount) { + setSelectedEmailAccountId(firstAccountId); + } + }, [ + selectedEmailAccountId, + selectedWorkspace, + selectedWorkspaceHasAccount, + setSelectedEmailAccountId, + ]); + + async function handleCreateWorkspace() { + setCreateWorkspaceError(null); + if (!workspaceName.trim()) { + setCreateWorkspaceError("Workspace name is required."); + return; + } + + try { + setIsUploading(true); + let image: string | undefined; + + if (file) { + const res = await edgestore.publicFiles.upload({ + file, + }); + image = res.url; + } + + const response = await fetch("/api/workspaces", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: workspaceName, + image, + inviteEmail, + inviteName, + inviteRole, + }), + }); + + if (!response.ok) throw new Error("Could not create workspace"); + + const result = await response.json(); + await mutateWorkspaces(); + setSelectedWorkspaceId(result.workspace.id); + setSelectedEmailAccountId(result.workspace.emailAccounts[0]?.id ?? null); + setWorkspaceName(""); + setInviteEmail(""); + setInviteName(""); + setInviteRole("USER"); + setFile(undefined); + setCreateWorkspaceOpen(false); + } catch (error) { + setCreateWorkspaceError((error as Error).message); + } finally { + setIsUploading(false); + } + } React.useEffect(() => { if (threadsData) { @@ -243,10 +371,14 @@ export function Mail({ )}`; }} > - + setCreateWorkspaceOpen(!createWorkspaceOpen)} + onOpenChange={setCreateWorkspaceOpen} > @@ -267,12 +399,50 @@ export function Mail({
- + setWorkspaceName(event.target.value)} + />
- - + + setInviteEmail(event.target.value)} + placeholder="teammate@example.com" + />
+
+
+ + setInviteName(event.target.value)} + /> +
+
+ + +
+
+ {createWorkspaceError && ( +

{createWorkspaceError}

+ )}
@@ -283,24 +453,8 @@ export function Mail({ Cancel
diff --git a/apps/web/components/mail/components/workspace-sidebar.tsx b/apps/web/components/mail/components/workspace-sidebar.tsx index a8d6cbc..c2f8886 100644 --- a/apps/web/components/mail/components/workspace-sidebar.tsx +++ b/apps/web/components/mail/components/workspace-sidebar.tsx @@ -7,6 +7,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { openCreateWorkspaceOpenAtom } from "@/utils/store"; +import type { WorkspaceSummary } from "@/utils/workspace"; import { useSetAtom } from "jotai"; import { Plus } from "lucide-react"; import { signOut, useSession } from "next-auth/react"; @@ -21,24 +22,52 @@ const userNavigation = [ }, ]; -export function WorkspaceSidebar() { - const { data: session, status } = useSession(); +function getInitials(name: string) { + return name + .split(" ") + .map((part) => part[0]) + .join("") + .slice(0, 2) + .toUpperCase(); +} + +export function WorkspaceSidebar({ + workspaces, + selectedWorkspaceId, + onSelectWorkspace, +}: { + workspaces: WorkspaceSummary[]; + selectedWorkspaceId: string | null; + onSelectWorkspace: (workspaceId: string) => void; +}) { + const { data: session } = useSession(); const setCreateWorkspaceOpen = useSetAtom(openCreateWorkspaceOpenAtom); return (
- - + {workspaces.map((workspace) => ( + + ))} + + + If the button does not work, copy and paste this link into your + browser: {inviteUrl} + + + + + + + ); +} + +const paragraph = { + fontSize: "16px", + lineHeight: "26px", +}; diff --git a/packages/resend/src/index.tsx b/packages/resend/src/index.tsx index 4f4b82a..a517aaa 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", + }, + ], + }); +};