A full-stack web application for managing PNAA's 55+ chapters, 4,000+ members, events, and fundraising campaigns. Built with Next.js and Firebase, integrated with Wild Apricot for membership data.
- Features
- Tech Stack
- Architecture
- Authentication Flow
- Project Structure
- Environment Variables
- Staging Environment
- Getting Started
- Firebase Cloud Functions
- Roles & Permissions
- Data Models
- Dashboard — Real-time stats (total/active/lapsed members, chapters, upcoming events, total fundraised)
- Chapter Management — Browse all chapters, view chapter-level member breakdowns; chapter aliases merge stats from alternative Wild Apricot names
- Event Management — Create, edit, and view events with metrics (attendees, volunteers, contact hours, etc.); synced from Wild Apricot via scheduled functions and real-time webhooks
- Fundraising — Track fundraising campaigns with amounts, notes, and chapter attribution
- Subchapters — Create subchapters within chapters, assign members, soft-delete support
- Member Sync — Automated Wild Apricot membership sync via scheduled Cloud Functions (daily) and real-time webhooks
- First-Time Onboarding — New users select their region and chapter on first sign-in; this can only be changed later by a national admin via the user management page
- Role-Based Access — National admins see all data; region admins manage their region; chapter admins manage their chapter; members have read-only access
- User Management — National admins can view all users and update roles, regions, and chapter assignments
- Advanced Data Tables — Chapters, Events, and Fundraising pages feature a rich table view with sortable, resizable, and drag-to-reorder columns; per-column filters; column visibility toggles; and pagination. Switchable to a card grid via a pill toggle.
- Responsive UI — Mobile-friendly with sidebar navigation and dark mode support
| Technology | Version | Purpose |
|---|---|---|
| Next.js | 16.1.6 | React framework (App Router) |
| React | 19.2.3 | UI library |
| TypeScript | 5 | Type safety |
| Tailwind CSS | 4 | Utility-first styling |
| shadcn/ui | — | Radix UI component library |
| TanStack Table | 8 | Headless table logic (sort, filter, resize, pagination, column order) |
| dnd-kit | — | Drag-and-drop for column reordering |
| React Hook Form | 7.71.2 | Form state management |
| Zod | 4.3.6 | Schema validation |
| date-fns | 4.1.0 | Date utilities |
| Lucide React | — | Icons |
| Sonner | — | Toast notifications |
| next-themes | — | Dark mode |
| Technology | Purpose |
|---|---|
| Firebase Auth | Authentication via custom tokens + session cookies |
| Firestore | Primary NoSQL database |
| Firebase Storage | Event poster image uploads (5MB max, images only) |
| Firebase Cloud Functions | Scheduled data sync + real-time webhooks from Wild Apricot |
| Wild Apricot | Membership management platform (OAuth 2.0 integration) |
┌─────────────────────────────────────────────────────────────┐
│ Next.js App │
│ ┌──────────┐ ┌──────────┐ ┌────────────┐ ┌──────────┐ │
│ │ Dashboard │ │ Events │ │ Fundraising│ │ Chapters │ │
│ └──────────┘ └──────────┘ └────────────┘ └──────────┘ │
│ │ Real-time listeners │
└─────────────────────┼───────────────────────────────────────┘
│
┌─────────────┴─────────────┐
│ Firestore │
│ members / events / │
│ fundraising / chapters / │
│ subchapters / users / │
│ chapter_aliases │
└──────────┬────────────────┘
│
┌─────────────┴──────────────────┐
│ Firebase Cloud Functions │
│ • syncMembers (HTTP, manual) │
│ • syncEvents (HTTP, manual) │
│ • updateMembers (daily 2 AM) │
│ • wildApricotWebhook (HTTP) │
│ • createUser (callable) │
└──────────┬─────────────────────┘
│ Wild Apricot REST API
┌──────────┴──────────────────┐
│ Wild Apricot │
│ (Membership management) │
│ Webhooks → Cloud Functions │
└─────────────────────────────┘
Authentication uses Wild Apricot OAuth 2.0 to identify users, then issues Firebase session cookies for secure server-side verification.
1. User visits /signin
2. GET /api/auth/signin
→ Generates CSRF state, sets httpOnly state cookie
→ Redirects to Wild Apricot OAuth login URL
3. Wild Apricot redirects to GET /api/auth/callback?code=...&state=...
→ Validates state cookie (CSRF protection)
→ Exchanges authorization code for Wild Apricot access token
→ Fetches user contact info from Wild Apricot API
→ Creates or finds Firebase Auth user by email
→ Creates Firestore /users/{uid} doc (new users get needsOnboarding: true)
→ Returning users: only lastLogin is updated (chapter/region preserved)
→ Issues Firebase custom token with role claims
→ Redirects to /callback?token=<customToken>
4. Client-side /callback page:
→ Signs into Firebase with custom token (signInWithCustomToken)
→ Obtains ID token from the signed-in user (getIdToken)
→ POSTs ID token to /api/auth/session
→ Server creates a verified session cookie (createSessionCookie)
→ Sets httpOnly cookie "firebase_token" (1 hour)
→ Redirects to /setup (new users) or /dashboard (returning users)
5. /setup page (first-time only):
→ User selects their region, then their chapter
→ Saves via POST /api/auth/setup → sets needsOnboarding: false
→ Redirects to /dashboard
6. Protected routes:
→ Middleware checks firebase_token cookie existence
→ API routes verify the cookie via verifySessionCookie (cryptographic check)
→ OnboardingGuard in app layout redirects to /setup if needsOnboarding
Key security properties:
- Session cookies are created by
createSessionCookieand verified byverifySessionCookie— cryptographic verification on every API call - The
firebase_tokencookie is httpOnly, secure (in production), SameSite=Lax - CSRF protection on the OAuth flow via a state cookie
- User chapter/region can only be changed by national admins via the user management page (after initial onboarding)
philippine-nurses-association-of-america/
├── firebase.json # Firebase project config
├── firestore.rules # Firestore security rules
├── firestore.indexes.json # Composite indexes
├── storage.rules # Firebase Storage rules
├── functions/ # Firebase Cloud Functions
│ └── src/
│ ├── index.ts # Function exports + Admin SDK init
│ ├── sync-members.ts # HTTP endpoint for manual full member sync
│ ├── sync-events.ts # HTTP endpoint for manual full event sync
│ ├── update-members.ts # Scheduled daily status recalculation (2 AM ET)
│ ├── webhook-handler.ts # Real-time Wild Apricot webhook receiver
│ ├── create-user.ts # Callable: admin user creation
│ ├── wa-utils.ts # Shared WA utilities (token, mapping, aggregation)
│ └── run-sync.ts # Local dev script for manual syncs
└── pnaa/ # Next.js application
├── middleware.ts # Route protection (cookie existence check)
├── app/
│ ├── layout.tsx # Root layout (AuthProvider)
│ ├── page.tsx # Root redirect (→ /dashboard or /signin)
│ ├── api/
│ │ ├── auth/
│ │ │ ├── signin/ # Start OAuth flow
│ │ │ ├── callback/ # Handle OAuth callback
│ │ │ ├── session/ # Create verified session cookie from ID token
│ │ │ ├── setup/ # Save first-time chapter/region selection
│ │ │ └── signout/ # Clear session cookie
│ │ ├── sync/trigger/ # Manual sync trigger (national_admin only)
│ │ └── users/[userId]/ # Update user role/chapter/region (national_admin only)
│ ├── (auth)/
│ │ ├── signin/ # Sign-in page
│ │ ├── callback/ # OAuth return → Firebase sign-in → session cookie
│ │ └── setup/ # First-time onboarding: pick region & chapter
│ └── (app)/ # Protected app routes (wrapped in OnboardingGuard)
│ ├── layout.tsx # App chrome (sidebar, header) + OnboardingGuard
│ ├── dashboard/
│ ├── chapters/[chapterId]/
│ │ ├── aliases/
│ │ └── subchapters/[subchapterId]/
│ ├── events/[eventId]/edit/
│ ├── fundraising/[fundraisingId]/edit/
│ ├── users/ # User management (national_admin only)
│ └── about/
├── components/
│ ├── ui/ # shadcn/ui primitives
│ ├── auth/ # OnboardingGuard
│ ├── layout/ # Header, Sidebar, MobileNav
│ ├── dashboard/ # Stats cards, widgets
│ ├── events/ # Event list, card, form, detail
│ ├── chapters/ # Chapter list, card, detail
│ ├── fundraising/ # Campaign list, card, form, detail
│ ├── users/ # User list with edit dialog
│ └── shared/ # PageHeader, SearchInput, AdvancedDataTable, ViewToggle
├── hooks/
│ ├── use-auth.ts # Auth helpers (role checks, chapter/region getters)
│ ├── use-firestore.ts # useDocument / useCollection (real-time listeners)
│ ├── use-debounce.ts
│ ├── use-mobile.ts
│ └── use-sidebar.ts
├── lib/
│ ├── auth/
│ │ ├── context.tsx # AuthProvider & useAuthContext
│ │ └── guards.tsx # RequireAuth / RequireRole components
│ ├── firebase/
│ │ ├── config.ts # Client SDK init
│ │ ├── admin.ts # Admin SDK (server-side, lazy Proxy pattern)
│ │ ├── firestore.ts # Firestore helpers
│ │ ├── storage.ts # Storage helpers
│ │ └── index.ts
│ └── wild-apricot/
│ └── oauth.ts # OAuth + API utilities
└── types/
├── user.ts
├── member.ts
├── chapter.ts
├── chapter-alias.ts
├── subchapter.ts
├── event.ts
└── fundraising.ts
Create a .env.local file inside pnaa/ for production:
A separate Firebase project is used for staging so you can test without touching production data. For more details, see Staging Environment
# Firebase (client-side — public)
NEXT_PUBLIC_FIREBASE_API_KEY=
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=
NEXT_PUBLIC_FIREBASE_PROJECT_ID=
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=
NEXT_PUBLIC_FIREBASE_APP_ID=
NEXT_PUBLIC_APP_URL=http://localhost:3000
# Firebase Admin (server-side — private)
FIREBASE_ADMIN_PROJECT_ID=
FIREBASE_ADMIN_CLIENT_EMAIL=
FIREBASE_ADMIN_PRIVATE_KEY=
# Wild Apricot OAuth
WILD_APRICOT_CLIENT_ID=
WILD_APRICOT_CLIENT_SECRET=
WILD_APRICOT_ACCOUNT_ID=
WILD_APRICOT_DOMAIN=Firebase Admin credentials are required for the OAuth callback route (issues custom tokens) and the session route (creates/verifies session cookies). Obtain them from a Firebase service account JSON file.
Cloud Functions use separate environment variables configured in functions/.env:
WILD_APRICOT_API_KEY=
WILD_APRICOT_ACCOUNT_ID=
WEBHOOK_SECRET= # For webhook endpoint authenticationA separate Firebase project is used for staging so you can test without touching production data.
- Production Firebase project ID:
pnaa-chapter-management - Staging Firebase project ID:
pnaa-chaptermanagement-staging
The repo uses Firebase CLI aliases defined in .firebaserc:
{
"projects": {
"default": "pnaa-chapter-management",
"staging": "pnaa-chaptermanagement-staging"
}
}From the repo root:
# Use production for deploys
firebase use default
# Use staging for deploys
firebase use stagingOn the free plan, only Firestore is deployed to staging as of now:
firebase deploy --only firestoreworks for both prod and staging.firebase deploy --only functionsand--only storagerequire the Blaze plan on the target project, so Functions and Storage are not deployed to staging as of now.
Inside pnaa/:
.env.local→ points to production Firebase..env.staging.local→ points to staging Firebase (pnaa-chaptermanagement-staging).
Typical workflows:
-
Run app against production (local):
-
Ensure
.env.localhas production values. -
Run:
cd pnaa npm run dev
-
-
Run app against staging (local):
-
Ensure
.env.staging.localexists with staging values. -
Either temporarily copy it over:
cd pnaa cp .env.staging.local .env.local npm run dev -
Or, if using
env-cmd, run the dedicated staging script:cd pnaa npm run dev:staging
-
When the app is using staging, all Firestore/Auth operations go to the staging Firebase project; production continues to use its own env and project.
- Node.js 20+
- Firebase CLI (
npm install -g firebase-tools) - A Firebase project with Firestore, Auth, Storage, and Functions enabled
- A Wild Apricot account with API/OAuth credentials
# Install Next.js app dependencies
cd pnaa
npm install
# Run the development server
npm run devThe app will be available at http://localhost:3000.
# Install Cloud Functions dependencies
cd functions
npm install
# Build functions
npm run build
# Run functions locally with Firebase emulator
npm run serveFrom the functions/ directory:
npm run sync # sync members + events
npm run sync:members # members only
npm run sync:events # events only
npm run sync:members -- --from 5000 # start at contact #5000
npm run sync:members -- --limit 1000 # first 1000 contacts only# Deploy Firestore rules and indexes
firebase deploy --only firestore
# Deploy Storage rules
firebase deploy --only storage
# Deploy Cloud Functions
firebase deploy --only functions| Function | Trigger | Description |
|---|---|---|
syncMembers |
HTTP (POST, manual) | Full member sync: fetches all contacts from Wild Apricot via async job polling, upserts to members collection in batches of 450, rebuilds chapter aggregates. Secured with ?key=[WEBHOOK_SECRET]. Timeout: 540s. |
syncEvents |
HTTP (POST, manual) | Full event sync: fetches all events from Wild Apricot, insert-only — existing events are never overwritten. Secured with ?key=[WEBHOOK_SECRET]. Timeout: 300s. |
updateMembers |
Scheduled (daily, 2 AM ET) | Queries only Active members whose renewalDueDate has passed and flips them to Lapsed. Updates chapter aggregates via FieldValue.increment — no full member re-read required. |
wildApricotWebhook |
HTTP (POST) | Real-time webhook receiver for Wild Apricot contact, membership, and event changes. Contact/membership changes upsert the member and update chapter aggregates via increments (old/new delta). Event changes: Created = insert-only, Changed = updates WA-owned fields only (preserves app fields), Deleted = soft-delete (archived: true). Always returns 200 to prevent WA retry loops. |
createUser |
Callable | Creates a Firebase Auth user and Firestore user document with role/chapter/region. Restricted to national_admin callers. |
Configure in Wild Apricot (Apps > Integrations > Webhooks):
| Setting | Value |
|---|---|
| URL | https://[region]-[project].cloudfunctions.net/wildApricotWebhook?key=[WEBHOOK_SECRET] |
| Authorization | Secret token (query param) |
| Token name | key |
| Token value | Value of WEBHOOK_SECRET from functions/.env |
| Notification types | Contact, Membership, Event, MembershipRenewed |
- Real-time: The webhook handler processes individual contact/event changes as they happen in Wild Apricot. Chapter aggregates are updated via
FieldValue.incrementusing the old/new member delta — no member re-reads required. - Manual full sync:
syncMembersandsyncEventsare HTTP endpoints (no schedule). Trigger them viaPOST /syncMembers?key=[WEBHOOK_SECRET]andPOST /syncEvents?key=[WEBHOOK_SECRET]when a full re-sync is needed (e.g. after a gap in webhook coverage or initial setup). - Daily status update:
updateMembersruns at 2 AM ET, querying onlyActivemembers with an expiredrenewalDueDate. On a typical day this touches tens to a few hundred documents rather than the full membership, and updates chapter counts via increments. - Chapter aggregates: The webhook and
updateMembersuse incremental updates.syncMembersrebuilds aggregates from scratch from the in-memory contact list during a full sync.recalculateChapterAggregatesinwa-utils.tsis available as a manual recovery utility if counts drift.
| Role | Access |
|---|---|
national_admin |
Full read/write access to all chapters, events, fundraising, members, users, and chapter aliases |
region_admin |
Read access to all data; can create/edit events and fundraising for chapters in their region; can manage chapter aliases |
chapter_admin |
Read access to all data; can create/edit events and fundraising for their chapter |
member |
Read-only access to events, chapters, fundraising, and their own user profile |
Roles are stored as Firebase Auth custom claims and mirrored in Firestore /users/{uid}. Firestore security rules enforce permissions server-side via getUserRole() which reads from the Firestore user document.
Soft deletes are used for events, fundraising, and subchapters (no hard deletes allowed via security rules — records are archived with archived: true).
Members and chapters are read-only from the client — only Cloud Functions write to these collections.
{
name: string
email: string
membershipLevel: string
renewalDueDate: string
chapterName: string
highestEducation: string
memberId: string // From WA custom field, fallback: WA contact ID
region: string
activeStatus: "Active" | "Lapsed"
lastSynced: Timestamp
}{
name: string
startDate: string
endDate: string
startTime: string
endTime: string
location: string
chapter: string
region: string
about: string
archived: boolean
attendees: number
volunteers: number
participantsServed: number
contactHours: number
volunteerHours: number
eventPoster: { name: string; ref: string; downloadURL: string }
source: "wildapricot" | "app"
lastUpdatedUser: string
lastUpdated: Timestamp
creationDate: Timestamp
}{
fundraiserName: string
chapterName: string
subchapterId?: string
date: string
amount: number
note: string
archived: boolean
lastUpdatedUser: string
lastUpdated: Timestamp
creationDate: Timestamp
}{
name: string
region: string
totalMembers: number
totalActive: number
totalLapsed: number
lastUpdated: Timestamp
}{
name: string
chapterId: string
createdBy: string
memberIds: string[]
archived: boolean
lastUpdated: Timestamp
}{
aliasName: string // Alternative WA chapter name
canonicalChapterId: string // Maps to chapters/{chapterId}
}{
email: string
displayName: string
role: "national_admin" | "region_admin" | "chapter_admin" | "member"
chapterName?: string
region?: string
needsOnboarding?: boolean // true for new users until they complete /setup
waContactId?: string
createdAt: Timestamp
lastLogin: Timestamp
}