From b86d5285b78392a6527bd8933e5f1ae3f44b1f38 Mon Sep 17 00:00:00 2001 From: Ben Petrillo Date: Fri, 17 Apr 2026 23:27:56 -0400 Subject: [PATCH 1/4] feat: implement donations tracker --- apps/frontend/src/api/apiClient.ts | 59 +- apps/frontend/src/app.tsx | 11 + apps/frontend/src/components/ui/button.tsx | 94 +- .../donations/DonationTrackerPage.tsx | 1090 +++++++++++++++++ .../dashboard/sidebar/DashboardOverview.tsx | 1 + scripts/docker-dev.bat | 140 --- scripts/docker-dev.sh | 187 --- scripts/init-db.sql | 43 - 8 files changed, 1197 insertions(+), 428 deletions(-) create mode 100644 apps/frontend/src/containers/dashboard/donations/DonationTrackerPage.tsx delete mode 100644 scripts/docker-dev.bat delete mode 100644 scripts/docker-dev.sh delete mode 100644 scripts/init-db.sql diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 36b3688..d4b012e 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -22,6 +22,37 @@ export type DonationStatsResponse = { monthToDate: number; }; +export type DonationListRow = { + id: number; + firstName: string; + lastName: string; + email: string; + amount: number; + donationType: 'one_time' | 'recurring'; + recurringInterval?: + | 'weekly' + | 'monthly' + | 'yearly' + | 'bimonthly' + | 'quarterly' + | 'annually'; + dedicationMessage?: string; + showDedicationPublicly: boolean; + status: 'pending' | 'succeeded' | 'failed' | 'cancelled'; + createdAt: string; + updatedAt: string; + transactionId?: string; + isAnonymous: boolean; +}; + +export type DonationListResponse = { + rows: DonationListRow[]; + total: number; + page: number; + perPage: number; + totalPages: number; +}; + export type ActiveGoalResponse = { goal: { id: number; @@ -185,22 +216,7 @@ export class ApiClient { status?: 'pending' | 'succeeded' | 'failed' | 'cancelled'; startDate?: string; endDate?: string; - }): Promise<{ - rows: Array<{ - id: number; - firstName: string; - lastName: string; - email: string; - amount: number; - donationType: 'one_time' | 'recurring'; - status: string; - createdAt: string; - }>; - total: number; - page: number; - perPage: number; - totalPages: number; - }> { + }): Promise { try { const res = await this.axiosInstance.get('/api/donations', { params, @@ -220,6 +236,17 @@ export class ApiClient { } } + public async exportDonationsCsv(): Promise { + try { + const res = await this.axiosInstance.get('/api/donations/export', { + responseType: 'blob', + }); + return res.data as Blob; + } catch (err: unknown) { + this.handleAxiosError(err, 'Failed to export donations'); + } + } + public async updateUserStatus( id: number, status: 'ADMIN' | 'STANDARD', diff --git a/apps/frontend/src/app.tsx b/apps/frontend/src/app.tsx index d9b13f9..4f10b20 100644 --- a/apps/frontend/src/app.tsx +++ b/apps/frontend/src/app.tsx @@ -16,6 +16,7 @@ import { ConfirmRegisteredPage } from '@containers/auth/ConfirmRegisteredPage'; import { DashboardPage } from '@containers/dashboard/DashboardPage'; import { DonorStatsChart } from '@components/DonorStatsChart'; import DashboardOverview from '@containers/dashboard/sidebar/DashboardOverview'; +import DonationTrackerPage from '@containers/dashboard/donations/DonationTrackerPage'; import { EmailEditor } from './components/EmailComms/EmailEditorOverviewPage'; import { AdminGrowingGoalTester } from '@containers/dashboard/AdminGrowingGoalTester'; import OverviewPage from '@containers/dashboard/OverviewPage'; @@ -49,6 +50,16 @@ const router = createBrowserRouter([ path: '', element: , }, + { + path: 'donations', + element: , + children: [ + { + path: '', + element: , + }, + ], + }, { path: 'email', element: , diff --git a/apps/frontend/src/components/ui/button.tsx b/apps/frontend/src/components/ui/button.tsx index 21caa09..bd86421 100644 --- a/apps/frontend/src/components/ui/button.tsx +++ b/apps/frontend/src/components/ui/button.tsx @@ -42,46 +42,56 @@ const buttonVariants = cva( }, }, ); -function Button({ - className, - variant = 'default', - size = 'default', - asChild = false, - withShareIcon = false, - children, - ...props -}: React.ComponentProps<'button'> & - VariantProps & { - asChild?: boolean; - withShareIcon?: boolean; - }) { - const Comp = asChild ? Slot : 'button'; - return ( - - {children} - {withShareIcon && ( - - - - - - )} - - ); -} +const Button = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<'button'> & + VariantProps & { + asChild?: boolean; + withShareIcon?: boolean; + } +>( + ( + { + className, + variant = 'default', + size = 'default', + asChild = false, + withShareIcon = false, + children, + ...props + }, + ref, + ) => { + const Comp = asChild ? Slot : 'button'; + return ( + + {children} + {withShareIcon && ( + + + + + + )} + + ); + }, +); +Button.displayName = 'Button'; export { Button, buttonVariants }; diff --git a/apps/frontend/src/containers/dashboard/donations/DonationTrackerPage.tsx b/apps/frontend/src/containers/dashboard/donations/DonationTrackerPage.tsx new file mode 100644 index 0000000..4086001 --- /dev/null +++ b/apps/frontend/src/containers/dashboard/donations/DonationTrackerPage.tsx @@ -0,0 +1,1090 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + ArrowDownUp, + CalendarDays, + Download, + Plus, + Search, + SlidersHorizontal, + X, +} from 'lucide-react'; + +import apiClient, { type DonationListRow } from '@api/apiClient'; +import { Button } from '@components/ui/button'; +import { Card, CardContent } from '@components/ui/card'; +import { Input } from '@components/ui/input'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@components/ui/popover'; + +const ROWS_PER_PAGE = 14; + +type SortKey = 'newest' | 'oldest' | 'highest' | 'lowest'; + +type RecurrenceFilterKey = + | 'one_time' + | 'weekly' + | 'monthly' + | 'yearly' + | 'bimonthly' + | 'quarterly' + | 'annually'; + +type FilterState = { + recurrences: Set; + statuses: Set; + dateFrom: string; + dateTo: string; + amountMin: string; + amountMax: string; +}; + +type CreateDonationState = { + firstName: string; + lastName: string; + email: string; + amount: string; + reason: string; + donationType: 'one_time' | 'recurring'; + recurringInterval: 'weekly' | 'monthly' | 'yearly'; + isAnonymous: boolean; + showDedicationPublicly: boolean; +}; + +const SORT_LABELS: Record = { + newest: 'Most Recent', + oldest: 'Oldest', + highest: 'Greatest', + lowest: 'Least', +}; + +function cn(...classes: Array) { + return classes.filter(Boolean).join(' '); +} + +function formatCurrency(amount: number): string { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }).format(amount); +} + +function formatDate(value: string): string { + return new Intl.DateTimeFormat('en-US', { + month: 'numeric', + day: 'numeric', + year: 'numeric', + }).format(new Date(value)); +} + +function getRecurrenceLabel(donation: DonationListRow): string { + if (donation.donationType !== 'recurring') { + return 'One-Time'; + } + + if (!donation.recurringInterval) { + return 'Recurring'; + } + + return ( + donation.recurringInterval.charAt(0).toUpperCase() + + donation.recurringInterval.slice(1) + ); +} + +function getReasonLabel(donation: DonationListRow): string { + if (donation.isAnonymous) { + return 'Anonymous'; + } + + if (donation.showDedicationPublicly && donation.dedicationMessage) { + return donation.dedicationMessage; + } + + return 'Standard'; +} + +function getFeeIndicator(status: DonationListRow['status']): { + label: string; + className: string; +} { + return { + label: status.charAt(0).toUpperCase() + status.slice(1), + className: 'bg-emerald-500', + }; +} + +function buildPageWindow( + page: number, + totalPages: number, +): Array { + if (totalPages <= 7) { + return Array.from({ length: totalPages }, (_, index) => index + 1); + } + + const pages: Array = [1]; + const leftBound = Math.max(2, page - 1); + const rightBound = Math.min(totalPages - 1, page + 1); + + if (leftBound > 2) { + pages.push('ellipsis'); + } + + for (let current = leftBound; current <= rightBound; current += 1) { + pages.push(current); + } + + if (rightBound < totalPages - 1) { + pages.push('ellipsis'); + } + + pages.push(totalPages); + return pages; +} + +function createInitialFilters(): FilterState { + return { + recurrences: new Set([ + 'one_time', + 'weekly', + 'monthly', + 'yearly', + 'bimonthly', + 'quarterly', + 'annually', + ]), + statuses: new Set(['pending', 'succeeded', 'failed', 'cancelled']), + dateFrom: '', + dateTo: '', + amountMin: '', + amountMax: '', + }; +} + +function cloneFilterState(filters: FilterState): FilterState { + return { + recurrences: new Set(filters.recurrences), + statuses: new Set(filters.statuses), + dateFrom: filters.dateFrom, + dateTo: filters.dateTo, + amountMin: filters.amountMin, + amountMax: filters.amountMax, + }; +} + +function createInitialDonationForm(): CreateDonationState { + return { + firstName: '', + lastName: '', + email: '', + amount: '', + reason: '', + donationType: 'one_time', + recurringInterval: 'monthly', + isAnonymous: false, + showDedicationPublicly: false, + }; +} + +function normalizeDonationSearch(donation: DonationListRow): string { + return [ + donation.firstName, + donation.lastName, + donation.email, + donation.amount, + donation.donationType, + donation.recurringInterval ?? '', + donation.status, + donation.dedicationMessage ?? '', + donation.transactionId ?? '', + donation.isAnonymous ? 'anonymous' : '', + ] + .join(' ') + .toLowerCase(); +} + +function isRecurringMatch( + donation: DonationListRow, + recurrences: FilterState['recurrences'], +): boolean { + if (donation.donationType !== 'recurring') { + return recurrences.has('one_time'); + } + + // Fallback for legacy recurring rows with missing interval. + if (!donation.recurringInterval) { + return true; + } + + return Boolean( + donation.recurringInterval && recurrences.has(donation.recurringInterval), + ); +} + +function toStartOfDayIso(value: string): Date { + return new Date(`${value}T00:00:00`); +} + +function toEndOfDayIso(value: string): Date { + return new Date(`${value}T23:59:59.999`); +} + +type ToolbarProps = { + totalCount: number; + visibleCount: number; + onExport: () => void; + onAddDonation: () => void; + searchValue: string; + onSearchChange: (value: string) => void; + sortKey: SortKey; + onSortChange: (value: SortKey) => void; + filters: FilterState; + onApplyFilters: (value: FilterState) => void; + onResetFilters: () => void; +}; + +function DonationTrackerToolbar({ + totalCount, + visibleCount, + onExport, + onAddDonation, + searchValue, + onSearchChange, + sortKey, + onSortChange, + filters, + onApplyFilters, + onResetFilters, +}: ToolbarProps) { + const [isSortOpen, setIsSortOpen] = useState(false); + const [isFilterOpen, setIsFilterOpen] = useState(false); + const [isExportOpen, setIsExportOpen] = useState(false); + + const activeFilterCount = + (filters.dateFrom ? 1 : 0) + + (filters.dateTo ? 1 : 0) + + (filters.amountMin ? 1 : 0) + + (filters.amountMax ? 1 : 0) + + (filters.recurrences.size !== createInitialFilters().recurrences.size + ? 1 + : 0); + + const updateFilters = (next: FilterState) => { + onApplyFilters(cloneFilterState(next)); + }; + + return ( +
+
+ + onSearchChange(event.target.value)} + className="h-8 border-0 bg-transparent px-0 text-sm shadow-none focus-visible:ring-0" + /> +
+ +
+ + + + + + + +

+ Sort donations +

+ {[ + { key: 'newest', label: 'Most Recent - Oldest' }, + { key: 'oldest', label: 'Oldest - Most Recent' }, + { key: 'highest', label: 'Greatest - Least' }, + { key: 'lowest', label: 'Least - Greatest' }, + ].map((option) => ( + + ))} +
+
+ + + + + + +
+

Filters

+ +
+ +
+
+ + Date range +
+
+ + updateFilters({ + ...filters, + dateFrom: event.target.value, + }) + } + /> + + updateFilters({ + ...filters, + dateTo: event.target.value, + }) + } + /> +
+
+ +
+

Recurrence

+
+ {[ + { key: 'one_time', label: 'One-Time' }, + { key: 'weekly', label: 'Weekly' }, + { key: 'monthly', label: 'Monthly' }, + { key: 'yearly', label: 'Yearly' }, + { key: 'bimonthly', label: 'Bi-monthly' }, + { key: 'quarterly', label: 'Quarterly' }, + { key: 'annually', label: 'Annually' }, + ].map((option) => { + const recurrenceKey = option.key as RecurrenceFilterKey; + const active = filters.recurrences.has(recurrenceKey); + + return ( + + ); + })} +
+
+ +
+

Amount

+
+ + updateFilters({ + ...filters, + amountMin: event.target.value, + }) + } + /> + + updateFilters({ + ...filters, + amountMax: event.target.value, + }) + } + /> +
+
+ +
+ +
+
+
+ + + + + + + + + + +
+ +
+ Showing {visibleCount} of {totalCount} donations +
+
+ ); +} + +function DonationTable({ rows }: { rows: DonationListRow[] }) { + return ( +
+
+ + + + + + + + + + + + + + + {rows.map((donation) => { + const feeIndicator = getFeeIndicator(donation.status); + + return ( + + + + + + + + + + + ); + })} + +
First NameLast NameEmailAmountRecurrenceDateFeeReason
+ {donation.firstName} + + {donation.lastName} + + {donation.email} + + {formatCurrency(donation.amount)} + + + {getRecurrenceLabel(donation)} + + + {formatDate(donation.createdAt)} + + + ✓ + + + + {getReasonLabel(donation)} + +
+
+
+ ); +} + +export default function DonationTrackerPage() { + const [allRows, setAllRows] = useState([]); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchValue, setSearchValue] = useState(''); + const [sortKey, setSortKey] = useState('newest'); + const [filters, setFilters] = useState(() => + createInitialFilters(), + ); + const [showCreateDonation, setShowCreateDonation] = useState(false); + const [createDonationForm, setCreateDonationForm] = + useState(() => createInitialDonationForm()); + const [createError, setCreateError] = useState(null); + const [isCreating, setIsCreating] = useState(false); + + const loadDonations = useCallback(async () => { + setLoading(true); + setError(null); + + try { + const response = await apiClient.getDonations({ + page: 1, + perPage: 1000, + }); + + setAllRows(response.rows); + } catch (fetchError) { + setAllRows([]); + setError( + fetchError instanceof Error + ? fetchError.message + : 'Failed to load donations', + ); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadDonations(); + }, [loadDonations]); + + const filteredRows = useMemo(() => { + const query = searchValue.trim().toLowerCase(); + + const nextRows = allRows.filter((donation) => { + const matchesSearch = + query.length === 0 || normalizeDonationSearch(donation).includes(query); + + const matchesRecurrence = isRecurringMatch(donation, filters.recurrences); + const matchesStatus = filters.statuses.has(donation.status); + + const donationDate = new Date(donation.createdAt); + const matchesDateFrom = + !filters.dateFrom || donationDate >= toStartOfDayIso(filters.dateFrom); + const matchesDateTo = + !filters.dateTo || donationDate <= toEndOfDayIso(filters.dateTo); + + const matchesAmountMin = + !filters.amountMin || donation.amount >= Number(filters.amountMin); + const matchesAmountMax = + !filters.amountMax || donation.amount <= Number(filters.amountMax); + + return ( + matchesSearch && + matchesRecurrence && + matchesStatus && + matchesDateFrom && + matchesDateTo && + matchesAmountMin && + matchesAmountMax + ); + }); + + return [...nextRows].sort((left, right) => { + switch (sortKey) { + case 'oldest': + return ( + new Date(left.createdAt).getTime() - + new Date(right.createdAt).getTime() + ); + case 'highest': + return right.amount - left.amount; + case 'lowest': + return left.amount - right.amount; + case 'newest': + default: + return ( + new Date(right.createdAt).getTime() - + new Date(left.createdAt).getTime() + ); + } + }); + }, [ + allRows, + filters.amountMax, + filters.amountMin, + filters.dateFrom, + filters.dateTo, + filters.recurrences, + filters.statuses, + searchValue, + sortKey, + ]); + + const totalPages = Math.max( + 1, + Math.ceil(filteredRows.length / ROWS_PER_PAGE), + ); + + useEffect(() => { + if (page > totalPages) { + setPage(totalPages); + } + }, [page, totalPages]); + + const visibleRows = useMemo(() => { + const startIndex = (page - 1) * ROWS_PER_PAGE; + return filteredRows.slice(startIndex, startIndex + ROWS_PER_PAGE); + }, [filteredRows, page]); + + const paginationItems = useMemo( + () => buildPageWindow(page, totalPages), + [page, totalPages], + ); + + const resetFilters = () => { + setFilters(createInitialFilters()); + setPage(1); + }; + + const applyFilters = (nextFilters: FilterState) => { + setFilters(nextFilters); + setPage(1); + }; + + const refreshDonations = async () => { + await loadDonations(); + setPage(1); + }; + + const handleCreateDonation = async () => { + if (isCreating) { + return; + } + + const amount = Number(createDonationForm.amount); + if ( + !createDonationForm.firstName.trim() || + !createDonationForm.lastName.trim() || + !createDonationForm.email.trim() || + !Number.isFinite(amount) || + amount <= 0 + ) { + setCreateError('Fill out the required fields with a valid amount.'); + return; + } + + setIsCreating(true); + setCreateError(null); + + try { + await apiClient.createDonation({ + firstName: createDonationForm.firstName.trim(), + lastName: createDonationForm.lastName.trim(), + email: createDonationForm.email.trim(), + amount, + isAnonymous: createDonationForm.isAnonymous, + donationType: createDonationForm.donationType, + dedicationMessage: createDonationForm.reason.trim(), + showDedicationPublicly: createDonationForm.showDedicationPublicly, + ...(createDonationForm.donationType === 'recurring' + ? { recurringInterval: createDonationForm.recurringInterval } + : {}), + }); + + setCreateDonationForm(createInitialDonationForm()); + setShowCreateDonation(false); + await refreshDonations(); + } catch (creationError) { + setCreateError( + creationError instanceof Error + ? creationError.message + : 'Failed to create donation', + ); + } finally { + setIsCreating(false); + } + }; + + const handleExport = async () => { + const csv = await apiClient.exportDonationsCsv(); + const blobUrl = window.URL.createObjectURL(csv); + const link = document.createElement('a'); + link.href = blobUrl; + link.download = 'donations.csv'; + link.click(); + window.URL.revokeObjectURL(blobUrl); + }; + + const startItem = visibleRows.length > 0 ? (page - 1) * ROWS_PER_PAGE + 1 : 0; + const endItem = + visibleRows.length > 0 + ? Math.min(page * ROWS_PER_PAGE, filteredRows.length) + : 0; + + return ( +
+
+ setShowCreateDonation(true)} + searchValue={searchValue} + onSearchChange={(value) => { + setSearchValue(value); + setPage(1); + }} + sortKey={sortKey} + onSortChange={(value) => { + setSortKey(value); + setPage(1); + }} + filters={filters} + onApplyFilters={applyFilters} + onResetFilters={resetFilters} + /> + + {error ? ( +
+ {error} +
+ ) : null} + + {loading ? ( + + + Loading donations... + + + ) : visibleRows.length === 0 ? ( + + + No donations found for this page. + + + ) : ( + + )} + +
+

+ Showing {startItem} - {endItem} of {filteredRows.length} donations +

+ + +
+
+ + {showCreateDonation ? ( +
+
+
+

+ New Donation +

+ +
+ + {createError ? ( +
+ {createError} +
+ ) : null} + +
+ + + + + + + + + +
+ +
+ + +
+
+
+ ) : null} +
+ ); +} diff --git a/apps/frontend/src/containers/dashboard/sidebar/DashboardOverview.tsx b/apps/frontend/src/containers/dashboard/sidebar/DashboardOverview.tsx index 0edb029..c727161 100644 --- a/apps/frontend/src/containers/dashboard/sidebar/DashboardOverview.tsx +++ b/apps/frontend/src/containers/dashboard/sidebar/DashboardOverview.tsx @@ -4,6 +4,7 @@ import { Outlet, useLocation } from 'react-router-dom'; const ROUTE_TITLES: Record = { '/overview': 'Dashboard Overview', + '/overview/donations': 'Donation Tracker', '/overview/email': 'Email Overview', }; diff --git a/scripts/docker-dev.bat b/scripts/docker-dev.bat deleted file mode 100644 index 719401f..0000000 --- a/scripts/docker-dev.bat +++ /dev/null @@ -1,140 +0,0 @@ -@echo off -REM Docker Development Helper for Windows -REM This script provides easy commands for Docker development - -setlocal enabledelayedexpansion - -REM Define colors (Windows) -set "GREEN=[92m" -set "YELLOW=[93m" -set "RED=[91m" -set "NC=[0m" - -REM Helper functions -:print_status -echo %GREEN%[INFO]%NC% %~1 -goto :eof - -:print_warning -echo %YELLOW%[WARNING]%NC% %~1 -goto :eof - -:print_error -echo %RED%[ERROR]%NC% %~1 -goto :eof - -REM Check if Docker is running -:check_docker -docker info >nul 2>&1 -if %errorlevel% neq 0 ( - call :print_error "Docker is not running. Please start Docker Desktop." - echo. - echo To install Docker Desktop: - echo 1. Download from https://www.docker.com/products/docker-desktop - echo 2. Install and restart your computer - echo 3. Start Docker Desktop - echo 4. Wait for Docker to be ready (check system tray icon) - echo. - pause - exit /b 1 -) -goto :eof - -REM Setup environment file -:setup_env -if not exist .env.docker ( - call :print_status "Creating .env.docker from template..." - copy .env.docker.example .env.docker >nul - call :print_warning "Please review and update .env.docker with your configuration" -) -goto :eof - -REM Build images -:build -call :print_status "Building Docker images..." -docker-compose -f docker-compose.dev.yml build --no-cache -goto :eof - -REM Start services -:up -call :print_status "Starting development services..." -call :setup_env -docker-compose -f docker-compose.dev.yml up -d -call :print_status "Services started! Backend: http://localhost:3000/api" -call :print_status "Database admin: http://localhost:8080 (user: postgres, pass: postgres, server: postgres)" -goto :eof - -REM Stop services -:down -call :print_status "Stopping development services..." -docker-compose -f docker-compose.dev.yml down -goto :eof - -REM Restart services -:restart -call :print_status "Restarting development services..." -call :down -call :up -goto :eof - -REM View logs -:logs -set service=%~1 -if "%service%"=="" set service=backend -call :print_status "Showing logs for %service%..." -docker-compose -f docker-compose.dev.yml logs -f %service% -goto :eof - -REM Run migrations -:migrate -call :print_status "Running database migrations..." -docker-compose -f docker-compose.dev.yml exec backend sh -c "cd /app && npm run migration:run" -goto :eof - -REM Show status -:status -call :print_status "Docker services status:" -docker-compose -f docker-compose.dev.yml ps -goto :eof - -REM Main script logic -if "%1"=="build" ( - call :check_docker - call :build -) else if "%1"=="up" ( - call :check_docker - call :up -) else if "%1"=="down" ( - call :down -) else if "%1"=="restart" ( - call :check_docker - call :restart -) else if "%1"=="logs" ( - call :logs %2 -) else if "%1"=="migrate" ( - call :migrate -) else if "%1"=="status" ( - call :status -) else ( - echo Docker Development Helper for Windows - echo. - echo Usage: %0 {command} - echo. - echo Commands: - echo build Build Docker images - echo up Start development services - echo down Stop development services - echo restart Restart development services - echo logs [service] Show logs (default: backend) - echo migrate Run database migrations - echo status Show services status - echo. - echo Examples: - echo %0 up Start all services - echo %0 logs backend Show backend logs - echo %0 migrate Run migrations - echo. - echo Prerequisites: - echo - Docker Desktop installed and running - echo - Download from: https://www.docker.com/products/docker-desktop -) diff --git a/scripts/docker-dev.sh b/scripts/docker-dev.sh deleted file mode 100644 index 69e9783..0000000 --- a/scripts/docker-dev.sh +++ /dev/null @@ -1,187 +0,0 @@ -#!/bin/bash - -# Docker Development Helper Script -# This script provides easy commands for Docker development - -set -e - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -# Helper function to print colored output -print_status() { - echo -e "${GREEN}[INFO]${NC} $1" -} - -print_warning() { - echo -e "${YELLOW}[WARNING]${NC} $1" -} - -print_error() { - echo -e "${RED}[ERROR]${NC} $1" -} - -# Function to check if Docker is running -check_docker() { - if ! docker info > /dev/null 2>&1; then - print_error "Docker is not running. Please start Docker Desktop." - exit 1 - fi -} - -# Function to setup environment file -setup_env() { - if [ ! -f .env.docker ]; then - print_status "Creating .env.docker from template..." - cp .env.docker.example .env.docker - print_warning "Please review and update .env.docker with your configuration" - fi -} - -# Function to build images -build() { - print_status "Building Docker images..." - docker-compose -f docker-compose.dev.yml build --no-cache -} - -# Function to start services -up() { - print_status "Starting development services..." - setup_env - docker-compose -f docker-compose.dev.yml up -d - print_status "Services started! Backend: http://localhost:3000/api" - print_status "Database admin: http://localhost:8080 (user: postgres, pass: postgres, server: postgres)" -} - -# Function to stop services -down() { - print_status "Stopping development services..." - docker-compose -f docker-compose.dev.yml down -} - -# Function to restart services -restart() { - print_status "Restarting development services..." - down - up -} - -# Function to view logs -logs() { - service=${1:-backend} - print_status "Showing logs for $service..." - docker-compose -f docker-compose.dev.yml logs -f "$service" -} - -# Function to run migrations -migrate() { - print_status "Running database migrations..." - docker-compose -f docker-compose.dev.yml exec backend sh -c "cd /app && npm run migration:run" -} - -# Function to generate migration -migrate_generate() { - if [ -z "$1" ]; then - print_error "Please provide a migration name: ./docker-dev.sh migrate:generate add_users" - exit 1 - fi - print_status "Generating migration: $1" - docker-compose -f docker-compose.dev.yml exec backend sh -c "cd /app && npm run migration:generate --name=$1" -} - -# Function to run backend shell -shell() { - print_status "Opening shell in backend container..." - docker-compose -f docker-compose.dev.yml exec backend sh -} - -# Function to run tests -test() { - service=${1:-backend} - print_status "Running tests for $service..." - docker-compose -f docker-compose.dev.yml exec "$service" sh -c "cd /app && npm run test:$service" -} - -# Function to clean up everything -clean() { - print_warning "This will remove all containers, volumes, and images. Are you sure? (y/N)" - read -r response - if [[ "$response" =~ ^([yY][eE][sS]|[yY])$ ]]; then - print_status "Cleaning up Docker resources..." - docker-compose -f docker-compose.dev.yml down -v --remove-orphans - docker system prune -f - print_status "Cleanup complete!" - fi -} - -# Function to show status -status() { - print_status "Docker services status:" - docker-compose -f docker-compose.dev.yml ps -} - -# Main script logic -case "$1" in - "build") - check_docker - build - ;; - "up") - check_docker - up - ;; - "down") - down - ;; - "restart") - check_docker - restart - ;; - "logs") - logs "$2" - ;; - "migrate") - migrate - ;; - "migrate:generate") - migrate_generate "$2" - ;; - "shell") - shell - ;; - "test") - test "$2" - ;; - "clean") - clean - ;; - "status") - status - ;; - *) - echo "Docker Development Helper" - echo "" - echo "Usage: $0 {command}" - echo "" - echo "Commands:" - echo " build Build Docker images" - echo " up Start development services" - echo " down Stop development services" - echo " restart Restart development services" - echo " logs [service] Show logs (default: backend)" - echo " migrate Run database migrations" - echo " migrate:generate Generate new migration" - echo " shell Open shell in backend container" - echo " test [service] Run tests (default: backend)" - echo " status Show services status" - echo " clean Clean up all Docker resources" - echo "" - echo "Examples:" - echo " $0 up # Start all services" - echo " $0 logs backend # Show backend logs" - echo " $0 migrate:generate add_users # Generate migration" - ;; -esac diff --git a/scripts/init-db.sql b/scripts/init-db.sql deleted file mode 100644 index dfdbe21..0000000 --- a/scripts/init-db.sql +++ /dev/null @@ -1,43 +0,0 @@ --- Database initialization script --- This script runs when the PostgreSQL container starts for the first time - --- Create user first if not exists -DO $$ -BEGIN - IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'fcc_user') THEN - CREATE USER fcc_user WITH PASSWORD 'fcc_password'; - END IF; -END -$$; - --- Create the main database if it doesn't exist -DO $$ -BEGIN - IF NOT EXISTS (SELECT FROM pg_database WHERE datname = 'fcc_dev') THEN - CREATE DATABASE fcc_dev OWNER fcc_user; - END IF; -END -$$; - --- Create a test database for running tests -DO $$ -BEGIN - IF NOT EXISTS (SELECT FROM pg_database WHERE datname = 'fcc_test') THEN - CREATE DATABASE fcc_test OWNER fcc_user; - END IF; -END -$$; - --- Grant permissions -GRANT ALL PRIVILEGES ON DATABASE fcc_dev TO fcc_user; -GRANT ALL PRIVILEGES ON DATABASE fcc_test TO fcc_user; - --- Enable necessary PostgreSQL extensions -\c fcc_dev; -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - -\c fcc_test; -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - --- Log completion -\echo 'Database initialization completed!' From edefe08d730a5721e0ca503c28cba2dfe663d9e7 Mon Sep 17 00:00:00 2001 From: Ben Petrillo Date: Fri, 17 Apr 2026 23:34:50 -0400 Subject: [PATCH 2/4] fix: lint again --- apps/frontend/src/containers/dashboard/UserManagement.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/frontend/src/containers/dashboard/UserManagement.tsx b/apps/frontend/src/containers/dashboard/UserManagement.tsx index baa956e..61c194f 100644 --- a/apps/frontend/src/containers/dashboard/UserManagement.tsx +++ b/apps/frontend/src/containers/dashboard/UserManagement.tsx @@ -72,6 +72,7 @@ export const UserManagement: React.FC = () => { const [denyingUser, setDenyingUser] = useState(null); const [verifyingUser, setVerifyingUser] = useState(null); const [modalPosition, setModalPosition] = useState< + // eslint-disable-next-line no-restricted-globals { top: number; right: number } | undefined >(undefined); const [isUpdatingRole, setIsUpdatingRole] = useState(false); From 1baa78c6349e05f71bfe07cdebdfed35237af31c Mon Sep 17 00:00:00 2001 From: Thanin Kongkiatsophon <108406347+thaninbew@users.noreply.github.com> Date: Sat, 18 Apr 2026 01:33:36 -0400 Subject: [PATCH 3/4] fix: yearly->annuallu --- .../containers/dashboard/donations/DonationTrackerPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/frontend/src/containers/dashboard/donations/DonationTrackerPage.tsx b/apps/frontend/src/containers/dashboard/donations/DonationTrackerPage.tsx index 4086001..dd330ef 100644 --- a/apps/frontend/src/containers/dashboard/donations/DonationTrackerPage.tsx +++ b/apps/frontend/src/containers/dashboard/donations/DonationTrackerPage.tsx @@ -48,7 +48,7 @@ type CreateDonationState = { amount: string; reason: string; donationType: 'one_time' | 'recurring'; - recurringInterval: 'weekly' | 'monthly' | 'yearly'; + recurringInterval: 'weekly' | 'monthly' | 'annually'; isAnonymous: boolean; showDedicationPublicly: boolean; }; @@ -1037,7 +1037,7 @@ export default function DonationTrackerPage() { > - +