Issue #46: feat: Implement meeting voting system (Résolutions & votes)
- State:
CLOSED
- Milestone:
Jalon 3: Features Différenciantes 🎯
- Labels:
phase:vps,track:software priority:high
- Assignees:
Unassigned
- Created:
2025-10-27
- Updated:
2025-11-17
- URL:
Description
## Context
**Meeting management:** ✅ **80% implemented**
- Meeting entity (AGO/AGE types, statuses, agenda)
- 8 API endpoints (create, update, complete, cancel, etc.)
- Frontend meeting list and management
**Missing:** ❌ **Voting/Resolution system**
- Votes (Pour/Contre/Abstention)
- Résolutions with vote tracking
- Quorum calculation
- Majority rules (simple, absolute, qualified)
- Vote results and minutes generation
Belgian copropriété law requires tracking:
- Resolutions discussed
- Vote results per resolution
- Owner attendance and voting power (tantièmes/millièmes)
- Quorum validation
## Objective
Implement complete voting system for general assemblies (AG).
## Domain Model
### New Entities
**1. Resolution** (`backend/src/domain/entities/resolution.rs`)
```rust
pub struct Resolution {
pub id: Uuid,
pub meeting_id: Uuid,
pub title: String,
pub description: String,
pub resolution_type: ResolutionType, // Ordinary, Extraordinary
pub majority_required: MajorityType, // Simple, Absolute, Qualified(f64)
pub vote_count_pour: i32,
pub vote_count_contre: i32,
pub vote_count_abstention: i32,
pub total_voting_power_pour: f64, // Sum of tantièmes "pour"
pub total_voting_power_contre: f64,
pub total_voting_power_abstention: f64,
pub status: ResolutionStatus, // Pending, Adopted, Rejected
pub created_at: DateTime<Utc>,
pub voted_at: Option<DateTime<Utc>>,
}
pub enum ResolutionType {
Ordinary, // Majority of votes present
Extraordinary, // Qualified majority (e.g., 2/3, 3/4)
}
pub enum MajorityType {
Simple, // 50% + 1 of votes cast
Absolute, // 50% + 1 of all votes (including absent)
Qualified(f64), // Custom threshold (e.g., 0.67 for 2/3)
}
pub enum ResolutionStatus {
Pending, // Not yet voted
Adopted, // Vote passed
Rejected, // Vote failed
}
```
**2. Vote** (`backend/src/domain/entities/vote.rs`)
```rust
pub struct Vote {
pub id: Uuid,
pub resolution_id: Uuid,
pub owner_id: Uuid,
pub unit_id: Uuid,
pub vote_choice: VoteChoice,
pub voting_power: f64, // Tantièmes/millièmes for this unit
pub voted_at: DateTime<Utc>,
pub proxy_owner_id: Option<Uuid>, // If voting by proxy
}
pub enum VoteChoice {
Pour, // For
Contre, // Against
Abstention, // Abstain
}
```
### Business Rules
**Quorum validation:**
```rust
impl Meeting {
pub fn has_quorum(&self, total_units: i32, present_units: i32) -> bool {
let attendance_rate = present_units as f64 / total_units as f64;
match self.meeting_type {
MeetingType::Ordinary => attendance_rate >= 0.5, // 50%
MeetingType::Extraordinary => attendance_rate >= 0.67, // 2/3
}
}
}
```
**Resolution validation:**
```rust
impl Resolution {
pub fn calculate_result(&self, total_voting_power: f64) -> ResolutionStatus {
match self.majority_required {
MajorityType::Simple => {
// Majority of votes cast
if self.vote_count_pour > self.vote_count_contre + self.vote_count_abstention {
ResolutionStatus::Adopted
} else {
ResolutionStatus::Rejected
}
}
MajorityType::Absolute => {
// Majority of all possible votes
if self.total_voting_power_pour > total_voting_power / 2.0 {
ResolutionStatus::Adopted
} else {
ResolutionStatus::Rejected
}
}
MajorityType::Qualified(threshold) => {
// Custom threshold (e.g., 2/3)
let pour_ratio = self.total_voting_power_pour / total_voting_power;
if pour_ratio >= threshold {
ResolutionStatus::Adopted
} else {
ResolutionStatus::Rejected
}
}
}
}
}
```
## Database Schema
**New tables:**
```sql
-- Resolutions
CREATE TABLE resolutions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
meeting_id UUID NOT NULL REFERENCES meetings(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
resolution_type VARCHAR(50) NOT NULL, -- 'Ordinary', 'Extraordinary'
majority_required VARCHAR(50) NOT NULL, -- 'Simple', 'Absolute', 'Qualified:0.67'
vote_count_pour INT DEFAULT 0,
vote_count_contre INT DEFAULT 0,
vote_count_abstention INT DEFAULT 0,
total_voting_power_pour DECIMAL(10,4) DEFAULT 0,
total_voting_power_contre DECIMAL(10,4) DEFAULT 0,
total_voting_power_abstention DECIMAL(10,4) DEFAULT 0,
status VARCHAR(50) DEFAULT 'Pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
voted_at TIMESTAMP
);
-- Votes
CREATE TABLE votes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
resolution_id UUID NOT NULL REFERENCES resolutions(id) ON DELETE CASCADE,
owner_id UUID NOT NULL REFERENCES owners(id),
unit_id UUID NOT NULL REFERENCES units(id),
vote_choice VARCHAR(50) NOT NULL, -- 'Pour', 'Contre', 'Abstention'
voting_power DECIMAL(10,4) NOT NULL,
proxy_owner_id UUID REFERENCES owners(id),
voted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(resolution_id, unit_id) -- One vote per unit per resolution
);
CREATE INDEX idx_resolutions_meeting ON resolutions(meeting_id);
CREATE INDEX idx_votes_resolution ON votes(resolution_id);
CREATE INDEX idx_votes_owner ON votes(owner_id);
```
## API Endpoints (New)
### Resolutions
- `POST /api/v1/meetings/:id/resolutions` - Create resolution
- `GET /api/v1/meetings/:id/resolutions` - List meeting resolutions
- `GET /api/v1/resolutions/:id` - Get resolution details
- `PUT /api/v1/resolutions/:id` - Update resolution
- `DELETE /api/v1/resolutions/:id` - Delete resolution
### Votes
- `POST /api/v1/resolutions/:id/vote` - Cast vote
- `GET /api/v1/resolutions/:id/votes` - Get all votes for resolution
- `PUT /api/v1/resolutions/:id/close` - Close voting & calculate result
- `GET /api/v1/meetings/:id/vote-summary` - Get all resolution results for meeting
## Frontend Components
### 1. ResolutionList.svelte
Display resolutions for a meeting with vote counts:
```svelte
<script lang="ts">
export let meetingId: string;
let resolutions: Resolution[] = [];
async function loadResolutions() {
const response = await fetch(`/api/v1/meetings/${meetingId}/resolutions`);
resolutions = await response.json();
}
</script>
<div class="resolutions">
{#each resolutions as resolution}
<div class="resolution-card">
<h3>{resolution.title}</h3>
<p>{resolution.description}</p>
<div class="vote-summary">
<span class="pour">Pour: {resolution.vote_count_pour} ({resolution.total_voting_power_pour}%)</span>
<span class="contre">Contre: {resolution.vote_count_contre}</span>
<span class="abstention">Abstention: {resolution.vote_count_abstention}</span>
</div>
<div class="status">
{#if resolution.status === 'Adopted'}
<span class="badge success">✓ Adoptée</span>
{:else if resolution.status === 'Rejected'}
<span class="badge danger">✗ Rejetée</span>
{:else}
<span class="badge warning">En attente</span>
{/if}
</div>
</div>
{/each}
</div>
```
### 2. VotingModal.svelte
Modal for casting votes:
```svelte
<script lang="ts">
export let resolution: Resolution;
export let ownerUnits: Unit[];
let selectedChoice: VoteChoice = 'Pour';
async function castVote(unitId: string) {
await fetch(`/api/v1/resolutions/${resolution.id}/vote`, {
method: 'POST',
body: JSON.stringify({
unit_id: unitId,
vote_choice: selectedChoice
})
});
}
</script>
<Modal>
<h2>Vote: {resolution.title}</h2>
{#each ownerUnits as unit}
<div class="vote-form">
<span>Lot {unit.unit_number} ({unit.quota} millièmes)</span>
<div class="vote-buttons">
<button on:click={() => castVote(unit.id, 'Pour')}>Pour</button>
<button on:click={() => castVote(unit.id, 'Contre')}>Contre</button>
<button on:click={() => castVote(unit.id, 'Abstention')}>Abstention</button>
</div>
</div>
{/each}
</Modal>
```
### 3. VoteResults.svelte
Display final vote results with charts:
```svelte
<script lang="ts">
export let resolution: Resolution;
const total = resolution.vote_count_pour + resolution.vote_count_contre + resolution.vote_count_abstention;
const pourPercent = (resolution.vote_count_pour / total) * 100;
const contrePercent = (resolution.vote_count_contre / total) * 100;
</script>
<div class="vote-results">
<div class="progress-bar">
<div class="pour" style="width: {pourPercent}%">{pourPercent.toFixed(1)}%</div>
<div class="contre" style="width: {contrePercent}%">{contrePercent.toFixed(1)}%</div>
</div>
<table>
<tr><td>Pour</td><td>{resolution.vote_count_pour}</td><td>{resolution.total_voting_power_pour} millièmes</td></tr>
<tr><td>Contre</td><td>{resolution.vote_count_contre}</td><td>{resolution.total_voting_power_contre} millièmes</td></tr>
<tr><td>Abstention</td><td>{resolution.vote_count_abstention}</td><td>{resolution.total_voting_power_abstention} millièmes</td></tr>
</table>
</div>
```
## PDF Generation Integration
Update meeting minutes PDF to include vote results:
```rust
// In pcn_exporter.rs or new meeting_minutes_exporter.rs
pub fn generate_meeting_minutes_pdf(meeting: &Meeting, resolutions: Vec<Resolution>) -> Vec<u8> {
// Add resolution results to PDF
for resolution in resolutions {
doc.add_page(...);
doc.add_text(format!("Résolution: {}", resolution.title));
doc.add_text(format!("Pour: {} votes ({} millièmes)",
resolution.vote_count_pour,
resolution.total_voting_power_pour));
doc.add_text(format!("Résultat: {}", resolution.status));
}
}
```
## Testing
- [ ] Create resolution
- [ ] Cast vote (Pour/Contre/Abstention)
- [ ] Calculate quorum
- [ ] Calculate vote results (Simple/Absolute/Qualified majority)
- [ ] Close voting and finalize status
- [ ] Generate PDF with vote results
- [ ] Proxy voting
- [ ] Prevent duplicate votes per unit
## Acceptance Criteria
- [ ] Resolution and Vote entities implemented
- [ ] Database migrations complete
- [ ] 8 new API endpoints functional
- [ ] Quorum calculation correct
- [ ] Majority calculation (3 types) correct
- [ ] Frontend voting UI complete
- [ ] Vote results display complete
- [ ] PDF generation includes vote results
- [ ] Tests passing
- [ ] Documentation updated
## Effort Estimate
**Medium** (2-3 days)
- Day 1: Domain entities + database + repositories
- Day 2: Use cases + API endpoints + business logic
- Day 3: Frontend components + PDF integration + testing
## Related
- Enhances: Meeting management (Issue in roadmap)
- Supports: PDF generation (procès-verbaux)
- Complies with: Belgian copropriété law
## References
- Belgian copropriété law: https://www.notaire.be/
- Voting systems: https://fr.wikipedia.org/wiki/Syst%C3%A8me_de_vote