App Directory
Location: src/app/
Purpose: Next.js App Router directory containing all routes, pages, layouts, and API endpoints.
Structure
app/
├── (base-layout)/ # Routes that don't need a team in the URL
│ ├── account/
│ │ └── page.tsx # /account
│ ├── no-access/
│ │ └── page.tsx # /no-access (terminal access-denied screen)
│ ├── layout.tsx # Sidebar with layout="root"
│ └── page.tsx # / (team chooser / welcome)
│
├── [primaryTeam]/ # Dynamic segment for the primary (top-level) team slug
│ ├── layout.tsx # Hydrates TeamsContext (teams, campaigns, permission snapshot)
│ │
│ ├── (team-layout)/ # Team-scoped routes
│ │ ├── layout.tsx # Calls getRoutePermissions; redirects to "/" when teamAccess is false
│ │ ├── page.tsx # /[primaryTeam] — campaigns list (Active / Inactive / All tabs)
│ │ ├── accept-campaign-invite/
│ │ │ └── page.tsx # /[primaryTeam]/accept-campaign-invite
│ │ ├── accept-invite/
│ │ │ └── page.tsx # /[primaryTeam]/accept-invite
│ │ ├── admin-voter-search/
│ │ │ └── page.tsx
│ │ ├── all-turn-ins/
│ │ │ ├── page.tsx
│ │ │ ├── add/page.tsx
│ │ │ └── [turnInRecord]/{page.tsx, edit/page.tsx}
│ │ ├── campaigns/page.tsx # Campaigns list (admin view)
│ │ ├── circulator/
│ │ │ ├── by-campaign/page.tsx # Circulator dashboard grouped by campaign
│ │ │ └── by-turn-in-date/page.tsx # Circulator dashboard grouped by turn-in date
│ │ ├── credentials/
│ │ │ ├── page.tsx
│ │ │ ├── new/page.tsx
│ │ │ └── [credID]/page.tsx
│ │ ├── members/
│ │ │ ├── layout.tsx
│ │ │ ├── page.tsx
│ │ │ └── [memberID]/page.tsx
│ │ ├── new-campaign/page.tsx
│ │ ├── permission-keys/
│ │ │ ├── page.tsx
│ │ │ └── new/page.tsx
│ │ ├── permission-set/ # Responsibilities (permission sets)
│ │ │ ├── page.tsx
│ │ │ ├── new/page.tsx
│ │ │ └── [permissionSetID]/page.tsx
│ │ ├── rates/
│ │ │ ├── page.tsx
│ │ │ ├── add/page.tsx
│ │ │ ├── already-exists/page.tsx
│ │ │ └── [rateID]/page.tsx
│ │ ├── roles/
│ │ │ ├── page.tsx
│ │ │ ├── new/page.tsx
│ │ │ └── [roleID]/page.tsx
│ │ ├── teams/ # Sub-team management (Super Admin / Team Admin)
│ │ │ ├── page.tsx
│ │ │ ├── add/page.tsx
│ │ │ └── [teamID]/{page.tsx, edit/page.tsx, add-admin/page.tsx, members/page.tsx}
│ │ ├── transactions/
│ │ │ ├── page.tsx
│ │ │ ├── add/page.tsx
│ │ │ └── [tid]/page.tsx
│ │ └── voter-search/page.tsx
│ │
│ └── (campaign-layout)/ # Campaign-scoped routes
│ └── campaign/
│ ├── layout.tsx # Hydrates TeamsContext + sidebar (layout="campaign")
│ ├── no-access/page.tsx # Terminal "no access for this campaign" screen
│ └── [slug]/ # Campaign slug
│ ├── layout.tsx # CampaignVisibilityProvider + getRoutePermissions
│ ├── page.tsx # Campaign overview / settings landing
│ │
│ ├── (data-views)/ # Read-only data views
│ │ ├── circulators-view/page.tsx
│ │ ├── households-view/page.tsx
│ │ └── validators-view/page.tsx
│ │
│ ├── add-signatures/
│ │ ├── page.tsx
│ │ └── [petitionId]/page.tsx
│ ├── admin-dashboard/page.tsx
│ ├── dashboard/
│ │ ├── page.tsx
│ │ ├── new/page.tsx
│ │ └── [dashID]/{page.tsx, edit/page.tsx}
│ ├── edit/page.tsx
│ ├── petition-format/
│ │ ├── page.tsx
│ │ ├── add/page.tsx
│ │ └── [id]/edit/page.tsx
│ ├── petitions/
│ │ ├── page.tsx
│ │ └── [petitionNo]/page.tsx
│ ├── rates/
│ │ ├── page.tsx
│ │ ├── add/page.tsx
│ │ └── [rateID]/page.tsx
│ ├── regions/
│ │ ├── page.tsx
│ │ ├── add/page.tsx
│ │ └── [region]/page.tsx
│ ├── signatures/page.tsx
│ ├── sub-teams/
│ │ ├── page.tsx
│ │ ├── add/page.tsx
│ │ └── [teamID]/{_page.tsx, edit/page.tsx}
│ ├── transactions/
│ │ ├── page.tsx
│ │ ├── add/page.tsx
│ │ └── [tid]/page.tsx
│ └── turn-in/
│ ├── page.tsx
│ ├── add/page.tsx
│ └── [turnInRecord]/{page.tsx, edit/page.tsx}
│
├── api/ # API route handlers
│ ├── charts/[id]/route.ts # POST: generate + persist chart data from a saved chart config
│ ├── duplicate/route.ts # POST: Redis duplicate-signer check per campaign
│ ├── essearch/route.ts # POST: Directus voter search with dynamic credentials
│ ├── essearch-common/route.ts # POST: Directus voter search with fixed credentials
│ └── permissions/revalidate/route.ts # POST: revalidateTag(`permissions-${userId}`) for current user
│
├── auth/ # Authentication routes
│ ├── callback/route.ts # OAuth / magic link exchange handler
│ ├── confirm/route.ts # Email confirmation handler
│ ├── invite-confirmtion/page.tsx
│ ├── login-confirmtion/page.tsx
│ ├── restricted/page.tsx
│ ├── sign-in/page.tsx
│ ├── sign-up/page.tsx
│ └── layout.tsx
│
├── fonts/ # Local font files (GeistVF, GeistMonoVF)
│ ├── GeistMonoVF.woff
│ └── GeistVF.woff
│
├── favicon.ico
├── globals.css # Tailwind base + CSS variables (incl. campaign primary/secondary colors)
└── layout.tsx # Root layout (metadata, fonts, Toaster)
Route Groups
(base-layout)— root sidebar (layout="root"), no team context.(team-layout)— team-scoped sidebar (layout="teams"); requiresteamAccess.(campaign-layout)— campaign-scoped sidebar (layout="campaign"); requiresteamAccess+campaignAccess(super-admin team bypasses the campaign check).(data-views)— three campaign read-only views (circulators, households, validators).
Dynamic Segments
[primaryTeam]— top-level team slug.[slug]— campaign slug.[memberID],[teamID],[credID],[roleID],[permissionSetID],[id],[petitionId],[petitionNo],[rateID],[turnInRecord],[tid],[region],[dashID]— resource identifiers.
Permission Gating Recap
| Where | Mechanism |
|---|---|
| Edge | src/middleware.ts → updateSession (auth/session only) |
| Team layout | getRoutePermissions({ teamId }) → redirect("/") if !teamAccess |
| Campaign layout | getRoutePermissions({ teamId, campaignId }) → redirect("/${teamId}/campaign/no-access") if !campaignAccess (unless super-admin) |
| Page-level | requireAccess({ teamId, campaignId, key }) for specific permission keys |
| Sidebar | useTeams() → permissionKeys filters route components in src/components/routes/* |