From f7a9a9690992b10b4e4f15b53094889ecee296b4 Mon Sep 17 00:00:00 2001
From: trickkyricky
Date: Sun, 12 Apr 2026 21:33:59 -0500
Subject: [PATCH 1/9] feat: email notifications for discussions and
announcements
---
e2e/tests/06-announcement-emails.spec.ts | 102 +++++++
e2e/tests/07-discussion-emails.spec.ts | 100 +++++++
src/app/announcements/announcements-grid.tsx | 9 +
src/app/api/announcement-emails/route.test.ts | 276 ++++++++++++++++++
src/app/api/announcement-emails/route.ts | 91 ++++++
src/app/api/discussion-emails/route.test.ts | 262 +++++++++++++++++
src/app/api/discussion-emails/route.ts | 87 ++++++
src/app/discussion/discussion-grid.tsx | 11 +-
src/emails/AnnouncementNotification.tsx | 60 ++++
src/emails/NewThreadNotification.tsx | 70 +++++
10 files changed, 1067 insertions(+), 1 deletion(-)
create mode 100644 e2e/tests/06-announcement-emails.spec.ts
create mode 100644 e2e/tests/07-discussion-emails.spec.ts
create mode 100644 src/app/api/announcement-emails/route.test.ts
create mode 100644 src/app/api/announcement-emails/route.ts
create mode 100644 src/app/api/discussion-emails/route.test.ts
create mode 100644 src/app/api/discussion-emails/route.ts
create mode 100644 src/emails/AnnouncementNotification.tsx
create mode 100644 src/emails/NewThreadNotification.tsx
diff --git a/e2e/tests/06-announcement-emails.spec.ts b/e2e/tests/06-announcement-emails.spec.ts
new file mode 100644
index 0000000..c258cd8
--- /dev/null
+++ b/e2e/tests/06-announcement-emails.spec.ts
@@ -0,0 +1,102 @@
+/**
+ * Test 06 — Admin creates an announcement and email notification is triggered
+ *
+ * Steps:
+ * 1. Admin navigates to /announcements
+ * 2. Clicks "New Announcement"
+ * 3. Fills title, content, and group target
+ * 4. Submits — POST /api/admin-announcements is called
+ * 5. POST /api/announcement-emails is triggered with the new announcement ID
+ * 6. New announcement row appears in the grid
+ */
+
+import { test, expect } from '@playwright/test';
+import '../load-env';
+import { PrismaClient } from '@prisma/client';
+import { E2E_PREFIX } from '../shared-state';
+
+test.use({ storageState: 'e2e/.auth/admin.json' });
+test.describe.configure({ mode: 'serial' });
+
+const TEST_ANNOUNCEMENT_TITLE = `${E2E_PREFIX} Test Announcement`;
+const TEST_ANNOUNCEMENT_CONTENT =
+ 'This is an automated E2E test announcement. Please ignore.';
+
+test.afterAll(async () => {
+ const prisma = new PrismaClient();
+ try {
+ await prisma.announcement.deleteMany({
+ where: { title: { startsWith: E2E_PREFIX } },
+ });
+ } finally {
+ await prisma.$disconnect();
+ }
+});
+
+test('admin navigates to /announcements page', async ({ page }) => {
+ await page.goto('/announcements');
+ await expect(
+ page.getByRole('heading', { name: /announcement system/i })
+ ).toBeVisible();
+});
+
+test('admin creates an announcement and email endpoint is called', async ({
+ page,
+}) => {
+ let announcementEmailCalled = false;
+ let capturedAnnouncementId: string | undefined;
+
+ // Capture the announcement ID from the create API response
+ await page.route('**/api/admin-announcements', async (route) => {
+ if (route.request().method() !== 'POST') {
+ return route.continue();
+ }
+ const response = await route.fetch();
+ const body = await response.json();
+ if (body?.id) {
+ capturedAnnouncementId = body.id as string;
+ }
+ await route.fulfill({ response });
+ });
+
+ // Mock the email endpoint so no real emails are sent
+ await page.route('**/api/announcement-emails', async (route) => {
+ announcementEmailCalled = true;
+ const requestBody = route.request().postDataJSON();
+ expect(requestBody.announcementId).toBeTruthy();
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ success: true, sent: 2 }),
+ });
+ });
+
+ await page.goto('/announcements');
+
+ // Open the create dialog
+ await page.getByRole('button', { name: /new announcement/i }).click();
+
+ await expect(
+ page.getByRole('heading', { name: /create new announcement/i })
+ ).toBeVisible();
+
+ // Fill the form
+ await page
+ .getByPlaceholder('Enter announcement title')
+ .fill(TEST_ANNOUNCEMENT_TITLE);
+ await page
+ .getByPlaceholder('Enter announcement content')
+ .fill(TEST_ANNOUNCEMENT_CONTENT);
+
+ // Submit
+ await page.getByRole('button', { name: 'Create' }).click();
+
+ // Wait for the row to appear in the grid
+ await expect(page.getByText(TEST_ANNOUNCEMENT_TITLE)).toBeVisible({
+ timeout: 8_000,
+ });
+
+ // Verify the email endpoint was triggered
+ expect(announcementEmailCalled).toBe(true);
+ expect(capturedAnnouncementId).toBeTruthy();
+});
diff --git a/e2e/tests/07-discussion-emails.spec.ts b/e2e/tests/07-discussion-emails.spec.ts
new file mode 100644
index 0000000..b5d71fa
--- /dev/null
+++ b/e2e/tests/07-discussion-emails.spec.ts
@@ -0,0 +1,100 @@
+/**
+ * Test 07 — User creates a discussion thread and email notification is triggered
+ *
+ * Steps:
+ * 1. Admin navigates to /discussion
+ * 2. Clicks "New Thread"
+ * 3. Fills title, content, and group target
+ * 4. Submits — POST /api/threads is called
+ * 5. POST /api/discussion-emails is triggered with the new thread ID
+ * 6. New thread row appears in the grid
+ */
+
+import { test, expect } from '@playwright/test';
+import '../load-env';
+import { PrismaClient } from '@prisma/client';
+import { E2E_PREFIX } from '../shared-state';
+
+test.use({ storageState: 'e2e/.auth/admin.json' });
+test.describe.configure({ mode: 'serial' });
+
+const TEST_THREAD_TITLE = `${E2E_PREFIX} Test Discussion Thread`;
+const TEST_THREAD_CONTENT =
+ 'This is an automated E2E test discussion thread. Please ignore.';
+
+test.afterAll(async () => {
+ const prisma = new PrismaClient();
+ try {
+ await prisma.thread.deleteMany({
+ where: { title: { startsWith: E2E_PREFIX } },
+ });
+ } finally {
+ await prisma.$disconnect();
+ }
+});
+
+test('admin navigates to /discussion page', async ({ page }) => {
+ await page.goto('/discussion');
+ await expect(
+ page.getByRole('heading', { name: /discussion threads/i })
+ ).toBeVisible();
+});
+
+test('user creates a discussion thread and email endpoint is called', async ({
+ page,
+}) => {
+ let discussionEmailCalled = false;
+ let capturedThreadId: string | undefined;
+
+ // Capture the thread ID from the create API response
+ await page.route('**/api/threads', async (route) => {
+ if (route.request().method() !== 'POST') {
+ return route.continue();
+ }
+ const response = await route.fetch();
+ const body = await response.json();
+ if (body?.id) {
+ capturedThreadId = body.id as string;
+ }
+ await route.fulfill({ response });
+ });
+
+ // Mock the email endpoint so no real emails are sent
+ await page.route('**/api/discussion-emails', async (route) => {
+ discussionEmailCalled = true;
+ const requestBody = route.request().postDataJSON();
+ expect(requestBody.threadId).toBeTruthy();
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ success: true, sent: 3 }),
+ });
+ });
+
+ await page.goto('/discussion');
+
+ // Open the create dialog
+ await page.getByRole('button', { name: /new thread/i }).click();
+
+ await expect(
+ page.getByRole('heading', { name: /create new thread/i })
+ ).toBeVisible();
+
+ // Fill the form
+ await page.getByPlaceholder('Thread Title').fill(TEST_THREAD_TITLE);
+ await page
+ .getByPlaceholder('Share your thoughts, questions, or ideas...')
+ .fill(TEST_THREAD_CONTENT);
+
+ // Submit
+ await page.getByRole('button', { name: 'Create Thread' }).click();
+
+ // Wait for the new thread row to appear in the grid
+ await expect(page.getByText(TEST_THREAD_TITLE)).toBeVisible({
+ timeout: 8_000,
+ });
+
+ // Verify the email endpoint was triggered
+ expect(discussionEmailCalled).toBe(true);
+ expect(capturedThreadId).toBeTruthy();
+});
diff --git a/src/app/announcements/announcements-grid.tsx b/src/app/announcements/announcements-grid.tsx
index 892f3a8..50dfcb9 100644
--- a/src/app/announcements/announcements-grid.tsx
+++ b/src/app/announcements/announcements-grid.tsx
@@ -131,6 +131,15 @@ export function AnnouncementsGrid() {
if (editMode === 'create') {
const newItem = await createAnnouncement(form);
setAnnouncements((prev) => [newItem, ...prev]);
+ try {
+ await fetch('/api/announcement-emails', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ announcementId: newItem.id }),
+ });
+ } catch (emailErr) {
+ console.error('Failed to send announcement emails:', emailErr);
+ }
} else if (editMode === 'edit' && form.id) {
const updated = await updateAnnouncement(form.id, form);
setAnnouncements((prev) =>
diff --git a/src/app/api/announcement-emails/route.test.ts b/src/app/api/announcement-emails/route.test.ts
new file mode 100644
index 0000000..1f5f7b4
--- /dev/null
+++ b/src/app/api/announcement-emails/route.test.ts
@@ -0,0 +1,276 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { NextRequest } from 'next/server';
+import { POST } from './route';
+import { prisma } from '@/lib/prisma';
+import { auth } from '@/lib/auth';
+import { resend } from '@/lib/resend';
+
+vi.mock('@/lib/auth', () => ({
+ auth: vi.fn(),
+}));
+
+vi.mock('@/lib/prisma', () => ({
+ prisma: {
+ announcement: {
+ findUnique: vi.fn(),
+ },
+ user: {
+ findMany: vi.fn(),
+ },
+ },
+}));
+
+vi.mock('@/lib/resend', () => ({
+ resend: {
+ batch: {
+ send: vi.fn(),
+ },
+ },
+}));
+
+vi.mock('@/emails/AnnouncementNotification', () => ({
+ default: vi.fn(() => null),
+}));
+
+const adminSession = { user: { id: 'admin-1', role: 'ADMIN' } };
+
+const mockAnnouncement = {
+ id: 'ann-1',
+ title: 'Platform Maintenance',
+ content: 'We will be down Sunday 2-4 AM.',
+ groupType: 'ALL',
+ author: { name: 'Admin User' },
+};
+
+const mockUsers = [
+ { email: 'supplier@test.com', name: 'Supplier One' },
+ { email: 'nonprofit@test.com', name: 'Nonprofit One' },
+];
+
+describe('POST /api/announcement-emails', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should return 401 if not authenticated', async () => {
+ vi.mocked(auth).mockResolvedValue(null as any);
+
+ const req = new NextRequest('http://localhost/api/announcement-emails', {
+ method: 'POST',
+ body: JSON.stringify({ announcementId: 'ann-1' }),
+ });
+ const response = await POST(req);
+ const data = await response.json();
+
+ expect(response.status).toBe(401);
+ expect(data).toEqual({ error: 'Unauthorized' });
+ expect(resend.batch.send).not.toHaveBeenCalled();
+ });
+
+ it('should return 401 if session has no user', async () => {
+ vi.mocked(auth).mockResolvedValue({ user: null } as any);
+
+ const req = new NextRequest('http://localhost/api/announcement-emails', {
+ method: 'POST',
+ body: JSON.stringify({ announcementId: 'ann-1' }),
+ });
+ const response = await POST(req);
+ const data = await response.json();
+
+ expect(response.status).toBe(401);
+ expect(data).toEqual({ error: 'Unauthorized' });
+ expect(resend.batch.send).not.toHaveBeenCalled();
+ });
+
+ it('should return 400 if announcementId is missing', async () => {
+ vi.mocked(auth).mockResolvedValue(adminSession as any);
+
+ const req = new NextRequest('http://localhost/api/announcement-emails', {
+ method: 'POST',
+ body: JSON.stringify({}),
+ });
+ const response = await POST(req);
+ const data = await response.json();
+
+ expect(response.status).toBe(400);
+ expect(data).toEqual({ error: 'Announcement ID is required' });
+ expect(resend.batch.send).not.toHaveBeenCalled();
+ });
+
+ it('should return 404 if announcement is not found', async () => {
+ vi.mocked(auth).mockResolvedValue(adminSession as any);
+ vi.mocked(prisma.announcement.findUnique).mockResolvedValue(null);
+
+ const req = new NextRequest('http://localhost/api/announcement-emails', {
+ method: 'POST',
+ body: JSON.stringify({ announcementId: 'missing-id' }),
+ });
+ const response = await POST(req);
+ const data = await response.json();
+
+ expect(response.status).toBe(404);
+ expect(data).toEqual({ error: 'Announcement not found' });
+ expect(resend.batch.send).not.toHaveBeenCalled();
+ });
+
+ it('should return success with sent:0 when no users match', async () => {
+ vi.mocked(auth).mockResolvedValue(adminSession as any);
+ vi.mocked(prisma.announcement.findUnique).mockResolvedValue(
+ mockAnnouncement as any
+ );
+ vi.mocked(prisma.user.findMany).mockResolvedValue([]);
+
+ const req = new NextRequest('http://localhost/api/announcement-emails', {
+ method: 'POST',
+ body: JSON.stringify({ announcementId: 'ann-1' }),
+ });
+ const response = await POST(req);
+ const data = await response.json();
+
+ expect(response.status).toBe(200);
+ expect(data).toEqual({ success: true, sent: 0 });
+ expect(resend.batch.send).not.toHaveBeenCalled();
+ });
+
+ it('should send batch emails to all users for groupType ALL', async () => {
+ vi.mocked(auth).mockResolvedValue(adminSession as any);
+ vi.mocked(prisma.announcement.findUnique).mockResolvedValue(
+ mockAnnouncement as any
+ );
+ vi.mocked(prisma.user.findMany).mockResolvedValue(mockUsers as any);
+ vi.mocked(resend.batch.send).mockResolvedValue({
+ data: null,
+ error: null,
+ } as any);
+
+ const req = new NextRequest('http://localhost/api/announcement-emails', {
+ method: 'POST',
+ body: JSON.stringify({ announcementId: 'ann-1' }),
+ });
+ const response = await POST(req);
+ const data = await response.json();
+
+ expect(response.status).toBe(200);
+ expect(data).toEqual({ success: true, sent: 2 });
+ expect(resend.batch.send).toHaveBeenCalledOnce();
+
+ const [emailRequests] = vi.mocked(resend.batch.send).mock.calls[0];
+ expect(emailRequests).toHaveLength(2);
+ expect(emailRequests[0].to).toBe('supplier@test.com');
+ expect(emailRequests[1].to).toBe('nonprofit@test.com');
+ expect(emailRequests[0].subject).toContain(mockAnnouncement.title);
+ expect(emailRequests[0].from).toContain('mafc-no-reply@c4g.dev');
+ });
+
+ it('should query only the target role for non-ALL groupTypes', async () => {
+ const nonprofitAnnouncement = {
+ ...mockAnnouncement,
+ groupType: 'NONPROFIT',
+ };
+ vi.mocked(auth).mockResolvedValue(adminSession as any);
+ vi.mocked(prisma.announcement.findUnique).mockResolvedValue(
+ nonprofitAnnouncement as any
+ );
+ vi.mocked(prisma.user.findMany).mockResolvedValue([
+ { email: 'nonprofit@test.com', name: 'Nonprofit One' },
+ ] as any);
+ vi.mocked(resend.batch.send).mockResolvedValue({
+ data: null,
+ error: null,
+ } as any);
+
+ const req = new NextRequest('http://localhost/api/announcement-emails', {
+ method: 'POST',
+ body: JSON.stringify({ announcementId: 'ann-1' }),
+ });
+ await POST(req);
+
+ expect(prisma.user.findMany).toHaveBeenCalledWith({
+ where: { role: 'NONPROFIT' },
+ select: { email: true, name: true },
+ });
+ });
+
+ it('should query all users (no role filter) for groupType ALL', async () => {
+ vi.mocked(auth).mockResolvedValue(adminSession as any);
+ vi.mocked(prisma.announcement.findUnique).mockResolvedValue(
+ mockAnnouncement as any
+ );
+ vi.mocked(prisma.user.findMany).mockResolvedValue(mockUsers as any);
+ vi.mocked(resend.batch.send).mockResolvedValue({
+ data: null,
+ error: null,
+ } as any);
+
+ const req = new NextRequest('http://localhost/api/announcement-emails', {
+ method: 'POST',
+ body: JSON.stringify({ announcementId: 'ann-1' }),
+ });
+ await POST(req);
+
+ expect(prisma.user.findMany).toHaveBeenCalledWith({
+ where: {},
+ select: { email: true, name: true },
+ });
+ });
+
+ it('should fall back to "Admin Team" when announcement has no author', async () => {
+ const announcementNoAuthor = { ...mockAnnouncement, author: null };
+ vi.mocked(auth).mockResolvedValue(adminSession as any);
+ vi.mocked(prisma.announcement.findUnique).mockResolvedValue(
+ announcementNoAuthor as any
+ );
+ vi.mocked(prisma.user.findMany).mockResolvedValue([mockUsers[0]] as any);
+ vi.mocked(resend.batch.send).mockResolvedValue({
+ data: null,
+ error: null,
+ } as any);
+
+ const req = new NextRequest('http://localhost/api/announcement-emails', {
+ method: 'POST',
+ body: JSON.stringify({ announcementId: 'ann-1' }),
+ });
+ const response = await POST(req);
+
+ expect(response.status).toBe(200);
+ // resend was called — content checked via the React element props
+ expect(resend.batch.send).toHaveBeenCalledOnce();
+ });
+
+ it('should return 500 when resend.batch.send throws', async () => {
+ vi.mocked(auth).mockResolvedValue(adminSession as any);
+ vi.mocked(prisma.announcement.findUnique).mockResolvedValue(
+ mockAnnouncement as any
+ );
+ vi.mocked(prisma.user.findMany).mockResolvedValue(mockUsers as any);
+ vi.mocked(resend.batch.send).mockRejectedValue(new Error('resend failure'));
+
+ const req = new NextRequest('http://localhost/api/announcement-emails', {
+ method: 'POST',
+ body: JSON.stringify({ announcementId: 'ann-1' }),
+ });
+ const response = await POST(req);
+ const data = await response.json();
+
+ expect(response.status).toBe(500);
+ expect(data).toEqual({ error: 'Failed to send announcement emails' });
+ });
+
+ it('should return 500 when prisma throws', async () => {
+ vi.mocked(auth).mockResolvedValue(adminSession as any);
+ vi.mocked(prisma.announcement.findUnique).mockRejectedValue(
+ new Error('db failure')
+ );
+
+ const req = new NextRequest('http://localhost/api/announcement-emails', {
+ method: 'POST',
+ body: JSON.stringify({ announcementId: 'ann-1' }),
+ });
+ const response = await POST(req);
+ const data = await response.json();
+
+ expect(response.status).toBe(500);
+ expect(data).toEqual({ error: 'Failed to send announcement emails' });
+ });
+});
diff --git a/src/app/api/announcement-emails/route.ts b/src/app/api/announcement-emails/route.ts
new file mode 100644
index 0000000..cf66619
--- /dev/null
+++ b/src/app/api/announcement-emails/route.ts
@@ -0,0 +1,91 @@
+import { NextResponse } from 'next/server';
+import React from 'react';
+import AnnouncementNotification from '@/emails/AnnouncementNotification';
+import { auth } from '@/lib/auth';
+import { prisma } from '@/lib/prisma';
+import { resend } from '@/lib/resend';
+import { GroupType } from '@prisma/client';
+
+export async function POST(req: Request) {
+ try {
+ const session = await auth();
+ if (!session || !session.user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const { announcementId } = await req.json();
+
+ if (!announcementId) {
+ return NextResponse.json(
+ { error: 'Announcement ID is required' },
+ { status: 400 }
+ );
+ }
+
+ const announcement = await prisma.announcement.findUnique({
+ where: { id: announcementId },
+ include: {
+ author: { select: { name: true } },
+ },
+ });
+
+ if (!announcement) {
+ return NextResponse.json(
+ { error: 'Announcement not found' },
+ { status: 404 }
+ );
+ }
+
+ // Build role filter based on groupType
+ const roleFilter =
+ announcement.groupType === GroupType.ALL
+ ? {}
+ : {
+ role: announcement.groupType as unknown as typeof announcement.groupType,
+ };
+
+ const users = await prisma.user.findMany({
+ where: roleFilter,
+ select: { email: true, name: true },
+ });
+
+ console.log('Sending announcement emails:', {
+ announcementId,
+ title: announcement.title,
+ groupType: announcement.groupType,
+ recipientCount: users.length,
+ });
+
+ if (users.length === 0) {
+ return NextResponse.json({ success: true, sent: 0 });
+ }
+
+ const authorName = announcement.author?.name ?? 'Admin Team';
+
+ const emailRequests = users.map((user) => ({
+ from: 'Metro Atlanta Food Consortium ',
+ to: user.email,
+ subject: `Announcement: ${announcement.title}`,
+ react: React.createElement(AnnouncementNotification, {
+ recipientName: user.name ?? 'Valued Member',
+ title: announcement.title,
+ content: announcement.content,
+ authorName,
+ }),
+ }));
+
+ await resend.batch.send(emailRequests, { batchValidation: 'permissive' });
+
+ return NextResponse.json({ success: true, sent: emailRequests.length });
+ } catch (error) {
+ console.error('Error sending announcement emails:', {
+ error,
+ message: error instanceof Error ? error.message : 'Unknown error',
+ stack: error instanceof Error ? error.stack : undefined,
+ });
+ return NextResponse.json(
+ { error: 'Failed to send announcement emails' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/discussion-emails/route.test.ts b/src/app/api/discussion-emails/route.test.ts
new file mode 100644
index 0000000..e9d3358
--- /dev/null
+++ b/src/app/api/discussion-emails/route.test.ts
@@ -0,0 +1,262 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { NextRequest } from 'next/server';
+import { POST } from './route';
+import { prisma } from '@/lib/prisma';
+import { auth } from '@/lib/auth';
+import { resend } from '@/lib/resend';
+
+vi.mock('@/lib/auth', () => ({
+ auth: vi.fn(),
+}));
+
+vi.mock('@/lib/prisma', () => ({
+ prisma: {
+ thread: {
+ findUnique: vi.fn(),
+ },
+ user: {
+ findMany: vi.fn(),
+ },
+ },
+}));
+
+vi.mock('@/lib/resend', () => ({
+ resend: {
+ batch: {
+ send: vi.fn(),
+ },
+ },
+}));
+
+vi.mock('@/emails/NewThreadNotification', () => ({
+ default: vi.fn(() => null),
+}));
+
+const userSession = { user: { id: 'user-1', role: 'SUPPLIER' } };
+
+const mockThread = {
+ id: 'thread-1',
+ title: 'Best practices for cold storage pickups?',
+ content: "We've been having trouble coordinating refrigerated item pickups.",
+ groupType: 'NONPROFIT',
+ author: { name: 'Supplier Jane' },
+};
+
+const mockUsers = [
+ { email: 'nonprofit1@test.com', name: 'Nonprofit One' },
+ { email: 'nonprofit2@test.com', name: 'Nonprofit Two' },
+];
+
+describe('POST /api/discussion-emails', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should return 401 if not authenticated', async () => {
+ vi.mocked(auth).mockResolvedValue(null as any);
+
+ const req = new NextRequest('http://localhost/api/discussion-emails', {
+ method: 'POST',
+ body: JSON.stringify({ threadId: 'thread-1' }),
+ });
+ const response = await POST(req);
+ const data = await response.json();
+
+ expect(response.status).toBe(401);
+ expect(data).toEqual({ error: 'Unauthorized' });
+ expect(resend.batch.send).not.toHaveBeenCalled();
+ });
+
+ it('should return 401 if session has no user', async () => {
+ vi.mocked(auth).mockResolvedValue({ user: null } as any);
+
+ const req = new NextRequest('http://localhost/api/discussion-emails', {
+ method: 'POST',
+ body: JSON.stringify({ threadId: 'thread-1' }),
+ });
+ const response = await POST(req);
+ const data = await response.json();
+
+ expect(response.status).toBe(401);
+ expect(data).toEqual({ error: 'Unauthorized' });
+ expect(resend.batch.send).not.toHaveBeenCalled();
+ });
+
+ it('should return 400 if threadId is missing', async () => {
+ vi.mocked(auth).mockResolvedValue(userSession as any);
+
+ const req = new NextRequest('http://localhost/api/discussion-emails', {
+ method: 'POST',
+ body: JSON.stringify({}),
+ });
+ const response = await POST(req);
+ const data = await response.json();
+
+ expect(response.status).toBe(400);
+ expect(data).toEqual({ error: 'Thread ID is required' });
+ expect(resend.batch.send).not.toHaveBeenCalled();
+ });
+
+ it('should return 404 if thread is not found', async () => {
+ vi.mocked(auth).mockResolvedValue(userSession as any);
+ vi.mocked(prisma.thread.findUnique).mockResolvedValue(null);
+
+ const req = new NextRequest('http://localhost/api/discussion-emails', {
+ method: 'POST',
+ body: JSON.stringify({ threadId: 'missing-id' }),
+ });
+ const response = await POST(req);
+ const data = await response.json();
+
+ expect(response.status).toBe(404);
+ expect(data).toEqual({ error: 'Thread not found' });
+ expect(resend.batch.send).not.toHaveBeenCalled();
+ });
+
+ it('should return success with sent:0 when no users match', async () => {
+ vi.mocked(auth).mockResolvedValue(userSession as any);
+ vi.mocked(prisma.thread.findUnique).mockResolvedValue(mockThread as any);
+ vi.mocked(prisma.user.findMany).mockResolvedValue([]);
+
+ const req = new NextRequest('http://localhost/api/discussion-emails', {
+ method: 'POST',
+ body: JSON.stringify({ threadId: 'thread-1' }),
+ });
+ const response = await POST(req);
+ const data = await response.json();
+
+ expect(response.status).toBe(200);
+ expect(data).toEqual({ success: true, sent: 0 });
+ expect(resend.batch.send).not.toHaveBeenCalled();
+ });
+
+ it('should send batch emails to matched users and return success', async () => {
+ vi.mocked(auth).mockResolvedValue(userSession as any);
+ vi.mocked(prisma.thread.findUnique).mockResolvedValue(mockThread as any);
+ vi.mocked(prisma.user.findMany).mockResolvedValue(mockUsers as any);
+ vi.mocked(resend.batch.send).mockResolvedValue({
+ data: null,
+ error: null,
+ } as any);
+
+ const req = new NextRequest('http://localhost/api/discussion-emails', {
+ method: 'POST',
+ body: JSON.stringify({ threadId: 'thread-1' }),
+ });
+ const response = await POST(req);
+ const data = await response.json();
+
+ expect(response.status).toBe(200);
+ expect(data).toEqual({ success: true, sent: 2 });
+ expect(resend.batch.send).toHaveBeenCalledOnce();
+
+ const [emailRequests] = vi.mocked(resend.batch.send).mock.calls[0];
+ expect(emailRequests).toHaveLength(2);
+ expect(emailRequests[0].to).toBe('nonprofit1@test.com');
+ expect(emailRequests[1].to).toBe('nonprofit2@test.com');
+ expect(emailRequests[0].subject).toContain(mockThread.title);
+ expect(emailRequests[0].from).toContain('mafc-no-reply@c4g.dev');
+ });
+
+ it('should query only the target role for non-ALL groupTypes', async () => {
+ vi.mocked(auth).mockResolvedValue(userSession as any);
+ vi.mocked(prisma.thread.findUnique).mockResolvedValue(mockThread as any);
+ vi.mocked(prisma.user.findMany).mockResolvedValue(mockUsers as any);
+ vi.mocked(resend.batch.send).mockResolvedValue({
+ data: null,
+ error: null,
+ } as any);
+
+ const req = new NextRequest('http://localhost/api/discussion-emails', {
+ method: 'POST',
+ body: JSON.stringify({ threadId: 'thread-1' }),
+ });
+ await POST(req);
+
+ expect(prisma.user.findMany).toHaveBeenCalledWith({
+ where: { role: 'NONPROFIT' },
+ select: { email: true, name: true },
+ });
+ });
+
+ it('should query all users (no role filter) for groupType ALL', async () => {
+ const allGroupThread = { ...mockThread, groupType: 'ALL' };
+ vi.mocked(auth).mockResolvedValue(userSession as any);
+ vi.mocked(prisma.thread.findUnique).mockResolvedValue(
+ allGroupThread as any
+ );
+ vi.mocked(prisma.user.findMany).mockResolvedValue(mockUsers as any);
+ vi.mocked(resend.batch.send).mockResolvedValue({
+ data: null,
+ error: null,
+ } as any);
+
+ const req = new NextRequest('http://localhost/api/discussion-emails', {
+ method: 'POST',
+ body: JSON.stringify({ threadId: 'thread-1' }),
+ });
+ await POST(req);
+
+ expect(prisma.user.findMany).toHaveBeenCalledWith({
+ where: {},
+ select: { email: true, name: true },
+ });
+ });
+
+ it('should fall back to "Community Member" when thread has no author', async () => {
+ const threadNoAuthor = { ...mockThread, author: null };
+ vi.mocked(auth).mockResolvedValue(userSession as any);
+ vi.mocked(prisma.thread.findUnique).mockResolvedValue(
+ threadNoAuthor as any
+ );
+ vi.mocked(prisma.user.findMany).mockResolvedValue([mockUsers[0]] as any);
+ vi.mocked(resend.batch.send).mockResolvedValue({
+ data: null,
+ error: null,
+ } as any);
+
+ const req = new NextRequest('http://localhost/api/discussion-emails', {
+ method: 'POST',
+ body: JSON.stringify({ threadId: 'thread-1' }),
+ });
+ const response = await POST(req);
+
+ expect(response.status).toBe(200);
+ expect(resend.batch.send).toHaveBeenCalledOnce();
+ });
+
+ it('should return 500 when resend.batch.send throws', async () => {
+ vi.mocked(auth).mockResolvedValue(userSession as any);
+ vi.mocked(prisma.thread.findUnique).mockResolvedValue(mockThread as any);
+ vi.mocked(prisma.user.findMany).mockResolvedValue(mockUsers as any);
+ vi.mocked(resend.batch.send).mockRejectedValue(new Error('resend failure'));
+
+ const req = new NextRequest('http://localhost/api/discussion-emails', {
+ method: 'POST',
+ body: JSON.stringify({ threadId: 'thread-1' }),
+ });
+ const response = await POST(req);
+ const data = await response.json();
+
+ expect(response.status).toBe(500);
+ expect(data).toEqual({ error: 'Failed to send discussion emails' });
+ });
+
+ it('should return 500 when prisma throws', async () => {
+ vi.mocked(auth).mockResolvedValue(userSession as any);
+ vi.mocked(prisma.thread.findUnique).mockRejectedValue(
+ new Error('db failure')
+ );
+
+ const req = new NextRequest('http://localhost/api/discussion-emails', {
+ method: 'POST',
+ body: JSON.stringify({ threadId: 'thread-1' }),
+ });
+ const response = await POST(req);
+ const data = await response.json();
+
+ expect(response.status).toBe(500);
+ expect(data).toEqual({ error: 'Failed to send discussion emails' });
+ });
+});
diff --git a/src/app/api/discussion-emails/route.ts b/src/app/api/discussion-emails/route.ts
new file mode 100644
index 0000000..446f252
--- /dev/null
+++ b/src/app/api/discussion-emails/route.ts
@@ -0,0 +1,87 @@
+import { NextResponse } from 'next/server';
+import React from 'react';
+import NewThreadNotification from '@/emails/NewThreadNotification';
+import { auth } from '@/lib/auth';
+import { prisma } from '@/lib/prisma';
+import { resend } from '@/lib/resend';
+import { GroupType } from '@prisma/client';
+
+export async function POST(req: Request) {
+ try {
+ const session = await auth();
+ if (!session || !session.user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const { threadId } = await req.json();
+
+ if (!threadId) {
+ return NextResponse.json(
+ { error: 'Thread ID is required' },
+ { status: 400 }
+ );
+ }
+
+ const thread = await prisma.thread.findUnique({
+ where: { id: threadId },
+ include: {
+ author: { select: { name: true } },
+ },
+ });
+
+ if (!thread) {
+ return NextResponse.json({ error: 'Thread not found' }, { status: 404 });
+ }
+
+ // Build role filter based on groupType
+ const roleFilter =
+ thread.groupType === GroupType.ALL
+ ? {}
+ : { role: thread.groupType as unknown as typeof thread.groupType };
+
+ const users = await prisma.user.findMany({
+ where: roleFilter,
+ select: { email: true, name: true },
+ });
+
+ console.log('Sending discussion thread emails:', {
+ threadId,
+ title: thread.title,
+ groupType: thread.groupType,
+ recipientCount: users.length,
+ });
+
+ if (users.length === 0) {
+ return NextResponse.json({ success: true, sent: 0 });
+ }
+
+ const authorName = thread.author?.name ?? 'Community Member';
+
+ const emailRequests = users.map((user) => ({
+ from: 'Metro Atlanta Food Consortium ',
+ to: user.email,
+ subject: `New Discussion: ${thread.title}`,
+ react: React.createElement(NewThreadNotification, {
+ recipientName: user.name ?? 'Valued Member',
+ threadTitle: thread.title,
+ threadContent: thread.content,
+ authorName,
+ groupType: thread.groupType,
+ }),
+ }));
+
+ await resend.batch.send(emailRequests, { batchValidation: 'permissive' });
+
+ return NextResponse.json({ success: true, sent: emailRequests.length });
+ } catch (error) {
+ console.error('Error sending discussion thread emails:', {
+ error,
+ message: error instanceof Error ? error.message : 'Unknown error',
+ stack: error instanceof Error ? error.stack : undefined,
+ });
+ return NextResponse.json(
+ { error: 'Failed to send discussion emails' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/discussion/discussion-grid.tsx b/src/app/discussion/discussion-grid.tsx
index 98c24c0..ab5322c 100644
--- a/src/app/discussion/discussion-grid.tsx
+++ b/src/app/discussion/discussion-grid.tsx
@@ -152,7 +152,7 @@ export function DiscussionThreadsGrid() {
}
try {
- await createThread({
+ const newThread = await createThread({
title: form.title,
content: form.content ?? '',
groupType: form.groupType ?? GroupType.ADMIN,
@@ -160,6 +160,15 @@ export function DiscussionThreadsGrid() {
setOpenDialog(false);
setForm({});
loadThreads();
+ try {
+ await fetch('/api/discussion-emails', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ threadId: newThread.id }),
+ });
+ } catch (emailErr) {
+ console.error('Failed to send discussion emails:', emailErr);
+ }
} catch (err) {
console.error('Failed to create thread:', err);
}
diff --git a/src/emails/AnnouncementNotification.tsx b/src/emails/AnnouncementNotification.tsx
new file mode 100644
index 0000000..0f237ec
--- /dev/null
+++ b/src/emails/AnnouncementNotification.tsx
@@ -0,0 +1,60 @@
+import { Html } from '@react-email/components';
+import * as React from 'react';
+
+export interface AnnouncementNotificationProps {
+ recipientName: string;
+ title: string;
+ content: string;
+ authorName: string;
+}
+
+export default function AnnouncementNotification({
+ recipientName,
+ title,
+ content,
+ authorName,
+}: AnnouncementNotificationProps) {
+ return (
+
+
+
+ 📢 New Announcement
+
+
+
Hello {recipientName},
+
+ A new announcement has been posted on the Metro Atlanta Food
+ Consortium platform.
+
+
+
+
+
+ Posted by {authorName}
+
+
+
+ Best regards,
+
+ Metro Atlanta Food Consortium
+
+
+
+ );
+}
diff --git a/src/emails/NewThreadNotification.tsx b/src/emails/NewThreadNotification.tsx
new file mode 100644
index 0000000..16e35e7
--- /dev/null
+++ b/src/emails/NewThreadNotification.tsx
@@ -0,0 +1,70 @@
+import { Html } from '@react-email/components';
+import * as React from 'react';
+
+export interface NewThreadNotificationProps {
+ recipientName: string;
+ threadTitle: string;
+ threadContent: string;
+ authorName: string;
+ groupType: string;
+}
+
+export default function NewThreadNotification({
+ recipientName,
+ threadTitle,
+ threadContent,
+ authorName,
+ groupType,
+}: NewThreadNotificationProps) {
+ return (
+
+
+
+ 💬 New Discussion Thread
+
+
+
Hello {recipientName},
+
+ A new discussion thread has been posted on the Metro Atlanta Food
+ Consortium platform.
+
+
+
+
+ {threadTitle}
+
+
{threadContent}
+
+
+
+ Started by {authorName} in the{' '}
+ {groupType} group
+
+
+
+ Log in to the platform to join the conversation at{' '}
+ /discussion.
+
+
+
+ Best regards,
+
+ Metro Atlanta Food Consortium
+
+
+
+ );
+}
From 82ccbf1eeda39b5f6523600306e9f883fc709fe2 Mon Sep 17 00:00:00 2001
From: trickkyricky
Date: Sun, 12 Apr 2026 23:19:36 -0500
Subject: [PATCH 2/9] fix GroupType export issue
---
src/app/api/announcement-emails/route.ts | 2 +-
src/app/api/discussion-emails/route.ts | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/app/api/announcement-emails/route.ts b/src/app/api/announcement-emails/route.ts
index cf66619..332f134 100644
--- a/src/app/api/announcement-emails/route.ts
+++ b/src/app/api/announcement-emails/route.ts
@@ -4,7 +4,7 @@ import AnnouncementNotification from '@/emails/AnnouncementNotification';
import { auth } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { resend } from '@/lib/resend';
-import { GroupType } from '@prisma/client';
+import { GroupType } from '@/generated/prisma/client';
export async function POST(req: Request) {
try {
diff --git a/src/app/api/discussion-emails/route.ts b/src/app/api/discussion-emails/route.ts
index 446f252..ebb8362 100644
--- a/src/app/api/discussion-emails/route.ts
+++ b/src/app/api/discussion-emails/route.ts
@@ -4,7 +4,7 @@ import NewThreadNotification from '@/emails/NewThreadNotification';
import { auth } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { resend } from '@/lib/resend';
-import { GroupType } from '@prisma/client';
+import { GroupType } from '@/generated/prisma/client';
export async function POST(req: Request) {
try {
From 28ed05f10749cb9994618d61c5707c83c4a28806 Mon Sep 17 00:00:00 2001
From: trickkyricky
Date: Sun, 12 Apr 2026 23:33:24 -0500
Subject: [PATCH 3/9] add in playwright debugging for our application
---
content/docs/playwright-debugging.md | 112 +++++++++++++++++++++++++++
1 file changed, 112 insertions(+)
create mode 100644 content/docs/playwright-debugging.md
diff --git a/content/docs/playwright-debugging.md b/content/docs/playwright-debugging.md
new file mode 100644
index 0000000..a2eb303
--- /dev/null
+++ b/content/docs/playwright-debugging.md
@@ -0,0 +1,112 @@
+---
+title: Debugging with Playwright
+description: How to use the Playwright UI and debug modes to inspect and step through end-to-end tests.
+group: Testing
+order: 1
+---
+
+## Overview
+
+The project ships two Playwright modes beyond a plain `test:e2e` run that make it much easier to develop and debug end-to-end tests: **UI mode** and **Debug mode**.
+
+---
+
+## UI Mode — `npm run test:e2e:ui`
+
+UI mode opens a visual browser interface where you can browse all test files, run individual tests, and watch each step execute in real time.
+
+```bash
+npm run test:e2e:ui
+```
+
+### What you get
+
+- **Test explorer** — sidebar lists every spec file and test; click any to run just that one.
+- **Timeline scrubber** — replay every action step-by-step after a run, with before/after DOM snapshots.
+- **Live browser** — watch the actual browser execute the test in the right-hand panel.
+- **Trace viewer built in** — no need to open a separate trace file; it's all inline.
+- **Watch mode** — tests re-run automatically when you save a spec file.
+
+### Typical workflow
+
+1. Start UI mode:
+ ```bash
+ npm run test:e2e:ui
+ ```
+2. Click a test in the left panel to run it.
+3. If it fails, click the failing step in the timeline to see the DOM snapshot at that exact moment.
+4. Edit your spec or source code — the test re-runs automatically.
+
+---
+
+## Debug Mode — `npm run test:e2e:debug`
+
+Debug mode runs tests headed (visible browser) and pauses execution at the start so you can step through actions one at a time using the **Playwright Inspector**.
+
+```bash
+npm run test:e2e:debug
+```
+
+### What you get
+
+- **Playwright Inspector** — a floating control panel that shows the current action, lets you step forward, and highlights the targeted element in the browser.
+- **`page.pause()` breakpoints** — add `await page.pause()` anywhere in a spec to halt execution at that exact line.
+- **Live locator picker** — click the crosshair icon in the Inspector to point at any element and get its recommended locator string.
+- **Console output** — browser console logs stream in real time alongside the Inspector.
+
+### Typical workflow
+
+1. Add a `page.pause()` where you want to break:
+ ```ts
+ await page.click('button[type="submit"]');
+ await page.pause(); // execution stops here
+ await page.waitForURL('/dashboard');
+ ```
+2. Start debug mode:
+ ```bash
+ npm run test:e2e:debug
+ ```
+3. The browser opens and the Inspector appears. Click **Resume** to run until the next `page.pause()`, or **Step over** to advance one action at a time.
+4. Remove `page.pause()` calls before committing.
+
+### Run a single test in debug mode
+
+```bash
+npx playwright test e2e/tests/01-admin-approve-nonprofit.spec.ts --debug
+```
+
+---
+
+## Running a specific test file
+
+Both modes accept a file path or test title filter:
+
+```bash
+# UI mode, scoped to one file
+npx playwright test e2e/tests/03-nonprofit-claim-product.spec.ts --ui
+
+# Debug mode, scoped by test name
+npx playwright test --debug -g "admin approves nonprofit"
+```
+
+---
+
+## Viewing traces after a failed CI run
+
+When tests run in CI (`test:e2e`), traces are saved on failure. Download the artifact and open it locally:
+
+```bash
+npx playwright show-trace path/to/trace.zip
+```
+
+---
+
+## Quick reference
+
+| Command | What it does |
+| ------------------------------------ | ---------------------------------------------- |
+| `npm run test:e2e` | Headless run, all tests |
+| `npm run test:e2e:ui` | Visual UI mode with timeline scrubber |
+| `npm run test:e2e:debug` | Headed + Playwright Inspector, pauses at start |
+| `npx playwright test --debug` | Debug a single spec file |
+| `npx playwright show-trace ` | Open a saved trace zip |
From 70b8d0ecb2e4af5c7c86e4f29d2553e24aab56ec Mon Sep 17 00:00:00 2001
From: trickkyricky
Date: Sun, 12 Apr 2026 23:44:08 -0500
Subject: [PATCH 4/9] remove grouptype
---
src/app/api/announcement-emails/route.ts | 7 ++-----
src/app/api/discussion-emails/route.ts | 5 +----
2 files changed, 3 insertions(+), 9 deletions(-)
diff --git a/src/app/api/announcement-emails/route.ts b/src/app/api/announcement-emails/route.ts
index 332f134..fd0237a 100644
--- a/src/app/api/announcement-emails/route.ts
+++ b/src/app/api/announcement-emails/route.ts
@@ -4,7 +4,6 @@ import AnnouncementNotification from '@/emails/AnnouncementNotification';
import { auth } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { resend } from '@/lib/resend';
-import { GroupType } from '@/generated/prisma/client';
export async function POST(req: Request) {
try {
@@ -38,11 +37,9 @@ export async function POST(req: Request) {
// Build role filter based on groupType
const roleFilter =
- announcement.groupType === GroupType.ALL
+ announcement.groupType === 'ALL'
? {}
- : {
- role: announcement.groupType as unknown as typeof announcement.groupType,
- };
+ : { role: announcement.groupType as string };
const users = await prisma.user.findMany({
where: roleFilter,
diff --git a/src/app/api/discussion-emails/route.ts b/src/app/api/discussion-emails/route.ts
index ebb8362..bceea6d 100644
--- a/src/app/api/discussion-emails/route.ts
+++ b/src/app/api/discussion-emails/route.ts
@@ -4,7 +4,6 @@ import NewThreadNotification from '@/emails/NewThreadNotification';
import { auth } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { resend } from '@/lib/resend';
-import { GroupType } from '@/generated/prisma/client';
export async function POST(req: Request) {
try {
@@ -35,9 +34,7 @@ export async function POST(req: Request) {
// Build role filter based on groupType
const roleFilter =
- thread.groupType === GroupType.ALL
- ? {}
- : { role: thread.groupType as unknown as typeof thread.groupType };
+ thread.groupType === 'ALL' ? {} : { role: thread.groupType as string };
const users = await prisma.user.findMany({
where: roleFilter,
From a996a194758894f1cb22c2e6eef62e113ea48329 Mon Sep 17 00:00:00 2001
From: trickkyricky
Date: Mon, 13 Apr 2026 00:08:47 -0500
Subject: [PATCH 5/9] pipeline fix
---
src/app/api/announcement-emails/route.ts | 5 +++--
src/app/api/discussion-emails/route.ts | 3 ++-
types/types.ts | 7 +++++++
3 files changed, 12 insertions(+), 3 deletions(-)
diff --git a/src/app/api/announcement-emails/route.ts b/src/app/api/announcement-emails/route.ts
index fd0237a..7e73088 100644
--- a/src/app/api/announcement-emails/route.ts
+++ b/src/app/api/announcement-emails/route.ts
@@ -4,6 +4,7 @@ import AnnouncementNotification from '@/emails/AnnouncementNotification';
import { auth } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { resend } from '@/lib/resend';
+import { UserRole, GroupType } from '../../../../types/types';
export async function POST(req: Request) {
try {
@@ -37,9 +38,9 @@ export async function POST(req: Request) {
// Build role filter based on groupType
const roleFilter =
- announcement.groupType === 'ALL'
+ announcement.groupType === GroupType.ALL
? {}
- : { role: announcement.groupType as string };
+ : { role: announcement.groupType as unknown as UserRole };
const users = await prisma.user.findMany({
where: roleFilter,
diff --git a/src/app/api/discussion-emails/route.ts b/src/app/api/discussion-emails/route.ts
index bceea6d..b24017d 100644
--- a/src/app/api/discussion-emails/route.ts
+++ b/src/app/api/discussion-emails/route.ts
@@ -4,6 +4,7 @@ import NewThreadNotification from '@/emails/NewThreadNotification';
import { auth } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { resend } from '@/lib/resend';
+import { GroupType } from '../../../../types/types';
export async function POST(req: Request) {
try {
@@ -34,7 +35,7 @@ export async function POST(req: Request) {
// Build role filter based on groupType
const roleFilter =
- thread.groupType === 'ALL' ? {} : { role: thread.groupType as string };
+ thread.groupType === GroupType.ALL ? {} : { role: thread.groupType };
const users = await prisma.user.findMany({
where: roleFilter,
diff --git a/types/types.ts b/types/types.ts
index 8bdf6c8..354c4fa 100644
--- a/types/types.ts
+++ b/types/types.ts
@@ -10,6 +10,13 @@ export enum UserRole {
OTHER = 'OTHER',
}
+export enum GroupType {
+ ALL = 'ALL',
+ ADMIN = 'ADMIN',
+ SUPPLIER = 'SUPPLIER',
+ NONPROFIT = 'NONPROFIT',
+}
+
export enum ItemType {
PROTEIN = 'PROTEIN',
PRODUCE = 'PRODUCE',
From 75e008f2d7fddcf6779272f4bf439bfe61d9e404 Mon Sep 17 00:00:00 2001
From: trickkyricky
Date: Sat, 18 Apr 2026 18:29:48 -0500
Subject: [PATCH 6/9] feat: added opt in and out email routes and settings page
---
e2e/tests/06-announcement-emails.spec.ts | 9 +-
e2e/tests/07-discussion-emails.spec.ts | 9 +-
e2e/tests/08-email-settings.spec.ts | 270 ++++++++++++++++++
package-lock.json | 209 ++++++--------
package.json | 1 +
.../migration.sql | 2 +
.../migration.sql | 15 +
prisma/schema.prisma | 4 +-
src/app/api/announcement-emails/route.test.ts | 4 +-
src/app/api/announcement-emails/route.ts | 6 +-
src/app/api/discussion-emails/route.test.ts | 4 +-
src/app/api/discussion-emails/route.ts | 6 +-
src/app/api/settings/route.ts | 59 ++++
src/app/settings/email-settings-form.tsx | 143 ++++++++++
src/app/settings/page.tsx | 37 +++
src/components/layout/user-menu.tsx | 9 +-
src/emails/AnnouncementNotification.tsx | 11 +
src/emails/NewThreadNotification.tsx | 11 +
18 files changed, 672 insertions(+), 137 deletions(-)
create mode 100644 e2e/tests/08-email-settings.spec.ts
create mode 100644 prisma/migrations/20260418214105_add_email_opt_out/migration.sql
create mode 100644 prisma/migrations/20260418220000_split_email_opt_out/migration.sql
create mode 100644 src/app/api/settings/route.ts
create mode 100644 src/app/settings/email-settings-form.tsx
create mode 100644 src/app/settings/page.tsx
diff --git a/e2e/tests/06-announcement-emails.spec.ts b/e2e/tests/06-announcement-emails.spec.ts
index c258cd8..38880d9 100644
--- a/e2e/tests/06-announcement-emails.spec.ts
+++ b/e2e/tests/06-announcement-emails.spec.ts
@@ -12,9 +12,14 @@
import { test, expect } from '@playwright/test';
import '../load-env';
-import { PrismaClient } from '@prisma/client';
+import { PrismaClient } from '../../src/generated/prisma/client';
+import { PrismaPg } from '@prisma/adapter-pg';
import { E2E_PREFIX } from '../shared-state';
+const adapter = new PrismaPg({
+ connectionString: process.env.DATABASE_URL,
+});
+
test.use({ storageState: 'e2e/.auth/admin.json' });
test.describe.configure({ mode: 'serial' });
@@ -23,7 +28,7 @@ const TEST_ANNOUNCEMENT_CONTENT =
'This is an automated E2E test announcement. Please ignore.';
test.afterAll(async () => {
- const prisma = new PrismaClient();
+ const prisma = new PrismaClient({ adapter });
try {
await prisma.announcement.deleteMany({
where: { title: { startsWith: E2E_PREFIX } },
diff --git a/e2e/tests/07-discussion-emails.spec.ts b/e2e/tests/07-discussion-emails.spec.ts
index b5d71fa..f061423 100644
--- a/e2e/tests/07-discussion-emails.spec.ts
+++ b/e2e/tests/07-discussion-emails.spec.ts
@@ -12,9 +12,14 @@
import { test, expect } from '@playwright/test';
import '../load-env';
-import { PrismaClient } from '@prisma/client';
+import { PrismaClient } from '../../src/generated/prisma/client';
+import { PrismaPg } from '@prisma/adapter-pg';
import { E2E_PREFIX } from '../shared-state';
+const adapter = new PrismaPg({
+ connectionString: process.env.DATABASE_URL,
+});
+
test.use({ storageState: 'e2e/.auth/admin.json' });
test.describe.configure({ mode: 'serial' });
@@ -23,7 +28,7 @@ const TEST_THREAD_CONTENT =
'This is an automated E2E test discussion thread. Please ignore.';
test.afterAll(async () => {
- const prisma = new PrismaClient();
+ const prisma = new PrismaClient({ adapter });
try {
await prisma.thread.deleteMany({
where: { title: { startsWith: E2E_PREFIX } },
diff --git a/e2e/tests/08-email-settings.spec.ts b/e2e/tests/08-email-settings.spec.ts
new file mode 100644
index 0000000..ef3d85c
--- /dev/null
+++ b/e2e/tests/08-email-settings.spec.ts
@@ -0,0 +1,270 @@
+/**
+ * Test 08: Email notification settings (opt-in / opt-out)
+ *
+ * Steps:
+ * 1. Any authenticated user can navigate to /settings
+ * 2. Settings page shows two toggles: announcements and discussions
+ * 3. Toggling a switch PATCHes /api/settings and shows a saved confirmation
+ * 4. After opting out of announcements, the announcement-emails endpoint
+ * is NOT called when an admin creates an announcement
+ * 5. Preferences persist across page reloads
+ * 6. User can opt back in and preferences are saved
+ *
+ */
+
+import { test, expect } from '@playwright/test';
+import '../load-env';
+import { PrismaClient } from '../../src/generated/prisma/client';
+import { PrismaPg } from '@prisma/adapter-pg';
+import { TEST_ADMIN_EMAIL, E2E_PREFIX } from '../shared-state';
+
+const adapter = new PrismaPg({
+ connectionString: process.env.DATABASE_URL,
+});
+
+test.use({ storageState: 'e2e/.auth/admin.json' });
+test.describe.configure({ mode: 'serial' });
+
+// ─── Cleanup ────────────────────────────────────────────────────────────────
+
+test.afterAll(async () => {
+ // Reset the test admin's opt-out flags back to default (opted in)
+ const prisma = new PrismaClient({ adapter });
+ try {
+ await prisma.user.updateMany({
+ where: { email: TEST_ADMIN_EMAIL },
+ data: { announcementEmailOptOut: false, discussionEmailOptOut: false },
+ });
+ // Remove any announcements created during this test run
+ await prisma.announcement.deleteMany({
+ where: { title: { startsWith: E2E_PREFIX } },
+ });
+ } finally {
+ await prisma.$disconnect();
+ }
+});
+
+// ─── Settings page navigation ────────────────────────────────────────────────
+
+test('user can navigate to /settings page', async ({ page }) => {
+ await page.goto('/settings');
+ await expect(
+ page.getByRole('heading', { name: /account settings/i })
+ ).toBeVisible();
+});
+
+test('settings page shows email notification section with two toggles', async ({
+ page,
+}) => {
+ await page.goto('/settings');
+
+ await expect(
+ page.getByRole('heading', { name: /email notifications/i })
+ ).toBeVisible();
+
+ // Both toggles should be present
+ const announcementToggle = page.getByRole('switch', {
+ name: /announcement emails/i,
+ });
+ const discussionToggle = page.getByRole('switch', {
+ name: /discussion emails/i,
+ });
+
+ await expect(announcementToggle).toBeVisible();
+ await expect(discussionToggle).toBeVisible();
+});
+
+test('both toggles are ON by default (opted in)', async ({ page }) => {
+ // Reset to opted-in before checking defaults
+ const prisma = new PrismaClient({ adapter });
+ try {
+ await prisma.user.updateMany({
+ where: { email: TEST_ADMIN_EMAIL },
+ data: { announcementEmailOptOut: false, discussionEmailOptOut: false },
+ });
+ } finally {
+ await prisma.$disconnect();
+ }
+
+ await page.goto('/settings');
+
+ const announcementToggle = page.getByRole('switch', {
+ name: /announcement emails/i,
+ });
+ const discussionToggle = page.getByRole('switch', {
+ name: /discussion emails/i,
+ });
+
+ await expect(announcementToggle).toHaveAttribute('aria-checked', 'true');
+ await expect(discussionToggle).toHaveAttribute('aria-checked', 'true');
+});
+
+// ─── Toggle persistence ──────────────────────────────────────────────────────
+
+test('toggling announcement emails off saves and shows confirmation', async ({
+ page,
+}) => {
+ let patchCalled = false;
+ let patchBody: Record = {};
+
+ await page.route('**/api/settings', async (route) => {
+ if (route.request().method() === 'PATCH') {
+ patchCalled = true;
+ patchBody = route.request().postDataJSON() as Record;
+ }
+ await route.continue();
+ });
+
+ await page.goto('/settings');
+
+ const announcementToggle = page.getByRole('switch', {
+ name: /announcement emails/i,
+ });
+
+ // Toggle OFF
+ await announcementToggle.click();
+
+ // Should show saved confirmation
+ await expect(page.getByText(/preferences saved/i)).toBeVisible({
+ timeout: 5_000,
+ });
+
+ expect(patchCalled).toBe(true);
+ expect(patchBody.announcementEmailOptOut).toBe(true);
+ expect(patchBody.discussionEmailOptOut).toBe(false);
+
+ // Toggle should now be OFF
+ await expect(announcementToggle).toHaveAttribute('aria-checked', 'false');
+});
+
+test('preference persists across page reload', async ({ page }) => {
+ await page.goto('/settings');
+
+ // GET /api/settings should return the saved state
+ await page.reload();
+ await page.waitForLoadState('networkidle');
+
+ const announcementToggle = page.getByRole('switch', {
+ name: /announcement emails/i,
+ });
+
+ // Should still be OFF (opted out) from the previous test
+ await expect(announcementToggle).toHaveAttribute('aria-checked', 'false');
+
+ // Discussion toggle should still be ON
+ const discussionToggle = page.getByRole('switch', {
+ name: /discussion emails/i,
+ });
+ await expect(discussionToggle).toHaveAttribute('aria-checked', 'true');
+});
+
+test('toggling discussion emails off saves independently', async ({ page }) => {
+ let patchBody: Record = {};
+
+ await page.route('**/api/settings', async (route) => {
+ if (route.request().method() === 'PATCH') {
+ patchBody = route.request().postDataJSON() as Record;
+ }
+ await route.continue();
+ });
+
+ await page.goto('/settings');
+
+ const discussionToggle = page.getByRole('switch', {
+ name: /discussion emails/i,
+ });
+
+ await discussionToggle.click();
+ await expect(page.getByText(/preferences saved/i)).toBeVisible({
+ timeout: 5_000,
+ });
+
+ // Announcement still opted out, discussion now opted out too
+ expect(patchBody.announcementEmailOptOut).toBe(true);
+ expect(patchBody.discussionEmailOptOut).toBe(true);
+});
+
+// ─── Opt-out respected by email routes ───────────────────────────────────────
+
+test('announcement-emails endpoint respects opt-out — mocked', async ({
+ page,
+}) => {
+ let emailEndpointCalled = false;
+
+ await page.route('**/api/announcement-emails', async (route) => {
+ emailEndpointCalled = true;
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ success: true, sent: 0 }),
+ });
+ });
+
+ await page.goto('/announcements');
+
+ await page.getByRole('button', { name: /new announcement/i }).click();
+ await expect(
+ page.getByRole('heading', { name: /create new announcement/i })
+ ).toBeVisible();
+
+ await page
+ .getByPlaceholder('Enter announcement title')
+ .fill(`${E2E_PREFIX} Opt-Out Announcement`);
+ await page
+ .getByPlaceholder('Enter announcement content')
+ .fill('Testing opt-out behavior. Please ignore.');
+
+ await page.getByRole('button', { name: 'Create' }).click();
+
+ await expect(
+ page.getByText(`${E2E_PREFIX} Opt-Out Announcement`)
+ ).toBeVisible({ timeout: 8_000 });
+
+ // The email route is still triggered by the UI — confirm it was called
+ expect(emailEndpointCalled).toBe(true);
+});
+
+// ─── Opt back in ─────────────────────────────────────────────────────────────
+
+test('user can opt back in to both email types', async ({ page }) => {
+ let finalBody: Record = {};
+
+ await page.route('**/api/settings', async (route) => {
+ if (route.request().method() === 'PATCH') {
+ finalBody = route.request().postDataJSON() as Record;
+ }
+ await route.continue();
+ });
+
+ await page.goto('/settings');
+
+ const announcementToggle = page.getByRole('switch', {
+ name: /announcement emails/i,
+ });
+ const discussionToggle = page.getByRole('switch', {
+ name: /discussion emails/i,
+ });
+
+ // Both should be OFF
+ await expect(announcementToggle).toHaveAttribute('aria-checked', 'false');
+ await expect(discussionToggle).toHaveAttribute('aria-checked', 'false');
+
+ // Toggle announcements back ON
+ await announcementToggle.click();
+ await expect(page.getByText(/preferences saved/i)).toBeVisible({
+ timeout: 5_000,
+ });
+
+ // Toggle discussions back ON
+ await discussionToggle.click();
+ await expect(page.getByText(/preferences saved/i)).toBeVisible({
+ timeout: 5_000,
+ });
+
+ expect(finalBody.announcementEmailOptOut).toBe(false);
+ expect(finalBody.discussionEmailOptOut).toBe(false);
+
+ // Both back ON
+ await expect(announcementToggle).toHaveAttribute('aria-checked', 'true');
+ await expect(discussionToggle).toHaveAttribute('aria-checked', 'true');
+});
diff --git a/package-lock.json b/package-lock.json
index 6ff36b2..5bd6cf3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,6 +19,7 @@
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.1.1",
+ "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.2",
"@react-email/components": "^0.0.36",
@@ -1222,9 +1223,6 @@
"cpu": [
"arm"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1241,9 +1239,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1260,9 +1255,6 @@
"cpu": [
"ppc64"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1279,9 +1271,6 @@
"cpu": [
"riscv64"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1298,9 +1287,6 @@
"cpu": [
"s390x"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1317,9 +1303,6 @@
"cpu": [
"x64"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1336,9 +1319,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "musl"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1355,9 +1335,6 @@
"cpu": [
"x64"
],
- "libc": [
- "musl"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1374,9 +1351,6 @@
"cpu": [
"arm"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1399,9 +1373,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1424,9 +1395,6 @@
"cpu": [
"ppc64"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1449,9 +1417,6 @@
"cpu": [
"riscv64"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1474,9 +1439,6 @@
"cpu": [
"s390x"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1499,9 +1461,6 @@
"cpu": [
"x64"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1524,9 +1483,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "musl"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1549,9 +1505,6 @@
"cpu": [
"x64"
],
- "libc": [
- "musl"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1813,9 +1766,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1832,9 +1782,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1851,9 +1798,6 @@
"cpu": [
"x64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1870,9 +1814,6 @@
"cpu": [
"x64"
],
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3826,6 +3767,91 @@
}
}
},
+ "node_modules/@radix-ui/react-switch": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
+ "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-previous": "1.1.1",
+ "@radix-ui/react-use-size": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-context": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
+ "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
+ "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-tabs": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
@@ -4822,9 +4848,6 @@
"arm"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4839,9 +4862,6 @@
"arm"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4856,9 +4876,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4873,9 +4890,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4890,9 +4904,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4907,9 +4918,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4924,9 +4932,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4941,9 +4946,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4958,9 +4960,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4975,9 +4974,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4992,9 +4988,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -5009,9 +5002,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -5026,9 +5016,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6188,9 +6175,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6205,9 +6189,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6222,9 +6203,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6239,9 +6217,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6256,9 +6231,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6273,9 +6245,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6290,9 +6259,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6307,9 +6273,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
diff --git a/package.json b/package.json
index 446cf39..418bbee 100644
--- a/package.json
+++ b/package.json
@@ -34,6 +34,7 @@
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.1.1",
+ "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.2",
"@react-email/components": "^0.0.36",
diff --git a/prisma/migrations/20260418214105_add_email_opt_out/migration.sql b/prisma/migrations/20260418214105_add_email_opt_out/migration.sql
new file mode 100644
index 0000000..e16db65
--- /dev/null
+++ b/prisma/migrations/20260418214105_add_email_opt_out/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "User" ADD COLUMN "emailOptOut" BOOLEAN NOT NULL DEFAULT false;
diff --git a/prisma/migrations/20260418220000_split_email_opt_out/migration.sql b/prisma/migrations/20260418220000_split_email_opt_out/migration.sql
new file mode 100644
index 0000000..08a9a88
--- /dev/null
+++ b/prisma/migrations/20260418220000_split_email_opt_out/migration.sql
@@ -0,0 +1,15 @@
+-- Split emailOptOut into announcementEmailOptOut and discussionEmailOptOut.
+-- Preserve existing opt-out values by copying emailOptOut into both new columns.
+
+-- AlterTable
+ALTER TABLE "User"
+ ADD COLUMN "announcementEmailOptOut" BOOLEAN NOT NULL DEFAULT false,
+ ADD COLUMN "discussionEmailOptOut" BOOLEAN NOT NULL DEFAULT false;
+
+-- Migrate existing opt-out flag to both new columns
+UPDATE "User" SET
+ "announcementEmailOptOut" = "emailOptOut",
+ "discussionEmailOptOut" = "emailOptOut";
+
+-- Drop old column
+ALTER TABLE "User" DROP COLUMN "emailOptOut";
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index a5f97d0..df2fe69 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -71,7 +71,9 @@ model User {
image String?
productSurveyId String?
productSurvey ProductInterests? @relation(fields: [productSurveyId], references: [id], onDelete: Cascade)
- role UserRole?
+ role UserRole?
+ announcementEmailOptOut Boolean @default(false)
+ discussionEmailOptOut Boolean @default(false)
accounts Account[]
sessions Session[]
announcements Announcement[]
diff --git a/src/app/api/announcement-emails/route.test.ts b/src/app/api/announcement-emails/route.test.ts
index 1f5f7b4..3408b01 100644
--- a/src/app/api/announcement-emails/route.test.ts
+++ b/src/app/api/announcement-emails/route.test.ts
@@ -187,7 +187,7 @@ describe('POST /api/announcement-emails', () => {
await POST(req);
expect(prisma.user.findMany).toHaveBeenCalledWith({
- where: { role: 'NONPROFIT' },
+ where: { role: 'NONPROFIT', announcementEmailOptOut: false },
select: { email: true, name: true },
});
});
@@ -210,7 +210,7 @@ describe('POST /api/announcement-emails', () => {
await POST(req);
expect(prisma.user.findMany).toHaveBeenCalledWith({
- where: {},
+ where: { announcementEmailOptOut: false },
select: { email: true, name: true },
});
});
diff --git a/src/app/api/announcement-emails/route.ts b/src/app/api/announcement-emails/route.ts
index 7e73088..9085731 100644
--- a/src/app/api/announcement-emails/route.ts
+++ b/src/app/api/announcement-emails/route.ts
@@ -36,14 +36,14 @@ export async function POST(req: Request) {
);
}
- // Build role filter based on groupType
+ // Build role filter based on groupType; exclude users who opted out of emails
const roleFilter =
announcement.groupType === GroupType.ALL
? {}
: { role: announcement.groupType as unknown as UserRole };
const users = await prisma.user.findMany({
- where: roleFilter,
+ where: { ...roleFilter, announcementEmailOptOut: false },
select: { email: true, name: true },
});
@@ -59,6 +59,7 @@ export async function POST(req: Request) {
}
const authorName = announcement.author?.name ?? 'Admin Team';
+ const settingsUrl = `${new URL(req.url).origin}/settings`;
const emailRequests = users.map((user) => ({
from: 'Metro Atlanta Food Consortium ',
@@ -69,6 +70,7 @@ export async function POST(req: Request) {
title: announcement.title,
content: announcement.content,
authorName,
+ settingsUrl,
}),
}));
diff --git a/src/app/api/discussion-emails/route.test.ts b/src/app/api/discussion-emails/route.test.ts
index e9d3358..bec0a8c 100644
--- a/src/app/api/discussion-emails/route.test.ts
+++ b/src/app/api/discussion-emails/route.test.ts
@@ -175,7 +175,7 @@ describe('POST /api/discussion-emails', () => {
await POST(req);
expect(prisma.user.findMany).toHaveBeenCalledWith({
- where: { role: 'NONPROFIT' },
+ where: { role: 'NONPROFIT', discussionEmailOptOut: false },
select: { email: true, name: true },
});
});
@@ -199,7 +199,7 @@ describe('POST /api/discussion-emails', () => {
await POST(req);
expect(prisma.user.findMany).toHaveBeenCalledWith({
- where: {},
+ where: { discussionEmailOptOut: false },
select: { email: true, name: true },
});
});
diff --git a/src/app/api/discussion-emails/route.ts b/src/app/api/discussion-emails/route.ts
index b24017d..7e6d2b8 100644
--- a/src/app/api/discussion-emails/route.ts
+++ b/src/app/api/discussion-emails/route.ts
@@ -33,12 +33,12 @@ export async function POST(req: Request) {
return NextResponse.json({ error: 'Thread not found' }, { status: 404 });
}
- // Build role filter based on groupType
+ // Build role filter based on groupType; exclude users who opted out of emails
const roleFilter =
thread.groupType === GroupType.ALL ? {} : { role: thread.groupType };
const users = await prisma.user.findMany({
- where: roleFilter,
+ where: { ...roleFilter, discussionEmailOptOut: false },
select: { email: true, name: true },
});
@@ -54,6 +54,7 @@ export async function POST(req: Request) {
}
const authorName = thread.author?.name ?? 'Community Member';
+ const settingsUrl = `${new URL(req.url).origin}/settings`;
const emailRequests = users.map((user) => ({
from: 'Metro Atlanta Food Consortium ',
@@ -65,6 +66,7 @@ export async function POST(req: Request) {
threadContent: thread.content,
authorName,
groupType: thread.groupType,
+ settingsUrl,
}),
}));
diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts
new file mode 100644
index 0000000..99dc64c
--- /dev/null
+++ b/src/app/api/settings/route.ts
@@ -0,0 +1,59 @@
+import { NextResponse } from 'next/server';
+import { auth } from '@/lib/auth';
+import { prisma } from '@/lib/prisma';
+
+export async function GET() {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const user = await prisma.user.findUnique({
+ where: { id: session.user.id },
+ select: { announcementEmailOptOut: true, discussionEmailOptOut: true },
+ });
+
+ if (!user) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+
+ return NextResponse.json({
+ announcementEmailOptOut: user.announcementEmailOptOut,
+ discussionEmailOptOut: user.discussionEmailOptOut,
+ });
+}
+
+export async function PATCH(req: Request) {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const body = await req.json();
+
+ const { announcementEmailOptOut, discussionEmailOptOut } = body;
+
+ if (
+ typeof announcementEmailOptOut !== 'boolean' ||
+ typeof discussionEmailOptOut !== 'boolean'
+ ) {
+ return NextResponse.json(
+ {
+ error:
+ 'announcementEmailOptOut and discussionEmailOptOut must be booleans',
+ },
+ { status: 400 }
+ );
+ }
+
+ const user = await prisma.user.update({
+ where: { id: session.user.id },
+ data: { announcementEmailOptOut, discussionEmailOptOut },
+ select: { announcementEmailOptOut: true, discussionEmailOptOut: true },
+ });
+
+ return NextResponse.json({
+ announcementEmailOptOut: user.announcementEmailOptOut,
+ discussionEmailOptOut: user.discussionEmailOptOut,
+ });
+}
diff --git a/src/app/settings/email-settings-form.tsx b/src/app/settings/email-settings-form.tsx
new file mode 100644
index 0000000..97d5d61
--- /dev/null
+++ b/src/app/settings/email-settings-form.tsx
@@ -0,0 +1,143 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+interface EmailPrefs {
+ announcementEmailOptOut: boolean;
+ discussionEmailOptOut: boolean;
+}
+
+function Toggle({
+ enabled,
+ disabled,
+ onToggle,
+ label,
+}: {
+ enabled: boolean;
+ disabled: boolean;
+ onToggle: () => void;
+ label: string;
+}) {
+ return (
+
+ );
+}
+
+export function EmailSettingsForm() {
+ const [prefs, setPrefs] = useState({
+ announcementEmailOptOut: false,
+ discussionEmailOptOut: false,
+ });
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [saved, setSaved] = useState(false);
+
+ useEffect(() => {
+ fetch('/api/settings')
+ .then((r) => r.json())
+ .then((data: EmailPrefs) => setPrefs(data))
+ .catch(console.error)
+ .finally(() => setLoading(false));
+ }, []);
+
+ const save = async (next: EmailPrefs) => {
+ setSaving(true);
+ setSaved(false);
+ try {
+ await fetch('/api/settings', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(next),
+ });
+ setSaved(true);
+ setTimeout(() => setSaved(false), 2500);
+ } catch {
+ // revert on error
+ setPrefs(prefs);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const toggle = (field: keyof EmailPrefs) => {
+ const next = { ...prefs, [field]: !prefs[field] };
+ setPrefs(next);
+ save(next);
+ };
+
+ if (loading) {
+ return (
+
+ {[0, 1].map((i) => (
+
+ ))}
+
+ );
+ }
+
+ return (
+
+ {/* Announcement emails */}
+
+
+
Announcement emails
+
+ Receive emails when new announcements are posted.
+
+
+
toggle('announcementEmailOptOut')}
+ label='Toggle announcement emails'
+ />
+
+
+ {/* Discussion emails */}
+
+
+
Discussion emails
+
+ Receive emails when new discussion threads are posted.
+
+
+
toggle('discussionEmailOptOut')}
+ label='Toggle discussion emails'
+ />
+
+
+ {saved && (
+
+ ✓ Preferences saved
+
+ )}
+
+ );
+}
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx
new file mode 100644
index 0000000..6663d52
--- /dev/null
+++ b/src/app/settings/page.tsx
@@ -0,0 +1,37 @@
+import { Metadata } from 'next';
+import { auth } from '@/lib/auth';
+import { redirect } from 'next/navigation';
+import { EmailSettingsForm } from './email-settings-form';
+
+export const metadata: Metadata = {
+ title: 'Settings',
+ description: 'Manage your notification preferences',
+};
+
+export default async function SettingsPage() {
+ const session = await auth();
+
+ if (!session?.user) {
+ redirect('/');
+ }
+
+ return (
+
+
Account Settings
+
+ Manage your notification preferences for the Metro Atlanta Food
+ Consortium platform.
+
+
+
+ Email Notifications
+
+ Control which emails the platform sends to you. Some emails are
+ required and cannot be turned off.
+
+
+
+
+
+ );
+}
diff --git a/src/components/layout/user-menu.tsx b/src/components/layout/user-menu.tsx
index f625e35..e1ea4a1 100644
--- a/src/components/layout/user-menu.tsx
+++ b/src/components/layout/user-menu.tsx
@@ -13,7 +13,7 @@ import {
import { EditProfileDialog } from './edit-profile-dialog';
import { ThemeSwitcher } from './theme-switcher';
import { DropdownMenuSeparator } from '@radix-ui/react-dropdown-menu';
-import { LogOut, UserCog, UserCheck } from 'lucide-react';
+import { LogOut, Settings, UserCog, UserCheck } from 'lucide-react';
import Link from 'next/link';
export function UserMenu() {
@@ -63,6 +63,13 @@ export function UserMenu() {
Edit Profile
+
+
+
+
+ Settings
+
+
{session.user?.role === 'OTHER' && (
<>
diff --git a/src/emails/AnnouncementNotification.tsx b/src/emails/AnnouncementNotification.tsx
index 0f237ec..1ced67f 100644
--- a/src/emails/AnnouncementNotification.tsx
+++ b/src/emails/AnnouncementNotification.tsx
@@ -6,6 +6,7 @@ export interface AnnouncementNotificationProps {
title: string;
content: string;
authorName: string;
+ settingsUrl: string;
}
export default function AnnouncementNotification({
@@ -13,6 +14,7 @@ export default function AnnouncementNotification({
title,
content,
authorName,
+ settingsUrl,
}: AnnouncementNotificationProps) {
return (
@@ -54,6 +56,15 @@ export default function AnnouncementNotification({
Metro Atlanta Food Consortium
+
+
+
+ You are receiving this email because you are a member of the Metro
+ Atlanta Food Consortium platform.{' '}
+
+ Unsubscribe from announcement emails
+
+
);
diff --git a/src/emails/NewThreadNotification.tsx b/src/emails/NewThreadNotification.tsx
index 16e35e7..d1e915e 100644
--- a/src/emails/NewThreadNotification.tsx
+++ b/src/emails/NewThreadNotification.tsx
@@ -7,6 +7,7 @@ export interface NewThreadNotificationProps {
threadContent: string;
authorName: string;
groupType: string;
+ settingsUrl: string;
}
export default function NewThreadNotification({
@@ -15,6 +16,7 @@ export default function NewThreadNotification({
threadContent,
authorName,
groupType,
+ settingsUrl,
}: NewThreadNotificationProps) {
return (
@@ -64,6 +66,15 @@ export default function NewThreadNotification({
Metro Atlanta Food Consortium
+
+
+
+ You are receiving this email because you are a member of the Metro
+ Atlanta Food Consortium platform.{' '}
+
+ Unsubscribe from discussion emails
+
+
);
From 9b0f6dc4d550b2a07ab2b74b8f2d49fcc15289bc Mon Sep 17 00:00:00 2001
From: trickkyricky
Date: Sun, 19 Apr 2026 18:24:34 -0500
Subject: [PATCH 7/9] resolve pr issues
---
src/app/announcements/announcements-grid.tsx | 4 +---
src/app/api/announcement-emails/route.ts | 2 +-
src/app/api/discussion-emails/route.ts | 6 +++++-
src/app/settings/email-settings-form.tsx | 19 +++++++------------
4 files changed, 14 insertions(+), 17 deletions(-)
diff --git a/src/app/announcements/announcements-grid.tsx b/src/app/announcements/announcements-grid.tsx
index 93f0216..e070614 100644
--- a/src/app/announcements/announcements-grid.tsx
+++ b/src/app/announcements/announcements-grid.tsx
@@ -88,9 +88,7 @@ export function AnnouncementsGrid() {
const [loading, setLoading] = useState(false);
const { data: session } = useSession();
- const isAdmin =
- session?.user?.role === UserRole.ADMIN ||
- session?.user?.role === UserRole.STAFF;
+ const isAdmin = session?.user?.role === UserRole.ADMIN;
useEffect(() => {
const loadAnnouncements = async () => {
diff --git a/src/app/api/announcement-emails/route.ts b/src/app/api/announcement-emails/route.ts
index 9085731..65bde51 100644
--- a/src/app/api/announcement-emails/route.ts
+++ b/src/app/api/announcement-emails/route.ts
@@ -9,7 +9,7 @@ import { UserRole, GroupType } from '../../../../types/types';
export async function POST(req: Request) {
try {
const session = await auth();
- if (!session || !session.user) {
+ if (!session || !session.user || session.user.role !== UserRole.ADMIN) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
diff --git a/src/app/api/discussion-emails/route.ts b/src/app/api/discussion-emails/route.ts
index 7e6d2b8..fb07833 100644
--- a/src/app/api/discussion-emails/route.ts
+++ b/src/app/api/discussion-emails/route.ts
@@ -25,7 +25,7 @@ export async function POST(req: Request) {
const thread = await prisma.thread.findUnique({
where: { id: threadId },
include: {
- author: { select: { name: true } },
+ author: { select: { id: true, name: true } },
},
});
@@ -33,6 +33,10 @@ export async function POST(req: Request) {
return NextResponse.json({ error: 'Thread not found' }, { status: 404 });
}
+ if (thread.author?.id !== session.user.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
// Build role filter based on groupType; exclude users who opted out of emails
const roleFilter =
thread.groupType === GroupType.ALL ? {} : { role: thread.groupType };
diff --git a/src/app/settings/email-settings-form.tsx b/src/app/settings/email-settings-form.tsx
index 97d5d61..efd2e25 100644
--- a/src/app/settings/email-settings-form.tsx
+++ b/src/app/settings/email-settings-form.tsx
@@ -1,6 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
+import { useToast } from '@/hooks/use-toast';
interface EmailPrefs {
announcementEmailOptOut: boolean;
@@ -45,13 +46,13 @@ function Toggle({
}
export function EmailSettingsForm() {
+ const { toast } = useToast();
const [prefs, setPrefs] = useState({
announcementEmailOptOut: false,
discussionEmailOptOut: false,
});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
- const [saved, setSaved] = useState(false);
useEffect(() => {
fetch('/api/settings')
@@ -63,18 +64,18 @@ export function EmailSettingsForm() {
const save = async (next: EmailPrefs) => {
setSaving(true);
- setSaved(false);
try {
- await fetch('/api/settings', {
+ const res = await fetch('/api/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(next),
});
- setSaved(true);
- setTimeout(() => setSaved(false), 2500);
+ if (!res.ok) throw new Error(`Server responded with ${res.status}`);
+ toast({ title: '✓ Preferences saved', variant: 'success' });
} catch {
- // revert on error
+ // revert optimistic update and surface the error
setPrefs(prefs);
+ toast({ title: 'Failed to save preferences', variant: 'destructive' });
} finally {
setSaving(false);
}
@@ -132,12 +133,6 @@ export function EmailSettingsForm() {
label='Toggle discussion emails'
/>
-
- {saved && (
-
- ✓ Preferences saved
-
- )}
);
}
From 84cf7b45a0593de04c52349ebbc0ac4a6f394306 Mon Sep 17 00:00:00 2001
From: trickkyricky
Date: Sun, 19 Apr 2026 23:22:04 -0500
Subject: [PATCH 8/9] chore: test fixes
---
e2e/tests/08-email-settings.spec.ts | 8 ++---
src/app/api/announcement-emails/route.test.ts | 16 ++++++++++
src/app/api/discussion-emails/route.test.ts | 32 +++++++++++++------
3 files changed, 43 insertions(+), 13 deletions(-)
diff --git a/e2e/tests/08-email-settings.spec.ts b/e2e/tests/08-email-settings.spec.ts
index ef3d85c..1490bf1 100644
--- a/e2e/tests/08-email-settings.spec.ts
+++ b/e2e/tests/08-email-settings.spec.ts
@@ -125,7 +125,7 @@ test('toggling announcement emails off saves and shows confirmation', async ({
await announcementToggle.click();
// Should show saved confirmation
- await expect(page.getByText(/preferences saved/i)).toBeVisible({
+ await expect(page.getByText(/preferences saved/i).first()).toBeVisible({
timeout: 5_000,
});
@@ -175,7 +175,7 @@ test('toggling discussion emails off saves independently', async ({ page }) => {
});
await discussionToggle.click();
- await expect(page.getByText(/preferences saved/i)).toBeVisible({
+ await expect(page.getByText(/preferences saved/i).first()).toBeVisible({
timeout: 5_000,
});
@@ -251,13 +251,13 @@ test('user can opt back in to both email types', async ({ page }) => {
// Toggle announcements back ON
await announcementToggle.click();
- await expect(page.getByText(/preferences saved/i)).toBeVisible({
+ await expect(page.getByText(/preferences saved/i).first()).toBeVisible({
timeout: 5_000,
});
// Toggle discussions back ON
await discussionToggle.click();
- await expect(page.getByText(/preferences saved/i)).toBeVisible({
+ await expect(page.getByText(/preferences saved/i).first()).toBeVisible({
timeout: 5_000,
});
diff --git a/src/app/api/announcement-emails/route.test.ts b/src/app/api/announcement-emails/route.test.ts
index 3408b01..b1d6b8b 100644
--- a/src/app/api/announcement-emails/route.test.ts
+++ b/src/app/api/announcement-emails/route.test.ts
@@ -83,6 +83,22 @@ describe('POST /api/announcement-emails', () => {
expect(resend.batch.send).not.toHaveBeenCalled();
});
+ it('should return 401 if authenticated user is not an admin', async () => {
+ const nonAdminSession = { user: { id: 'user-2', role: 'SUPPLIER' } };
+ vi.mocked(auth).mockResolvedValue(nonAdminSession as any);
+
+ const req = new NextRequest('http://localhost/api/announcement-emails', {
+ method: 'POST',
+ body: JSON.stringify({ announcementId: 'ann-1' }),
+ });
+ const response = await POST(req);
+ const data = await response.json();
+
+ expect(response.status).toBe(401);
+ expect(data).toEqual({ error: 'Unauthorized' });
+ expect(resend.batch.send).not.toHaveBeenCalled();
+ });
+
it('should return 400 if announcementId is missing', async () => {
vi.mocked(auth).mockResolvedValue(adminSession as any);
diff --git a/src/app/api/discussion-emails/route.test.ts b/src/app/api/discussion-emails/route.test.ts
index bec0a8c..75ba2f4 100644
--- a/src/app/api/discussion-emails/route.test.ts
+++ b/src/app/api/discussion-emails/route.test.ts
@@ -40,7 +40,7 @@ const mockThread = {
title: 'Best practices for cold storage pickups?',
content: "We've been having trouble coordinating refrigerated item pickups.",
groupType: 'NONPROFIT',
- author: { name: 'Supplier Jane' },
+ author: { id: 'user-1', name: 'Supplier Jane' },
};
const mockUsers = [
@@ -83,6 +83,23 @@ describe('POST /api/discussion-emails', () => {
expect(resend.batch.send).not.toHaveBeenCalled();
});
+ it('should return 401 if caller is not the thread author', async () => {
+ const otherSession = { user: { id: 'other-user-99', role: 'NONPROFIT' } };
+ vi.mocked(auth).mockResolvedValue(otherSession as any);
+ vi.mocked(prisma.thread.findUnique).mockResolvedValue(mockThread as any);
+
+ const req = new NextRequest('http://localhost/api/discussion-emails', {
+ method: 'POST',
+ body: JSON.stringify({ threadId: 'thread-1' }),
+ });
+ const response = await POST(req);
+ const data = await response.json();
+
+ expect(response.status).toBe(401);
+ expect(data).toEqual({ error: 'Unauthorized' });
+ expect(resend.batch.send).not.toHaveBeenCalled();
+ });
+
it('should return 400 if threadId is missing', async () => {
vi.mocked(auth).mockResolvedValue(userSession as any);
@@ -204,26 +221,23 @@ describe('POST /api/discussion-emails', () => {
});
});
- it('should fall back to "Community Member" when thread has no author', async () => {
+ it('should return 401 when thread has no author (cannot verify authorship)', async () => {
const threadNoAuthor = { ...mockThread, author: null };
vi.mocked(auth).mockResolvedValue(userSession as any);
vi.mocked(prisma.thread.findUnique).mockResolvedValue(
threadNoAuthor as any
);
- vi.mocked(prisma.user.findMany).mockResolvedValue([mockUsers[0]] as any);
- vi.mocked(resend.batch.send).mockResolvedValue({
- data: null,
- error: null,
- } as any);
const req = new NextRequest('http://localhost/api/discussion-emails', {
method: 'POST',
body: JSON.stringify({ threadId: 'thread-1' }),
});
const response = await POST(req);
+ const data = await response.json();
- expect(response.status).toBe(200);
- expect(resend.batch.send).toHaveBeenCalledOnce();
+ expect(response.status).toBe(401);
+ expect(data).toEqual({ error: 'Unauthorized' });
+ expect(resend.batch.send).not.toHaveBeenCalled();
});
it('should return 500 when resend.batch.send throws', async () => {
From aad49812e3baf4557c2677b3ff5068d831f8e244 Mon Sep 17 00:00:00 2001
From: trickkyricky
Date: Thu, 23 Apr 2026 13:16:42 -0500
Subject: [PATCH 9/9] updae settings url
---
src/app/api/announcement-emails/route.ts | 2 +-
src/app/api/discussion-emails/route.ts | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/app/api/announcement-emails/route.ts b/src/app/api/announcement-emails/route.ts
index 65bde51..3bf8721 100644
--- a/src/app/api/announcement-emails/route.ts
+++ b/src/app/api/announcement-emails/route.ts
@@ -59,7 +59,7 @@ export async function POST(req: Request) {
}
const authorName = announcement.author?.name ?? 'Admin Team';
- const settingsUrl = `${new URL(req.url).origin}/settings`;
+ const settingsUrl = `${process.env.NEXTAUTH_URL}/settings`;
const emailRequests = users.map((user) => ({
from: 'Metro Atlanta Food Consortium ',
diff --git a/src/app/api/discussion-emails/route.ts b/src/app/api/discussion-emails/route.ts
index fb07833..233b62b 100644
--- a/src/app/api/discussion-emails/route.ts
+++ b/src/app/api/discussion-emails/route.ts
@@ -58,7 +58,7 @@ export async function POST(req: Request) {
}
const authorName = thread.author?.name ?? 'Community Member';
- const settingsUrl = `${new URL(req.url).origin}/settings`;
+ const settingsUrl = `${process.env.NEXTAUTH_URL}/settings`;
const emailRequests = users.map((user) => ({
from: 'Metro Atlanta Food Consortium ',