Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
d062778
Add global theme toggle to app header
iamsrijan724 Mar 14, 2026
5815c91
Add dark mode styling to admin dashboard shell
iamsrijan724 Mar 14, 2026
8a1c3ae
Add dark mode styling to admin tab navigation
iamsrijan724 Mar 14, 2026
4600dc7
Add dark mode styling to admin overview chart cards
iamsrijan724 Mar 14, 2026
bd2a55f
Add dark mode styling to KPI cards
iamsrijan724 Mar 14, 2026
59d8c80
Merge branch 'dark-mode-srijan-kiersten' into staging
iamsrijan724 Mar 17, 2026
914ed58
feat: updated changes for stage
TrickkyRicky Mar 18, 2026
dd07045
Merge pull request #30 from C4G/branched-from-doc-wesley-for-stage
TrickkyRicky Mar 18, 2026
9f6562e
Update deploy.yml
mrysav Mar 18, 2026
5d35b0d
Update deploy.yml
mrysav Mar 18, 2026
f2ceed8
Update deploy.yml
mrysav Mar 18, 2026
620f528
feat: merge and project team page
TrickkyRicky Mar 18, 2026
ea25454
feat: force cookies to store with hardcode
TrickkyRicky Mar 18, 2026
213fdc5
feat: add production flag to force local and stage
TrickkyRicky Mar 18, 2026
282247d
Merge pull request #32 from C4G/feat-miscellaneous-changes-and-bug-fi…
TrickkyRicky Mar 18, 2026
695f642
add survey link
TrickkyRicky Mar 18, 2026
269a9b0
Add npmrc to Dockerfile.migrations build
mrysav Mar 19, 2026
2baa916
uncomment migrations build
mrysav Mar 19, 2026
7a35254
Fix admin page title colors for dark mode
Mar 20, 2026
e08001f
Merge latest main into dark-mode-srijan-kiersten and resolve admin ov…
iamsrijan724 Mar 21, 2026
51841e9
Fix hover tooltips to be better in dark mode
iamsrijan724 Mar 21, 2026
d0bce20
Fix all chart labels to be white in dark mode
iamsrijan724 Mar 21, 2026
9d28200
Fix dark mode in suppliers table
iamsrijan724 Mar 21, 2026
7002086
Fix dark mode in nonprofits and product requests tables
iamsrijan724 Mar 21, 2026
54229da
Restore icons
mrysav Mar 28, 2026
b9bb14e
feat: demo tab and video
TrickkyRicky Mar 30, 2026
900a0dd
Migrate to Prisma 7
mrysav Mar 19, 2026
40051fa
Generate prisma client in ci.yml
mrysav Apr 6, 2026
3e25ee2
Merge branch 'prisma7' into staging
mrysav Apr 6, 2026
ff6dd1a
set dummy DATABASE_URL
mrysav Apr 6, 2026
7c34f2c
Update Dockerfile.migrations
mrysav Apr 6, 2026
541695d
Merge branch 'feat-top-navbar-will' into staging
whao37 Apr 11, 2026
033f1ad
Merge branch 'claims-sort-options' into staging
whao37 Apr 12, 2026
2bc282a
nhance supplier dashboard with drill-down KPIs, sortable tables, and …
Apr 12, 2026
9ef5816
feat: added partail claim for food selections
TrickkyRicky Apr 12, 2026
4aa451b
fix cancel button text and border
TrickkyRicky Apr 12, 2026
50a65a6
resolve bug where partial claiming and full claiming created 2 claim …
TrickkyRicky Apr 12, 2026
56896ef
Merge branch 'staging' of https://github.com/C4G/AtlantaFoodConsortiu…
TrickkyRicky Apr 12, 2026
8ad00cf
Merge pull request #50 from C4G/feat-add-partial-nonprofit-claims-sta…
TrickkyRicky Apr 12, 2026
8c5ac48
Merge branch 'staging' into feat-supplier-dashboard
dipenp495 Apr 12, 2026
118db2d
Merge pull request #48 from C4G/feat-supplier-dashboard
dipenp495 Apr 12, 2026
f7a9a96
feat: email notifications for discussions and announcements
TrickkyRicky Apr 13, 2026
cee8645
Merge branch 'staging' of https://github.com/C4G/AtlantaFoodConsortiu…
TrickkyRicky Apr 13, 2026
9373698
feat: staging email notifier and team 2026 update
TrickkyRicky Apr 13, 2026
c51a573
email quick login and pipeline fixes
TrickkyRicky Apr 13, 2026
d47e8aa
Merge pull request #51 from C4G/feat-announcement-discussion-notifier…
TrickkyRicky Apr 13, 2026
db85947
add email upsert for test login
TrickkyRicky Apr 13, 2026
464e472
Apply dark mode across all pages and components
dipenp495 Apr 15, 2026
f88f4e9
Merge branch 'staging' into dark-mode-srijan-kiersten
dipenp495 Apr 15, 2026
679e775
Add prisma generate step to e2e workflow
dipenp495 Apr 15, 2026
dbe06b1
Fix e2e test Prisma imports to use generated client
dipenp495 Apr 15, 2026
7977726
Fix e2e Prisma client to use PrismaPg adapter
dipenp495 Apr 15, 2026
073cd7a
Fix quick login buttons dark mode colors
dipenp495 Apr 15, 2026
2b60312
Sync test-login route with staging
dipenp495 Apr 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ jobs:

- name: Build
run: |
npm ci
npm ci --ignore-scripts
DATABASE_URL="postgres://user:pass@localhost:5432/test" npx prisma generate

- name: Run tests
run: npm test
12 changes: 8 additions & 4 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,11 @@ jobs:
package-type: 'container'
min-versions-to-keep: 10
delete-only-untagged-versions: 'false'

- name: Deploy to Coolify
run: |
curl --request GET '${{ secrets.STAGING_DEPLOY_URL }}' --header 'Authorization: Bearer ${{ secrets.DEPLOY_KEY }}'

- name: Deploy to staging
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.STAGING_HOST }}
username: ${{ secrets.STAGING_USER }}
key: ${{ secrets.STAGING_KEY }}
script: sudo /atlanta-food-consortium-staging/update.sh
3 changes: 3 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ jobs:
- name: Create file upload directory
run: mkdir -p /tmp/e2e-uploads

- name: Generate Prisma client
run: npx prisma generate

- name: Run database migrations
run: npx prisma migrate deploy

Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,8 @@ e2e/.state/
e2e/report/
e2e/results/
playwright-report/

# prisma
/src/generated/prisma
/prisma/generated/prisma

4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ RUN \
COPY . .

# Generate Prisma client
RUN npx prisma generate
RUN DATABASE_URL="postgres://user:pass@localhost/test" npx prisma generate

# Not used during build, but needs to be set
ENV FILE_UPLOADS="/app/uploads"
Expand Down Expand Up @@ -78,4 +78,4 @@ ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

# Start the application
CMD ["node", "server.js"]
CMD ["node", "server.js"]
4 changes: 2 additions & 2 deletions Dockerfile.migrations
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs

# Copy only files needed for migrations
COPY --chown=nextjs:nodejs package.json package-lock.json .npmrc* ./
COPY --chown=nextjs:nodejs package.json package-lock.json .npmrc* prisma.config.ts ./
COPY --chown=nextjs:nodejs prisma ./prisma

# Install production dependencies (skip postinstall to avoid prisma generate before CLI is available)
# Then install prisma CLI from devDependencies and generate client
RUN npm install --omit=dev --ignore-scripts && \
npm install --save-dev prisma && \
npx prisma generate
DATABASE_URL="postgres://user:pass@localhost/test" npx prisma generate

# Switch to non-root user
USER nextjs
Expand Down
9 changes: 7 additions & 2 deletions e2e/global.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import './load-env';
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import { PrismaClient } from '@prisma/client';
import { PrismaClient } from '../src/generated/prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import {
E2E_PREFIX,
TEST_ADMIN_EMAIL,
Expand All @@ -26,6 +27,10 @@ import {
writeState,
} from './shared-state';

const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL,
});

// ─── Cleanup helpers ────────────────────
async function cleanupTestData(prisma: PrismaClient) {
await prisma.session.deleteMany({
Expand Down Expand Up @@ -62,7 +67,7 @@ async function cleanupTestData(prisma: PrismaClient) {
}

async function globalSetup() {
const prisma = new PrismaClient();
const prisma = new PrismaClient({ adapter });

try {
console.log('\nE2E global setup starting…');
Expand Down
9 changes: 7 additions & 2 deletions e2e/global.teardown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,16 @@
*/

import './load-env';
import { PrismaClient } from '@prisma/client';
import { PrismaClient } from '../src/generated/prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { E2E_PREFIX, clearStateFile } from './shared-state';

const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL,
});

async function globalTeardown() {
const prisma = new PrismaClient();
const prisma = new PrismaClient({ adapter });

try {
console.log('\n E2E global teardown starting…');
Expand Down
78 changes: 78 additions & 0 deletions e2e/tests/03-nonprofit-claim-product.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,84 @@ test('Available Products tab shows the supplier product with Claim enabled', asy
await expect(claimButton).toBeEnabled();
});

test('nonprofit partially claims 30 units — product stays in Available and appears in My Claims', async ({
page,
}) => {
await page.route('**/api/product-request-claimed-emails', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true }),
});
});

await page.goto('/nonprofit');
await page.getByRole('button', { name: /available products/i }).click();
await expect(page.getByText(TEST_PRODUCT_NAME)).toBeVisible({
timeout: 10_000,
});

const productCard = page
.locator('.space-y-4 > div')
.filter({ hasText: TEST_PRODUCT_NAME });
await productCard
.getByRole('button', { name: /claim this product/i })
.click();

// Modal opens — change quantity from default (100) to 30
await expect(
page.getByRole('heading', { name: /claim this product/i })
).toBeVisible();
await page.locator('#claim-quantity').fill('30');
await page.getByRole('button', { name: /confirm claim/i }).click();

// Product card should still be visible (partial claim leaves the remainder available)
await expect(productCard).toBeVisible({ timeout: 8_000 });

// Switch to My Claims the 30-unit entry should appear.
// Use heading role to avoid strict-mode collision with the still-visible toast notification.
await page.getByRole('button', { name: /my claims/i }).click();
await expect(
page.getByRole('heading', { name: TEST_PRODUCT_NAME })
).toBeVisible({
timeout: 8_000,
});
await expect(page.getByText('Quantity: 30')).toBeVisible({ timeout: 5_000 });
});

test('unclaiming the partial claim restores the available quantity', async ({
page,
}) => {
await page.goto('/nonprofit');
await page.getByRole('button', { name: /my claims/i }).click();

// The 30-unit partial claim should be present
const claimedCard = page
.locator('[class*="rounded-lg"]')
.filter({ hasText: TEST_PRODUCT_NAME })
.filter({ hasText: 'Quantity: 30' });
await expect(claimedCard).toBeVisible({ timeout: 8_000 });

await claimedCard.getByRole('button', { name: /unclaim product/i }).click();

// Unclaim confirmation modal
await expect(
page.getByRole('heading', { name: /unclaim this product/i })
).toBeVisible();
await page.getByRole('button', { name: /confirm unclaim/i }).click();

// The entry should vanish from My Claims
await expect(claimedCard).not.toBeVisible({ timeout: 8_000 });

// Switch to Available Products — the original product's quantity is restored to 100
await page.getByRole('button', { name: /available products/i }).click();
const restoredCard = page
.locator('.space-y-4 > div')
.filter({ hasText: TEST_PRODUCT_NAME });
await expect(restoredCard).toBeVisible({ timeout: 8_000 });
await expect(restoredCard).toContainText('100', { timeout: 5_000 });
});

test('nonprofit claims the product and it moves to My Claims', async ({
page,
}) => {
Expand Down
26 changes: 16 additions & 10 deletions e2e/tests/05-edge-cases.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,25 @@

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 * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import { E2E_PREFIX, EDGE_CASE_PRODUCT_NAME, readState } from '../shared-state';

const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL,
});

test.describe('A — Unapproved nonprofit sees disabled claim button', () => {
// We create a second nonprofit WITHOUT approval and use its session
const UNAPPROVED_EMAIL = `${E2E_PREFIX}-unapproved@test.local`;
const UNAPPROVED_ORG = `${E2E_PREFIX} Unapproved Org`;
const UNAPPROVED_AUTH = 'e2e/.auth/unapproved.json';

test.beforeAll(async () => {
const prisma = new PrismaClient();
const prisma = new PrismaClient({ adapter });
try {
// Clean up any previous run
await prisma.session.deleteMany({
Expand Down Expand Up @@ -104,7 +109,7 @@ test.describe('A — Unapproved nonprofit sees disabled claim button', () => {
});

test.afterAll(async () => {
const prisma = new PrismaClient();
const prisma = new PrismaClient({ adapter });
try {
await prisma.session.deleteMany({
where: { user: { email: UNAPPROVED_EMAIL } },
Expand Down Expand Up @@ -152,19 +157,19 @@ test.describe('A — Unapproved nonprofit sees disabled claim button', () => {
});
});

// ─── B. Claiming an already-RESERVED product returns an error ────────────
// ─── B. Claiming an already-RESERVED product returns a conflict error ───

test.describe('B — Double-claim a RESERVED product is idempotent', () => {
test.describe('B — Double-claim a RESERVED product returns 409', () => {
test.use({ storageState: 'e2e/.auth/nonprofit.json' });

test('PATCH /api/item-availability on already-RESERVED product returns 200 and status stays RESERVED', async ({
test('PATCH /api/item-availability on already-RESERVED product returns 409', async ({
request,
}) => {
const state = readState();
const productId = state.postedProductId;

// The product was claimed in test 03 — its status is now RESERVED.
// A second PATCH should still respond. the API responds and the status stays RESERVED.
// The API guards against re-claiming and returns 409 Conflict.
if (!productId) {
console.warn(
'postedProductId not found in state — skipping double-claim check'
Expand All @@ -176,9 +181,10 @@ test.describe('B — Double-claim a RESERVED product is idempotent', () => {
data: { productId },
});

// The API has no guard against re-claiming
// RESERVED again and returns 200.
expect(res.status()).toBe(200);
// API correctly rejects claiming an already-RESERVED product.
expect(res.status()).toBe(409);
const body = await res.json();
expect(body.error).toMatch(/no longer available/i);

// Confirm the product is still RESERVED in the DB
const getRes = await request.get(`/api/item-availability?status=RESERVED`);
Expand Down
104 changes: 104 additions & 0 deletions e2e/tests/06-announcement-emails.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* 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';

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 adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
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();
});
Loading
Loading