Skip to main content

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"); requires teamAccess.
  • (campaign-layout) — campaign-scoped sidebar (layout="campaign"); requires teamAccess + 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

WhereMechanism
Edgesrc/middleware.tsupdateSession (auth/session only)
Team layoutgetRoutePermissions({ teamId })redirect("/") if !teamAccess
Campaign layoutgetRoutePermissions({ teamId, campaignId })redirect("/${teamId}/campaign/no-access") if !campaignAccess (unless super-admin)
Page-levelrequireAccess({ teamId, campaignId, key }) for specific permission keys
SidebaruseTeams()permissionKeys filters route components in src/components/routes/*