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. +

+ +
+

{title}

+

{content}

+
+ +

+ 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 ',