Arkitektur¶
🏗️ System Architecture¶
┌─────────────────────────────────────────────────────┐
│ React Frontend (Vite) │
│ ┌───────────────────────────────────────────────┐ │
│ │ Admin UI │ Delegate UI │ Components │ │
│ │ (AdminApp) │ (ClientApp) │ (shared) │ │
│ └───────────────────────────────────────────────┘ │
└──────────────────────┬──────────────────────────────┘
│
Firebase SDK
│
┌──────────────┼──────────────┐
│ │ │
┌───▼──┐ ┌─────▼──┐ ┌──────▼──┐
│Auth │ │Firestore│ │Storage │
│ │ │Database │ │(Files) │
└──────┘ └─────────┘ └─────────┘
│ │ │
┌───▼──────────────▼──────────────▼───┐
│ Firebase Backend (europe-north1) │
│ - Real-time Firestore │
│ - Cloud Functions │
│ - Security Rules │
└─────────────────────────────────────┘
📦 Tech Stack¶
Frontend¶
- React 19 - UI components
- TypeScript 5.7 - Type safety
- Vite 6.1 - Build tool & dev server
- Tailwind CSS 3.4 - Styling
- Lucide React - Icons
Backend¶
- Firebase Firestore - Real-time document database
- Firebase Auth - User authentication
- Cloud Functions - Serverless compute (planned)
- Cloud Storage - File storage
DevOps¶
- GitHub - Version control
- Firebase Hosting - Production deployment
- GitHub Actions - CI/CD (planned)
🗄️ Firestore Data Model¶
Collections¶
members¶
{
id: string (Golf-ID)
name: string
email: string
phone: string
role: string
verification: string (post code for login)
canVote: boolean
joinDate: timestamp
}
agenda¶
{
id: string
number: number (§1, §2, etc)
title: string
description: string
status: 'PENDING' | 'ACTIVE' | 'VOTING_OPEN' | 'VOTING_CLOSED' | 'FINISHED'
isVotable: boolean
requiresSecretBallot: boolean
decision?: string
proposals?: Proposal[]
voteResults?: { for, against, abstain, total }
}
speakers¶
{
id: string
agendaItemId: string
userId: string
userName: string
type: 'INNLEGG' | 'REPLIKK'
timestamp: timestamp
}
votes (Anonymous, never readable)¶
{
id: string
agendaItemId: string
voteOption: 'FOR' | 'MOT' | 'AVSTAR'
timestamp: timestamp
}
vote_receipts (Proof of participation)¶
{
id: string (userId)
agendaItemId: string
hasVoted: boolean
timestamp: timestamp
}
🔐 Security Model¶
Firestore Rules¶
- Members: Read-only own data (no client writes)
- Agenda: Authenticated read, admin-only write
- Speakers: Authenticated create, real-time read
- Votes: Anonymous creation, never readable
- Default: Deny-all for unspecified paths
Authentication¶
- Golf ID (6 digits) + PIN (4 digits) verification
- No OAuth - direct Firestore lookup
🔄 Data Flow¶
Login Flow (Delegate)¶
1. User enters Golf ID + PIN in ClientApp
2. Query Firestore: members/{golfId}
3. Verify PIN matches member.verification
4. Check member.canVote permission
5. Set isCheckedIn = true
6. Subscribe to real-time agenda
Voting Flow (Planned)¶
1. Admin opens voting (toggleVoting in ConductorView)
2. Update agenda.status = 'VOTING_OPEN'
3. Delegate casts vote → Cloud Function
4. Function creates anonymous votes/{voteId}
5. Function creates vote_receipts/{userId}
6. Real-time aggregation shows vote count
7. Admin closes voting → status = 'VOTING_CLOSED'
📁 Component Hierarchy¶
App.tsx
├── AdminApp.tsx (role-based)
│ ├── ConductorView (§-behandling)
│ └── SecretaryView (protokoll)
├── ClientApp.tsx (mobile-first)
│ ├── DashboardView
│ ├── MemberListView
│ ├── UploadView (secretary)
│ └── AIView (Gemini insights)
└── Shared Components
├── Sidebar
├── DashboardView
└── MemberListView
🎯 Real-time Subscriptions¶
All components use custom hooks for data binding:
// Collection listener
const { data: agenda, loading, error } = useCollection('agenda');
// Document listener
const { data: activeItem, exists } = useDocument('agenda', itemId);
Auto-unsubscribe on unmount, handles loading/error states.