Skip to content

Commit a25148a

Browse files
authored
Merge pull request #285 from GTBitsOfGood/joel/admin-email-flow
Complete the admin email sending flow
2 parents de534e6 + 2ebcbd5 commit a25148a

15 files changed

Lines changed: 869 additions & 151 deletions

‎api/emails.ts‎

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import { requireAdmin } from "@/lib/authUtils";
55
import { EmailService } from "@/lib/services/EmailService";
66

77
const sendEmailSchema = z.object({
8-
subject: z.string(),
9-
subtitle: z.string().optional(),
10-
body: z.string(),
11-
footer: z.string().optional(),
12-
recipientIds: z.array(z.string()),
8+
subject: z.string().trim().min(1, "Subject is required"),
9+
body: z.string().trim().min(1, "Message text is required"),
10+
subtitle: z.string().trim().optional(),
11+
footer: z.string().trim().optional(),
12+
recipientIds: z
13+
.array(z.string().min(1))
14+
.min(1, "Select at least one recipient"),
1315
});
1416

1517
function composeEmailBody({
@@ -29,14 +31,21 @@ function composeEmailBody({
2931

3032
const app = new Hono().post(
3133
"/",
32-
zValidator("json", sendEmailSchema),
34+
zValidator("json", sendEmailSchema, (result, c) => {
35+
if (!result.success) {
36+
return c.json(
37+
{ error: result.error.issues[0]?.message ?? "Invalid email" },
38+
400,
39+
);
40+
}
41+
}),
3342
async (c) => {
3443
const session = await requireAdmin(c);
3544
const organizationId = session.session.activeOrganizationId!;
3645
const { subject, subtitle, body, footer, recipientIds } =
3746
c.req.valid("json");
3847

39-
await EmailService.emailMembers(organizationId, {
48+
const result = await EmailService.emailMembers(organizationId, {
4049
subject,
4150
content: [
4251
{
@@ -47,6 +56,19 @@ const app = new Hono().post(
4756
targetUserIds: recipientIds,
4857
});
4958

59+
if (result === "no-recipients") {
60+
return c.json(
61+
{
62+
error: "None of the selected recipients belong to this organization",
63+
},
64+
400,
65+
);
66+
}
67+
68+
if (result === "delivery-failed") {
69+
return c.json({ error: "Failed to send email" }, 500);
70+
}
71+
5072
return c.json({ success: true });
5173
},
5274
);

‎app/inbox/page.tsx‎

Lines changed: 34 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"use client";
22

3-
import { useEffect, useMemo, useState } from "react";
3+
import { useMemo, useState } from "react";
44
import { DropdownMenu } from "radix-ui";
5+
import BogBanner from "@/components/bog/BogBanner/BogBanner";
56
import BogButton from "@/components/bog/BogButton/BogButton";
67
import BogIcon from "@/components/bog/BogIcon/BogIcon";
78
import BogTextInput from "@/components/bog/BogTextInput/BogTextInput";
@@ -12,8 +13,12 @@ import { cn } from "@/lib/utils";
1213
import authClient from "@/lib/authClient";
1314
import { useActiveOrganization } from "@/lib/hooks/useActiveOrganization";
1415
import { isAdmin } from "@/lib/clientUtils";
16+
import { useEmailRecipients } from "@/lib/hooks/useEmailRecipients";
1517
import SendEmailModal from "@/components/SendEmailModal";
16-
import api from "@/lib/api";
18+
import {
19+
EMAIL_SENT_MESSAGE,
20+
postOrganizationEmail,
21+
} from "@/lib/organizationEmail";
1722

1823
type FilterValue = "all" | NotificationType;
1924

@@ -83,6 +88,7 @@ export default function InboxPage() {
8388
const [search, setSearch] = useState("");
8489
const [filterType, setFilterType] = useState<FilterValue>("all");
8590
const [isModalOpen, setIsModalOpen] = useState(false);
91+
const [emailSent, setEmailSent] = useState(false);
8692

8793
const {
8894
allNotifications,
@@ -112,32 +118,8 @@ export default function InboxPage() {
112118
}, [notifications, search]);
113119

114120
const organizationId = organization?.data?.id;
115-
const [recipients, setRecipients] = useState<{ id: string; name: string }[]>(
116-
[],
117-
);
118-
119-
useEffect(() => {
120-
if (!organizationId) return;
121-
api.members
122-
.$get({ query: { page: "1", pageSize: "100" } })
123-
.then((res) => {
124-
if (res.ok) {
125-
return res.json();
126-
}
127-
throw new Error("Failed to fetch members");
128-
})
129-
.then((resJson) => {
130-
if ("data" in resJson) {
131-
setRecipients(
132-
resJson.data.map((m: { userId: string; name: string }) => ({
133-
id: m.userId,
134-
name: m.name,
135-
})),
136-
);
137-
}
138-
})
139-
.catch((err) => console.error(err));
140-
}, [organizationId]);
121+
const { recipients, recipientsLoading, recipientsError, retryRecipients } =
122+
useEmailRecipients(organizationId, isModalOpen && isAuthorizedAdmin);
141123

142124
if (isLoading) {
143125
return (
@@ -172,13 +154,25 @@ export default function InboxPage() {
172154
<BogButton
173155
variant="primary"
174156
size="medium"
175-
onClick={() => setIsModalOpen(true)}
157+
onClick={() => {
158+
setEmailSent(false);
159+
setIsModalOpen(true);
160+
}}
176161
>
177162
New +
178163
</BogButton>
179164
)}
180165
</div>
181166

167+
{emailSent && (
168+
<BogBanner
169+
type="success"
170+
variant="surface"
171+
role="status"
172+
content={<span>{EMAIL_SENT_MESSAGE}</span>}
173+
/>
174+
)}
175+
182176
<div className="flex flex-col gap-3 mobile:flex-row mobile:items-center mobile:justify-between">
183177
<div className="flex">
184178
<button
@@ -237,7 +231,7 @@ export default function InboxPage() {
237231
{FILTER_OPTIONS.map((option) => (
238232
<DropdownMenu.Item
239233
key={option.value}
240-
className="flex cursor-pointer items-center gap-3 px-4 py-2 text-paragraph-2 text-grey-text-strong outline-none data-[highlighted]:bg-grey-fill-weaker"
234+
className="flex cursor-pointer items-center gap-3 px-4 py-2 text-paragraph-2 text-grey-text-strong outline-none data-highlighted:bg-grey-fill-weaker"
241235
onSelect={() => setFilterType(option.value)}
242236
>
243237
<span
@@ -311,20 +305,19 @@ export default function InboxPage() {
311305
</div>
312306
</div>
313307
<SendEmailModal
314-
isOpen={isModalOpen}
308+
key={organizationId}
309+
isOpen={isModalOpen && isAuthorizedAdmin}
315310
onClose={() => setIsModalOpen(false)}
316311
recipients={recipients}
317-
onSend={async ({ subject, subtitle, body, footer, recipientIds }) => {
318-
if (!organizationId) return;
319-
const res = await api.emails.$post({
320-
json: { subject, subtitle, body, footer, recipientIds },
321-
});
322-
if (!res.ok) {
323-
const data = await res.json().catch(() => ({}));
324-
throw new Error(
325-
(data as { error?: string }).error ?? "Failed to send email",
326-
);
312+
recipientsLoading={recipientsLoading}
313+
recipientsError={recipientsError}
314+
onRetryRecipients={retryRecipients}
315+
onSend={async (values) => {
316+
if (!organizationId) {
317+
throw new Error("Failed to send email");
327318
}
319+
await postOrganizationEmail(values);
320+
setEmailSent(true);
328321
}}
329322
/>
330323
</div>

0 commit comments

Comments
 (0)