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 | diff --git a/e2e/tests/06-announcement-emails.spec.ts b/e2e/tests/06-announcement-emails.spec.ts new file mode 100644 index 0000000..38880d9 --- /dev/null +++ b/e2e/tests/06-announcement-emails.spec.ts @@ -0,0 +1,107 @@ +/** + * 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 '../../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' }); + +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({ adapter }); + 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..f061423 --- /dev/null +++ b/e2e/tests/07-discussion-emails.spec.ts @@ -0,0 +1,105 @@ +/** + * 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 '../../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' }); + +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({ adapter }); + 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/e2e/tests/08-email-settings.spec.ts b/e2e/tests/08-email-settings.spec.ts new file mode 100644 index 0000000..1490bf1 --- /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).first()).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).first()).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).first()).toBeVisible({ + timeout: 5_000, + }); + + // Toggle discussions back ON + await discussionToggle.click(); + await expect(page.getByText(/preferences saved/i).first()).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/announcements/announcements-grid.tsx b/src/app/announcements/announcements-grid.tsx index 9e0a25f..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 () => { @@ -132,6 +130,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..b1d6b8b --- /dev/null +++ b/src/app/api/announcement-emails/route.test.ts @@ -0,0 +1,292 @@ +/* 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 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); + + 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', announcementEmailOptOut: false }, + 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: { announcementEmailOptOut: false }, + 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..3bf8721 --- /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 { UserRole, GroupType } from '../../../../types/types'; + +export async function POST(req: Request) { + try { + const session = await auth(); + if (!session || !session.user || session.user.role !== UserRole.ADMIN) { + 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; 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, announcementEmailOptOut: false }, + 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 settingsUrl = `${process.env.NEXTAUTH_URL}/settings`; + + 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, + settingsUrl, + }), + })); + + 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..75ba2f4 --- /dev/null +++ b/src/app/api/discussion-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: { + 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: { id: 'user-1', 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 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); + + 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', discussionEmailOptOut: false }, + 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: { discussionEmailOptOut: false }, + select: { email: true, name: true }, + }); + }); + + 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 + ); + + 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 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..233b62b --- /dev/null +++ b/src/app/api/discussion-emails/route.ts @@ -0,0 +1,91 @@ +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 '../../../../types/types'; + +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: { id: true, name: true } }, + }, + }); + + if (!thread) { + 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 }; + + const users = await prisma.user.findMany({ + where: { ...roleFilter, discussionEmailOptOut: false }, + 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 settingsUrl = `${process.env.NEXTAUTH_URL}/settings`; + + 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, + settingsUrl, + }), + })); + + 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/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/discussion/discussion-grid.tsx b/src/app/discussion/discussion-grid.tsx index 1407ffe..a0fa975 100644 --- a/src/app/discussion/discussion-grid.tsx +++ b/src/app/discussion/discussion-grid.tsx @@ -153,7 +153,7 @@ export function DiscussionThreadsGrid() { } try { - await createThread({ + const newThread = await createThread({ title: form.title, content: form.content ?? '', groupType: form.groupType ?? Group.ADMIN, @@ -161,6 +161,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/app/settings/email-settings-form.tsx b/src/app/settings/email-settings-form.tsx new file mode 100644 index 0000000..efd2e25 --- /dev/null +++ b/src/app/settings/email-settings-form.tsx @@ -0,0 +1,138 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useToast } from '@/hooks/use-toast'; + +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 { toast } = useToast(); + const [prefs, setPrefs] = useState({ + announcementEmailOptOut: false, + discussionEmailOptOut: false, + }); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = 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); + try { + const res = await fetch('/api/settings', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(next), + }); + if (!res.ok) throw new Error(`Server responded with ${res.status}`); + toast({ title: '✓ Preferences saved', variant: 'success' }); + } catch { + // revert optimistic update and surface the error + setPrefs(prefs); + toast({ title: 'Failed to save preferences', variant: 'destructive' }); + } 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' + /> +
+
+ ); +} 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 new file mode 100644 index 0000000..1ced67f --- /dev/null +++ b/src/emails/AnnouncementNotification.tsx @@ -0,0 +1,71 @@ +import { Html } from '@react-email/components'; +import * as React from 'react'; + +export interface AnnouncementNotificationProps { + recipientName: string; + title: string; + content: string; + authorName: string; + settingsUrl: string; +} + +export default function AnnouncementNotification({ + recipientName, + title, + content, + authorName, + settingsUrl, +}: 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 +

+ +
+

+ 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 new file mode 100644 index 0000000..d1e915e --- /dev/null +++ b/src/emails/NewThreadNotification.tsx @@ -0,0 +1,81 @@ +import { Html } from '@react-email/components'; +import * as React from 'react'; + +export interface NewThreadNotificationProps { + recipientName: string; + threadTitle: string; + threadContent: string; + authorName: string; + groupType: string; + settingsUrl: string; +} + +export default function NewThreadNotification({ + recipientName, + threadTitle, + threadContent, + authorName, + groupType, + settingsUrl, +}: 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 +

+ +
+

+ You are receiving this email because you are a member of the Metro + Atlanta Food Consortium platform.{' '} + + Unsubscribe from discussion emails + +

+
+ + ); +} diff --git a/types/types.ts b/types/types.ts index 28bb4dc..fc836de 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',