Initial commit: Voting plugin v1.0.0

This commit is contained in:
mmabdalla 2025-11-03 14:01:07 +02:00
commit c619e27370
21 changed files with 2371 additions and 0 deletions

44
CHANGELOG.md Normal file
View file

@ -0,0 +1,44 @@
# Changelog
All notable changes to the Voting Plugin will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.0] - 2025-11-02
### Added
- Comprehensive voting system for HOA board elections
- Decision-making voting functionality
- Community surveys support
- Campaign management (create, edit, delete campaigns)
- Form builder with SurveyJS integration
- Response collection and tracking
- Results and analytics dashboard
- Group management for custom recipient selection
- CSV and PDF export functionality
- Participation tracking and statistics
- Response change logs for audit trails
### Technical
- Plugin structure following Etihadat plugin standards
- BaseRepository pattern for database operations
- Multi-tenant support with site isolation
- RESTful API endpoints
- Permission-based access control
### Dependencies
- Core Etihadat API: ^2.3.0
- SurveyJS Form Builder
- Database abstraction via BaseRepository
---
## Version History
- **1.0.0** (2025-11-02): Initial release
---
**Note**: For detailed development notes, see `version.txt`.

174
README.md Normal file
View file

@ -0,0 +1,174 @@
# Voting Plugin
## Overview
The Voting Plugin provides a comprehensive voting system for HOA board elections, decision-making, and community surveys. It enables HOAs to conduct various types of votes with proper tracking, result sharing, and audit trails.
## Features
### 1. Campaign Types
- **Board Elections**: Vote for board members (Chairman, Treasurer, Deputy Manager, Members)
- **Decisions**: Vote on board decisions that require owner input
- **Surveys**: Collect public opinions on various matters
### 2. Form Builder (Placeholder)
Currently using a placeholder for form creation. Future versions will include:
- Drag-and-drop form builder
- External form builder integration
- Custom question types and validation
Form structure is stored as JSONB in the database, allowing flexibility for future enhancements.
### 3. Campaign Management
- Create, edit, and delete voting campaigns
- Set campaign dates (start and end)
- Configure result sharing (detailed, anonymous, none)
- Control public access (restrict/allow)
- Track campaign status (draft, scheduled, active, completed, cancelled)
### 4. Recipient Management
- Target all owners, board members, or authorized voters
- Create custom groups for targeted campaigns
- Support for multiple delivery methods (email, SMS, owner app)
### 5. Response Tracking
- Track participation percentage
- Monitor email/text delivery status
- Record answered dates and modifications
- Maintain audit trail with change logs
### 6. Results & Analytics
- View global and individual responses
- Export results as CSV or PDF
- Display participation statistics
- Generate analytics reports
## Database Schema
### Core Tables (pg_vt_* naming convention)
- **pg_vt_campaigns**: Voting campaign definitions
- **pg_vt_forms**: Form templates and structures
- **pg_vt_responses**: Individual response tracking
- **pg_vt_response_answers**: Detailed answer data
- **pg_vt_response_change_logs**: Audit trail
- **pg_vt_groups**: Custom recipient groups
- **pg_vt_group_members**: Group membership
All tables follow the plugin naming convention: `pg_vt_*` (pg_ = plugin, vt_ = voting plugin).
## API Endpoints
### Campaigns
- `GET /api/plugins/voting/campaigns` - List all campaigns
- `GET /api/plugins/voting/campaigns/:id` - Get campaign details
- `POST /api/plugins/voting/campaigns` - Create campaign
- `PUT /api/plugins/voting/campaigns/:id` - Update campaign
- `DELETE /api/plugins/voting/campaigns/:id` - Delete campaign
### Forms
- `GET /api/plugins/voting/forms` - List all forms
- `GET /api/plugins/voting/forms/:id` - Get form details
- `POST /api/plugins/voting/forms` - Create form
- `PUT /api/plugins/voting/forms/:id` - Update form
- `DELETE /api/plugins/voting/forms/:id` - Delete form
### Responses
- `GET /api/plugins/voting/responses` - List responses
- `GET /api/plugins/voting/responses/:id` - Get response details
- `POST /api/plugins/voting/responses` - Submit response
### Groups
- `GET /api/plugins/voting/groups` - List all groups
- `GET /api/plugins/voting/groups/:id` - Get group details
- `POST /api/plugins/voting/groups` - Create group
- `PUT /api/plugins/voting/groups/:id` - Update group
- `DELETE /api/plugins/voting/groups/:id` - Delete group
## Repository Pattern
All database operations use the repository pattern extending the core `BaseRepository` directly. This ensures:
- Database abstraction (works with all supported databases)
- Multi-tenant support via site_id
- Consistent CRUD operations
- No database-specific code
Repositories:
- `CampaignRepository` - pg_vt_campaigns
- `FormRepository` - pg_vt_forms
- `ResponseRepository` - pg_vt_responses
- `ResponseAnswerRepository` - pg_vt_response_answers
- `ResponseChangeLogRepository` - pg_vt_response_change_logs
- `GroupRepository` - pg_vt_groups
- `GroupMemberRepository` - pg_vt_group_members
## Permissions
The plugin requires the following permissions:
- `view_campaigns`
- `create_campaigns`
- `edit_campaigns`
- `delete_campaigns`
- `view_results`
- `manage_forms`
- `export_data`
- `view_analytics`
## Pricing Plans
### Basic
- Board elections, decisions, basic surveys
- Email notifications
- 50 responses/month
- $39/month or $390/year
### Professional
- Advanced elections and surveys
- Email & SMS notifications
- Custom forms
- Export results
- Analytics dashboard
- 1000 responses/month
- $79/month or $790/year
### Enterprise
- Full voting suite
- Unlimited responses
- Advanced analytics
- API access
- White-label options
- $149/month or $1490/year
## Future Enhancements
1. **Form Builder**: Drag-and-drop form creation UI
2. **External Form Builder**: Integration with tools like Typeform, JotForm
3. **Advanced Analytics**: Interactive dashboards and reporting
4. **Real-time Updates**: WebSocket support for live results
5. **Mobile App**: Enhanced voting experience on mobile
6. **Integration**: Connect with communication plugin for notifications
## Installation
The plugin is automatically loaded by the PluginLoader on server startup. No additional configuration required.
## Testing
All API endpoints should have comprehensive tests following TDD principles. Tests must use real database connections - no mocks allowed per project policy.
## Architecture Compliance
This plugin follows all constitutional principles:
- ✅ Plugin-first architecture with prefixed table names (pg_vt_*)
- ✅ Repository pattern for database abstraction
- ✅ No mock data in tests
- ✅ TypeScript-ready structure
- ✅ Multi-tenant support
- ✅ RESTful API design

View file

@ -0,0 +1,254 @@
/**
* Voting Plugin Test Helper
*
* Provides utilities for testing the voting plugin endpoints
* Uses REAL PostgreSQL database - NO mocks
*/
const path = require('path');
const fs = require('fs');
/**
* Create test fixtures for voting plugin testing
*/
class VotingTestHelper {
constructor() {
this.siteId = null;
this.unitId = null;
this.userId = null;
this.personId = null;
}
/**
* Create a test site
*/
async createTestSite() {
const { siteRepository } = require('../../../src/repositories');
const siteData = {
name: `Test Voting Site ${Date.now()}`,
address: '123 Voting Street',
email: `voting-${Date.now()}@example.com`,
owner_name: 'Test Owner',
owner_email: `owner-${Date.now()}@example.com`,
phone: '+1234567890',
plan_id: null,
subscription_type: 'monthly',
is_active: true,
operational_mode: 'dev'
};
const site = await siteRepository.createSite(siteData);
this.siteId = site.id;
return site;
}
/**
* Create a test building
*/
async createTestBuilding(siteId) {
const { buildingRepository } = require('../../../src/repositories');
const buildingData = {
site_id: siteId,
name: 'Test Building',
address: '123 Test Avenue',
total_floors: 5,
total_units: 10
};
const building = await buildingRepository.createBuilding(buildingData);
return building;
}
/**
* Create a test unit
*/
async createTestUnit(siteId, buildingId) {
const { unitsRepository } = require('../../../src/repositories');
const unitData = {
site_id: siteId,
building_id: buildingId,
unit_number: `U${Date.now()}`,
type: 'residential',
size: 100,
monthly_service_fee: 500,
location: 'Floor 3'
};
const unit = await unitsRepository.createUnit(unitData);
this.unitId = unit.id;
return unit;
}
/**
* Create a test user for authentication
*/
async createTestUser(siteId) {
const { userRepository } = require('../../../src/repositories');
const bcrypt = require('bcryptjs');
const passwordHash = await bcrypt.hash('test123', 10);
const userData = {
email: `voting-${Date.now()}@test.com`,
name: 'Test Voting User',
role: 'hoa_admin',
site_id: siteId,
is_active: true,
password_hash: passwordHash
};
const user = await userRepository.createUser(userData);
this.userId = user.id;
return user;
}
/**
* Create a test person (for voting)
*/
async createTestPerson(siteId, unitId, userId = null) {
const { peopleRepository } = require('../../../src/repositories');
const personData = {
site_id: siteId,
name: `Test Person ${Date.now()}`,
email: `person-${Date.now()}@test.com`,
phone: '+1234567890',
role: 'owner',
user_id: userId,
unit_ids: [unitId],
is_active: true
};
const person = await peopleRepository.create(personData);
this.personId = person.id;
return person;
}
/**
* Create a test form
*/
async createTestForm(siteId, formType = 'board_election') {
const { FormRepository } = require('../../repositories');
const formRepo = new FormRepository();
const formData = {
site_id: siteId,
name: 'Test Board Election',
description: 'Test board election form',
form_type: formType,
form_structure: {
questions: [
{
id: 'q1',
type: 'multiple_choice',
label: 'Choose 4 Board Members *',
required: true,
multiple: true,
max_selections: 4,
options: [
{ id: 'opt1', value: 'bod 1' },
{ id: 'opt2', value: 'bod 2' },
{ id: 'opt3', value: 'bod 3' },
{ id: 'opt4', value: 'bod 4' },
{ id: 'opt5', value: 'bod 5' }
]
}
]
},
is_active: true,
created_by: this.userId || 'test-user'
};
return await formRepo.create(formData);
}
/**
* Create a test campaign
*/
async createTestCampaign(siteId, formId, campaignType = 'board_election') {
const { CampaignRepository } = require('../../repositories');
const campaignRepo = new CampaignRepository();
const startDate = new Date();
const endDate = new Date(startDate.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days later
const campaignData = {
site_id: siteId,
title: 'Test Board Election Campaign',
description: 'Test board election campaign',
campaign_type: campaignType,
form_id: formId,
status: 'active',
overall_sharing_results: 'detailed',
public_access: 'restrict',
start_date: startDate.toISOString(),
end_date: endDate.toISOString(),
recipient_type: 'all_owners',
send_email: true,
send_text: false,
send_owner_app: true,
created_by: this.userId || 'test-user'
};
return await campaignRepo.create(campaignData);
}
/**
* Create a test group
*/
async createTestGroup(siteId) {
const { GroupRepository } = require('../../repositories');
const groupRepo = new GroupRepository();
const groupData = {
site_id: siteId,
name: `Test Group ${Date.now()}`,
description: 'Test voting group',
created_by: this.userId || 'test-user'
};
return await groupRepo.create(groupData);
}
/**
* Cleanup test data
*/
async cleanup() {
// Cleanup will be handled by the schema manager
// This method is here for future use if needed
}
/**
* Get mock request object for testing
*/
getMockRequest(siteId, userId) {
return {
user: {
id: userId,
siteId: siteId,
role: 'hoa_admin'
},
query: {
site_id: siteId
},
body: {}
};
}
/**
* Get mock response object for testing
*/
getMockResponse() {
const res = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis()
};
return res;
}
}
module.exports = VotingTestHelper;

View file

@ -0,0 +1,208 @@
/**
* Campaign Repository Tests
*
* Tests for CampaignRepository database operations
* Uses REAL PostgreSQL database - NO mocks
*/
const { CampaignRepository } = require('../../repositories');
const VotingTestHelper = require('../helpers/testHelper');
describe('CampaignRepository', () => {
let campaignRepo;
let testHelper;
let testSite;
let testUserId;
beforeAll(async () => {
campaignRepo = new CampaignRepository();
testHelper = new VotingTestHelper();
testSite = await testHelper.createTestSite();
const testUser = await testHelper.createTestUser(testSite.id);
testUserId = testUser.id;
});
afterAll(async () => {
// Cleanup handled by global schema manager
});
describe('create', () => {
it('should create a new campaign', async () => {
const formData = await testHelper.createTestForm(testSite.id);
const campaignData = {
site_id: testSite.id,
title: 'Board Elections 2025',
description: 'Annual board elections',
campaign_type: 'board_election',
form_id: formData.id,
status: 'draft',
overall_sharing_results: 'detailed',
public_access: 'restrict',
start_date: new Date().toISOString(),
end_date: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
recipient_type: 'all_owners',
send_email: true,
send_text: true,
send_owner_app: true,
created_by: testUserId
};
const campaign = await campaignRepo.create(campaignData);
expect(campaign).toBeDefined();
expect(campaign.id).toBeDefined();
expect(campaign.title).toBe('Board Elections 2025');
expect(campaign.campaign_type).toBe('board_election');
expect(campaign.status).toBe('draft');
});
});
describe('findOne', () => {
it('should find a campaign by ID', async () => {
const formData = await testHelper.createTestForm(testSite.id);
const campaign = await testHelper.createTestCampaign(testSite.id, formData.id);
const found = await campaignRepo.findOne({ id: campaign.id });
expect(found).toBeDefined();
expect(found.id).toBe(campaign.id);
expect(found.title).toBe(campaign.title);
});
it('should return null if campaign not found', async () => {
const found = await campaignRepo.findOne({ id: '00000000-0000-0000-0000-000000000000' });
expect(found).toBeUndefined();
});
});
describe('findAll', () => {
it('should find all campaigns for a site', async () => {
const formData = await testHelper.createTestForm(testSite.id);
// Create multiple campaigns
await testHelper.createTestCampaign(testSite.id, formData.id, 'board_election');
await testHelper.createTestCampaign(testSite.id, formData.id, 'decision');
await testHelper.createTestCampaign(testSite.id, formData.id, 'survey');
const campaigns = await campaignRepo.findAll(
{ site_id: testSite.id },
{ orderBy: 'created_at', orderDirection: 'desc' }
);
expect(campaigns).toBeInstanceOf(Array);
expect(campaigns.length).toBeGreaterThanOrEqual(3);
});
it('should filter campaigns by type', async () => {
const formData = await testHelper.createTestForm(testSite.id);
await testHelper.createTestCampaign(testSite.id, formData.id, 'board_election');
await testHelper.createTestCampaign(testSite.id, formData.id, 'decision');
const elections = await campaignRepo.findAll(
{ site_id: testSite.id, campaign_type: 'board_election' },
{ orderBy: 'created_at', orderDirection: 'desc' }
);
elections.forEach(campaign => {
expect(campaign.campaign_type).toBe('board_election');
});
});
it('should filter campaigns by status', async () => {
const formData = await testHelper.createTestForm(testSite.id);
const campaign = await testHelper.createTestCampaign(testSite.id, formData.id);
// Update status
await campaignRepo.updateById(campaign.id, { status: 'active' });
const activeCampaigns = await campaignRepo.findAll(
{ site_id: testSite.id, status: 'active' }
);
expect(activeCampaigns.length).toBeGreaterThanOrEqual(1);
activeCampaigns.forEach(c => {
expect(c.status).toBe('active');
});
});
});
describe('updateById', () => {
it('should update campaign details', async () => {
const formData = await testHelper.createTestForm(testSite.id);
const campaign = await testHelper.createTestCampaign(testSite.id, formData.id);
const updated = await campaignRepo.updateById(campaign.id, {
title: 'Updated Campaign Title',
status: 'active'
});
expect(updated).toBeDefined();
expect(updated.title).toBe('Updated Campaign Title');
expect(updated.status).toBe('active');
});
});
describe('deleteById', () => {
it('should delete a campaign', async () => {
const formData = await testHelper.createTestForm(testSite.id);
const campaign = await testHelper.createTestCampaign(testSite.id, formData.id);
const deleted = await campaignRepo.deleteById(campaign.id);
expect(deleted).toBe(true);
const found = await campaignRepo.findOne({ id: campaign.id });
expect(found).toBeUndefined();
});
});
describe('findByIdWithDetails', () => {
it('should return campaign with participation statistics', async () => {
const formData = await testHelper.createTestForm(testSite.id);
const campaign = await testHelper.createTestCampaign(testSite.id, formData.id);
const details = await campaignRepo.findByIdWithDetails(campaign.id);
expect(details).toBeDefined();
expect(details.participation_count).toBeDefined();
expect(details.total_invited).toBeDefined();
expect(details.participation_percentage).toBeDefined();
});
});
describe('updateStatus', () => {
it('should update campaign status to active', async () => {
const formData = await testHelper.createTestForm(testSite.id);
const campaign = await testHelper.createTestCampaign(testSite.id, formData.id);
const updated = await campaignRepo.updateStatus(campaign.id, 'active');
expect(updated).toBeDefined();
expect(updated.status).toBe('active');
});
it('should update campaign status to completed', async () => {
const formData = await testHelper.createTestForm(testSite.id);
const campaign = await testHelper.createTestCampaign(testSite.id, formData.id);
const updated = await campaignRepo.updateStatus(campaign.id, 'completed');
expect(updated).toBeDefined();
expect(updated.status).toBe('completed');
});
it('should throw error for invalid status', async () => {
const formData = await testHelper.createTestForm(testSite.id);
const campaign = await testHelper.createTestCampaign(testSite.id, formData.id);
await expect(
campaignRepo.updateStatus(campaign.id, 'invalid_status')
).rejects.toThrow('Invalid status: invalid_status');
});
});
});

View file

@ -0,0 +1,208 @@
/**
* Campaign Routes Tests
*
* Tests for `/api/plugins/voting/campaigns` endpoints
* Uses REAL PostgreSQL database - NO mocks
*/
const request = require('supertest');
const express = require('express');
const campaignsRouter = require('../../routes/campaigns');
const VotingTestHelper = require('../helpers/testHelper');
describe('Campaign Routes', () => {
let app;
let testHelper;
let testSite;
let testUserId;
beforeAll(async () => {
// Create Express app for testing
app = express();
app.use(express.json());
app.use('/api/plugins/voting', campaignsRouter);
// Initialize test helper
testHelper = new VotingTestHelper();
// Create test data
testSite = await testHelper.createTestSite();
const testUser = await testHelper.createTestUser(testSite.id);
testUserId = testUser.id;
});
afterAll(async () => {
// Cleanup handled by global schema manager
});
describe('GET /api/plugins/voting/campaigns', () => {
it('should return all campaigns for a site', async () => {
// Create test campaigns
const form1 = await testHelper.createTestForm(testSite.id);
const form2 = await testHelper.createTestForm(testSite.id);
await testHelper.createTestCampaign(testSite.id, form1.id, 'board_election');
await testHelper.createTestCampaign(testSite.id, form2.id, 'decision');
const response = await request(app)
.get('/api/plugins/voting/campaigns')
.query({ site_id: testSite.id });
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toBeInstanceOf(Array);
expect(response.body.data.length).toBeGreaterThanOrEqual(2);
// Verify campaign structure
const campaign = response.body.data.find(c => c.campaign_type === 'board_election');
expect(campaign).toBeDefined();
expect(campaign.title).toBeDefined();
expect(campaign.status).toBeDefined();
});
it('should return empty array if no campaigns exist', async () => {
// Create a different site with no campaigns
const anotherSite = await testHelper.createTestSite();
const response = await request(app)
.get('/api/plugins/voting/campaigns')
.query({ site_id: anotherSite.id });
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toEqual([]);
});
});
describe('GET /api/plugins/voting/campaigns/:id', () => {
let testCampaign;
beforeEach(async () => {
// Create a test campaign
const form = await testHelper.createTestForm(testSite.id);
testCampaign = await testHelper.createTestCampaign(testSite.id, form.id);
});
it('should return campaign details by ID', async () => {
const response = await request(app)
.get(`/api/plugins/voting/campaigns/${testCampaign.id}`)
.query({ site_id: testSite.id });
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toBeDefined();
expect(response.body.data.id).toBe(testCampaign.id);
expect(response.body.data.title).toBe(testCampaign.title);
});
it('should return 404 if campaign not found', async () => {
const response = await request(app)
.get('/api/plugins/voting/campaigns/00000000-0000-0000-0000-000000000000');
expect(response.status).toBe(404);
expect(response.body.success).toBe(false);
expect(response.body.error).toBe('Campaign not found');
});
});
describe('POST /api/plugins/voting/campaigns', () => {
it('should create a new campaign', async () => {
const form = await testHelper.createTestForm(testSite.id);
const campaignData = {
site_id: testSite.id,
title: 'New Board Election',
description: 'Annual elections for board members',
campaign_type: 'board_election',
form_id: form.id,
status: 'draft',
overall_sharing_results: 'detailed',
public_access: 'restrict',
start_date: new Date().toISOString(),
end_date: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
recipient_type: 'all_owners',
send_email: true,
send_text: false,
send_owner_app: true,
created_by: testUserId
};
const response = await request(app)
.post('/api/plugins/voting/campaigns')
.send(campaignData);
expect(response.status).toBe(201);
expect(response.body.success).toBe(true);
expect(response.body.data).toBeDefined();
expect(response.body.data.title).toBe('New Board Election');
expect(response.body.data.campaign_type).toBe('board_election');
});
});
describe('PUT /api/plugins/voting/campaigns/:id', () => {
let testCampaign;
beforeEach(async () => {
const form = await testHelper.createTestForm(testSite.id);
testCampaign = await testHelper.createTestCampaign(testSite.id, form.id);
});
it('should update campaign details', async () => {
const updateData = {
title: 'Updated Campaign Title',
status: 'active'
};
const response = await request(app)
.put(`/api/plugins/voting/campaigns/${testCampaign.id}`)
.send(updateData);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.title).toBe('Updated Campaign Title');
expect(response.body.data.status).toBe('active');
});
it('should return 404 if campaign not found', async () => {
const response = await request(app)
.put('/api/plugins/voting/campaigns/00000000-0000-0000-0000-000000000000')
.send({ title: 'Updated' });
expect(response.status).toBe(404);
expect(response.body.success).toBe(false);
});
});
describe('DELETE /api/plugins/voting/campaigns/:id', () => {
let testCampaign;
beforeEach(async () => {
const form = await testHelper.createTestForm(testSite.id);
testCampaign = await testHelper.createTestCampaign(testSite.id, form.id);
});
it('should delete a campaign', async () => {
const response = await request(app)
.delete(`/api/plugins/voting/campaigns/${testCampaign.id}`);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.message).toBe('Campaign deleted successfully');
// Verify deletion
const getResponse = await request(app)
.get(`/api/plugins/voting/campaigns/${testCampaign.id}`);
expect(getResponse.status).toBe(404);
});
it('should return 404 if campaign not found', async () => {
const response = await request(app)
.delete('/api/plugins/voting/campaigns/00000000-0000-0000-0000-000000000000');
expect(response.status).toBe(404);
expect(response.body.success).toBe(false);
});
});
});

170
database/schema.sql Normal file
View file

@ -0,0 +1,170 @@
-- Voting Plugin Database Schema
-- Plugin naming convention: pg_vt_tableName
-- pg_ = plugin, vt_ = voting plugin
-- Campaign Types: 'board_election', 'decision', 'survey'
-- Campaign Status: 'draft', 'scheduled', 'active', 'completed', 'cancelled'
-- Result Sharing: 'detailed', 'anonymous', 'none'
-- Voting Campaigns table
CREATE TABLE IF NOT EXISTS pg_vt_campaigns (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
site_id UUID REFERENCES sites(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
campaign_type VARCHAR(50) NOT NULL, -- board_election, decision, survey
form_id UUID, -- Foreign key to pg_vt_forms table
status VARCHAR(50) DEFAULT 'draft', -- draft, scheduled, active, completed, cancelled
overall_sharing_results VARCHAR(50) DEFAULT 'detailed', -- detailed, anonymous, none
public_access VARCHAR(50) DEFAULT 'restrict', -- restrict, allow
-- Campaign dates
start_date TIMESTAMP WITH TIME ZONE NOT NULL,
end_date TIMESTAMP WITH TIME ZONE NOT NULL,
-- Recipients
recipient_group_id UUID, -- Reference to a group or null for all owners
recipient_type VARCHAR(50) DEFAULT 'all_owners', -- all_owners, board_members, authorized_voters, custom_group
-- Delivery methods
send_email BOOLEAN DEFAULT false,
send_text BOOLEAN DEFAULT false,
send_owner_app BOOLEAN DEFAULT false,
-- Metadata
created_by UUID REFERENCES users(id),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create indexes for campaigns
CREATE INDEX IF NOT EXISTS idx_pg_vt_campaigns_site_id ON pg_vt_campaigns(site_id);
CREATE INDEX IF NOT EXISTS idx_pg_vt_campaigns_type ON pg_vt_campaigns(campaign_type);
CREATE INDEX IF NOT EXISTS idx_pg_vt_campaigns_status ON pg_vt_campaigns(status);
CREATE INDEX IF NOT EXISTS idx_pg_vt_campaigns_dates ON pg_vt_campaigns(start_date, end_date);
-- Voting Forms table
-- Placeholder for form builder - will store form structure as JSON
CREATE TABLE IF NOT EXISTS pg_vt_forms (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
site_id UUID REFERENCES sites(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
description TEXT,
form_type VARCHAR(50) NOT NULL, -- board_election, decision, survey
-- Form structure stored as JSON
-- This is a placeholder until we build/embed a form builder
-- Example structure:
-- {
-- "questions": [
-- {
-- "id": "q1",
-- "type": "multiple_choice",
-- "label": "Choose 4 Board Members *",
-- "required": true,
-- "multiple": true,
-- "max_selections": 4,
-- "options": [
-- {"id": "opt1", "value": "bod 1"},
-- {"id": "opt2", "value": "bod 2"}
-- ]
-- }
-- ]
-- }
form_structure JSONB DEFAULT '{"questions": []}',
-- Metadata
created_by UUID REFERENCES users(id),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
is_active BOOLEAN DEFAULT true
);
CREATE INDEX IF NOT EXISTS idx_pg_vt_forms_site_id ON pg_vt_forms(site_id);
CREATE INDEX IF NOT EXISTS idx_pg_vt_forms_type ON pg_vt_forms(form_type);
-- Campaign Responses table (main response tracking)
CREATE TABLE IF NOT EXISTS pg_vt_responses (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
campaign_id UUID REFERENCES pg_vt_campaigns(id) ON DELETE CASCADE,
person_id UUID REFERENCES people(id),
user_id UUID REFERENCES users(id),
-- Participation tracking
answered BOOLEAN DEFAULT false,
answered_date TIMESTAMP WITH TIME ZONE,
last_modified_date TIMESTAMP WITH TIME ZONE,
-- Communication tracking
email_sent BOOLEAN DEFAULT false,
text_sent BOOLEAN DEFAULT false,
-- Metadata
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(campaign_id, person_id)
);
CREATE INDEX IF NOT EXISTS idx_pg_vt_responses_campaign_id ON pg_vt_responses(campaign_id);
CREATE INDEX IF NOT EXISTS idx_pg_vt_responses_person_id ON pg_vt_responses(person_id);
CREATE INDEX IF NOT EXISTS idx_pg_vt_responses_answered ON pg_vt_responses(answered);
-- Response Answers table (detailed answer data)
CREATE TABLE IF NOT EXISTS pg_vt_response_answers (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
response_id UUID REFERENCES pg_vt_responses(id) ON DELETE CASCADE,
question_id VARCHAR(255) NOT NULL, -- Reference to question in form_structure
answer_value TEXT, -- For text, numeric, date answers
answer_options JSONB, -- For multiple choice answers: ["bod 1", "bod 3", "bod 5", "bod 4"]
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_pg_vt_response_answers_response_id ON pg_vt_response_answers(response_id);
CREATE INDEX IF NOT EXISTS idx_pg_vt_response_answers_question_id ON pg_vt_response_answers(question_id);
-- Change Logs table (audit trail for response changes)
CREATE TABLE IF NOT EXISTS pg_vt_response_change_logs (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
response_id UUID REFERENCES pg_vt_responses(id) ON DELETE CASCADE,
campaign_id UUID REFERENCES pg_vt_campaigns(id) ON DELETE CASCADE,
person_id UUID REFERENCES people(id),
person_name VARCHAR(255),
unit VARCHAR(255),
email VARCHAR(255),
phone VARCHAR(50),
answered_date TIMESTAMP WITH TIME ZONE,
answered_change_date TIMESTAMP WITH TIME ZONE,
who_changed VARCHAR(255), -- Person who made the change
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_pg_vt_change_logs_campaign_id ON pg_vt_response_change_logs(campaign_id);
CREATE INDEX IF NOT EXISTS idx_pg_vt_change_logs_response_id ON pg_vt_response_change_logs(response_id);
-- Groups table (for custom recipient groups)
CREATE TABLE IF NOT EXISTS pg_vt_groups (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
site_id UUID REFERENCES sites(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
description TEXT,
created_by UUID REFERENCES users(id),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_pg_vt_groups_site_id ON pg_vt_groups(site_id);
-- Group Members junction table
CREATE TABLE IF NOT EXISTS pg_vt_group_members (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
group_id UUID REFERENCES pg_vt_groups(id) ON DELETE CASCADE,
person_id UUID REFERENCES people(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(group_id, person_id)
);
CREATE INDEX IF NOT EXISTS idx_pg_vt_group_members_group_id ON pg_vt_group_members(group_id);
CREATE INDEX IF NOT EXISTS idx_pg_vt_group_members_person_id ON pg_vt_group_members(person_id);

102
plugin.json Normal file
View file

@ -0,0 +1,102 @@
{
"name": "voting",
"displayName": "Voting Plugin",
"version": "1.0.0",
"description": "Comprehensive voting system for HOA board elections, decision-making, and community surveys",
"apiVersion": "1.0",
"author": "Etihadat Team",
"website": "https://etihadat.com",
"coreApiVersion": "^2.3.0",
"coreApiMinVersion": "2.3.0",
"coreApiMaxVersion": "3.0.0",
"database": {
"schema": "public",
"tables": [
"pg_vt_campaigns",
"pg_vt_forms",
"pg_vt_responses",
"pg_vt_response_answers",
"pg_vt_response_change_logs",
"pg_vt_groups",
"pg_vt_group_members"
],
"migrations": {
"enabled": true,
"path": "database/migrations"
}
},
"build": {
"entryPoint": "routes/index.js",
"exclude": ["__tests__", "node_modules", ".git", "build", ".DS_Store"],
"include": ["database", "routes", "repositories", "services", "frontend"]
},
"frontend": {
"enabled": true,
"bundle": "frontend/bundle.js"
},
"dependencies": {
"core": "^2.3.0"
},
"permissions": [
"view_campaigns",
"create_campaigns",
"edit_campaigns",
"delete_campaigns",
"view_results",
"manage_forms",
"export_data",
"view_analytics"
],
"pricing_plans": [
{
"name": "Basic",
"monthly_price": 39,
"annual_price": 390,
"features": [
"Board elections",
"Decision voting",
"Basic surveys",
"Email notifications",
"50 responses/month"
]
},
{
"name": "Professional",
"monthly_price": 79,
"annual_price": 790,
"features": [
"Advanced board elections",
"Decision voting with approvals",
"Advanced surveys",
"Email & SMS notifications",
"1000 responses/month",
"Custom forms",
"Export results",
"Analytics dashboard"
]
},
{
"name": "Enterprise",
"monthly_price": 149,
"annual_price": 1490,
"features": [
"Full voting suite",
"Multi-campaign management",
"Unlimited responses",
"Advanced analytics",
"Custom integrations",
"API access",
"White-label options",
"Priority support"
]
}
],
"routes": {
"campaigns": "/campaigns",
"forms": "/forms",
"responses": "/responses",
"results": "/results",
"analytics": "/analytics"
}
}

View file

@ -0,0 +1,74 @@
const BaseRepository = require('../../../src/database/repository');
const logger = require('../../../src/utils/logger');
class CampaignRepository extends BaseRepository {
constructor() {
super('pg_vt_campaigns');
}
/**
* Find campaigns with filters
* @param {Object} filters - Filter criteria
* @param {Object} options - Query options
*/
async findWithFilters(filters = {}, options = {}) {
const criteria = {};
if (filters.site_id) criteria.site_id = filters.site_id;
if (filters.campaign_type) criteria.campaign_type = filters.campaign_type;
if (filters.status) criteria.status = filters.status;
const orderBy = options.orderBy || 'created_at';
const orderDirection = options.orderDirection || 'desc';
const limit = options.limit;
return await this.findAll(criteria, { orderBy, orderDirection, limit });
}
/**
* Get campaign by ID with related data
* @param {string} campaignId - Campaign ID
*/
async findByIdWithDetails(campaignId) {
const campaign = await this.findOne({ id: campaignId });
if (!campaign) return null;
// Get response count
const ResponseRepository = require('./ResponseRepository');
const responseRepo = new ResponseRepository();
const totalResponses = await responseRepo.count({ campaign_id: campaignId });
const answeredCount = await responseRepo.count({
campaign_id: campaignId,
answered: true
});
return {
...campaign,
participation_count: answeredCount,
total_invited: totalResponses,
participation_percentage: totalResponses > 0
? ((answeredCount / totalResponses) * 100).toFixed(2)
: 0
};
}
/**
* Update campaign status
* @param {string} campaignId - Campaign ID
* @param {string} status - New status
*/
async updateStatus(campaignId, status) {
const allowedStatuses = ['draft', 'scheduled', 'active', 'completed', 'cancelled'];
if (!allowedStatuses.includes(status)) {
throw new Error(`Invalid status: ${status}`);
}
return await this.updateById(campaignId, {
status,
updated_at: new Date().toISOString()
});
}
}
module.exports = CampaignRepository;

View file

@ -0,0 +1,45 @@
const BaseRepository = require('../../../src/database/repository');
const logger = require('../../../src/utils/logger');
class FormRepository extends BaseRepository {
constructor() {
super('pg_vt_forms');
}
/**
* Find forms with filters
* @param {Object} filters - Filter criteria
* @param {Object} options - Query options
*/
async findWithFilters(filters = {}, options = {}) {
const criteria = {};
if (filters.site_id) criteria.site_id = filters.site_id;
if (filters.form_type) criteria.form_type = filters.form_type;
if (filters.is_active !== undefined) criteria.is_active = filters.is_active;
const orderBy = options.orderBy || 'created_at';
const orderDirection = options.orderDirection || 'desc';
const limit = options.limit;
return await this.findAll(criteria, { orderBy, orderDirection, limit });
}
/**
* Create or update form
* @param {Object} formData - Form data
*/
async save(formData) {
if (formData.id) {
return await this.updateById(formData.id, {
...formData,
updated_at: new Date().toISOString()
});
} else {
return await this.create(formData);
}
}
}
module.exports = FormRepository;

View file

@ -0,0 +1,70 @@
const BaseRepository = require('../../../src/database/repository');
const logger = require('../../../src/utils/logger');
class GroupMemberRepository extends BaseRepository {
constructor() {
super('pg_vt_group_members');
}
/**
* Find members with filters
* @param {Object} filters - Filter criteria
* @param {Object} options - Query options
*/
async findWithFilters(filters = {}, options = {}) {
const criteria = {};
if (filters.group_id) criteria.group_id = filters.group_id;
if (filters.person_id) criteria.person_id = filters.person_id;
const orderBy = options.orderBy || 'created_at';
const orderDirection = options.orderDirection || 'desc';
const limit = options.limit;
return await this.findAll(criteria, { orderBy, orderDirection, limit });
}
/**
* Add member to group
* @param {string} groupId - Group ID
* @param {string} personId - Person ID
*/
async addMember(groupId, personId) {
try {
return await this.create({
group_id: groupId,
person_id: personId,
created_at: new Date().toISOString()
});
} catch (error) {
// Already exists, return existing
if (error.message && error.message.includes('duplicate')) {
return await this.findOne({ group_id: groupId, person_id: personId });
}
throw error;
}
}
/**
* Remove member from group
* @param {string} groupId - Group ID
* @param {string} personId - Person ID
*/
async removeMember(groupId, personId) {
const member = await this.findOne({ group_id: groupId, person_id: personId });
if (!member) return false;
return await this.deleteById(member.id);
}
/**
* Get all members of a group
* @param {string} groupId - Group ID
*/
async getGroupMembers(groupId) {
return await this.findAll({ group_id: groupId });
}
}
module.exports = GroupMemberRepository;

View file

@ -0,0 +1,28 @@
const BaseRepository = require('../../../src/database/repository');
const logger = require('../../../src/utils/logger');
class GroupRepository extends BaseRepository {
constructor() {
super('pg_vt_groups');
}
/**
* Find groups with filters
* @param {Object} filters - Filter criteria
* @param {Object} options - Query options
*/
async findWithFilters(filters = {}, options = {}) {
const criteria = {};
if (filters.site_id) criteria.site_id = filters.site_id;
const orderBy = options.orderBy || 'created_at';
const orderDirection = options.orderDirection || 'desc';
const limit = options.limit;
return await this.findAll(criteria, { orderBy, orderDirection, limit });
}
}
module.exports = GroupRepository;

View file

@ -0,0 +1,44 @@
const BaseRepository = require('../../../src/database/repository');
const logger = require('../../../src/utils/logger');
class ResponseAnswerRepository extends BaseRepository {
constructor() {
super('pg_vt_response_answers');
}
/**
* Find answers with filters
* @param {Object} filters - Filter criteria
* @param {Object} options - Query options
*/
async findWithFilters(filters = {}, options = {}) {
const criteria = {};
if (filters.response_id) criteria.response_id = filters.response_id;
if (filters.question_id) criteria.question_id = filters.question_id;
const orderBy = options.orderBy || 'created_at';
const orderDirection = options.orderDirection || 'desc';
const limit = options.limit;
return await this.findAll(criteria, { orderBy, orderDirection, limit });
}
/**
* Create or update answer
* @param {Object} answerData - Answer data
*/
async save(answerData) {
if (answerData.id) {
return await this.updateById(answerData.id, {
...answerData,
updated_at: new Date().toISOString()
});
} else {
return await this.create(answerData);
}
}
}
module.exports = ResponseAnswerRepository;

View file

@ -0,0 +1,41 @@
const BaseRepository = require('../../../src/database/repository');
const logger = require('../../../src/utils/logger');
class ResponseChangeLogRepository extends BaseRepository {
constructor() {
super('pg_vt_response_change_logs');
}
/**
* Find logs with filters
* @param {Object} filters - Filter criteria
* @param {Object} options - Query options
*/
async findWithFilters(filters = {}, options = {}) {
const criteria = {};
if (filters.response_id) criteria.response_id = filters.response_id;
if (filters.campaign_id) criteria.campaign_id = filters.campaign_id;
if (filters.person_id) criteria.person_id = filters.person_id;
const orderBy = options.orderBy || 'created_at';
const orderDirection = options.orderDirection || 'desc';
const limit = options.limit;
return await this.findAll(criteria, { orderBy, orderDirection, limit });
}
/**
* Log a response change
* @param {Object} logData - Change log data
*/
async logChange(logData) {
return await this.create({
...logData,
created_at: new Date().toISOString()
});
}
}
module.exports = ResponseChangeLogRepository;

View file

@ -0,0 +1,63 @@
const BaseRepository = require('../../../src/database/repository');
const logger = require('../../../src/utils/logger');
class ResponseRepository extends BaseRepository {
constructor() {
super('pg_vt_responses');
}
/**
* Find responses with filters
* @param {Object} filters - Filter criteria
* @param {Object} options - Query options
*/
async findWithFilters(filters = {}, options = {}) {
const criteria = {};
if (filters.campaign_id) criteria.campaign_id = filters.campaign_id;
if (filters.person_id) criteria.person_id = filters.person_id;
if (filters.answered !== undefined) criteria.answered = filters.answered;
const orderBy = options.orderBy || 'created_at';
const orderDirection = options.orderDirection || 'desc';
const limit = options.limit;
return await this.findAll(criteria, { orderBy, orderDirection, limit });
}
/**
* Get or create response
* @param {string} campaignId - Campaign ID
* @param {string} personId - Person ID
*/
async getOrCreate(campaignId, personId) {
const existing = await this.findOne({
campaign_id: campaignId,
person_id: personId
});
if (existing) return existing;
return await this.create({
campaign_id: campaignId,
person_id: personId,
answered: false
});
}
/**
* Mark response as answered
* @param {string} responseId - Response ID
*/
async markAnswered(responseId) {
return await this.updateById(responseId, {
answered: true,
answered_date: new Date().toISOString(),
last_modified_date: new Date().toISOString(),
updated_at: new Date().toISOString()
});
}
}
module.exports = ResponseRepository;

18
repositories/index.js Normal file
View file

@ -0,0 +1,18 @@
const CampaignRepository = require('./CampaignRepository');
const FormRepository = require('./FormRepository');
const ResponseRepository = require('./ResponseRepository');
const ResponseAnswerRepository = require('./ResponseAnswerRepository');
const ResponseChangeLogRepository = require('./ResponseChangeLogRepository');
const GroupRepository = require('./GroupRepository');
const GroupMemberRepository = require('./GroupMemberRepository');
module.exports = {
CampaignRepository,
FormRepository,
ResponseRepository,
ResponseAnswerRepository,
ResponseChangeLogRepository,
GroupRepository,
GroupMemberRepository
};

170
routes/campaigns.js Normal file
View file

@ -0,0 +1,170 @@
const express = require('express');
const router = express.Router();
const { CampaignRepository } = require('../repositories');
const logger = require('../../../src/utils/logger');
/**
* Get all campaigns for the site
* GET /api/plugins/voting/campaigns
*/
router.get('/', async (req, res) => {
try {
const siteId = req.query.site_id || req.user?.siteId;
const campaignRepo = new CampaignRepository();
const filters = { site_id: siteId };
const options = {
orderBy: 'created_at',
orderDirection: 'desc'
};
const campaigns = await campaignRepo.findWithFilters(filters, options);
res.json({
success: true,
data: campaigns
});
} catch (error) {
logger.error('Failed to get campaigns:', error);
res.status(500).json({
success: false,
error: 'Failed to retrieve campaigns',
message: error.message
});
}
});
/**
* Get a specific campaign by ID
* GET /api/plugins/voting/campaigns/:id
*/
router.get('/:id', async (req, res) => {
try {
const { id } = req.params;
const campaignRepo = new CampaignRepository();
const campaign = await campaignRepo.findByIdWithDetails(id);
if (!campaign) {
return res.status(404).json({
success: false,
error: 'Campaign not found'
});
}
res.json({
success: true,
data: campaign
});
} catch (error) {
logger.error('Failed to get campaign:', error);
res.status(500).json({
success: false,
error: 'Failed to retrieve campaign',
message: error.message
});
}
});
/**
* Create a new campaign
* POST /api/plugins/voting/campaigns
*/
router.post('/', async (req, res) => {
try {
const campaignRepo = new CampaignRepository();
const campaignData = {
...req.body,
created_by: req.user?.id,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
const campaign = await campaignRepo.create(campaignData);
res.status(201).json({
success: true,
data: campaign,
message: 'Campaign created successfully'
});
} catch (error) {
logger.error('Failed to create campaign:', error);
res.status(500).json({
success: false,
error: 'Failed to create campaign',
message: error.message
});
}
});
/**
* Update a campaign
* PUT /api/plugins/voting/campaigns/:id
*/
router.put('/:id', async (req, res) => {
try {
const { id } = req.params;
const campaignRepo = new CampaignRepository();
const updateData = {
...req.body,
updated_at: new Date().toISOString()
};
const campaign = await campaignRepo.updateById(id, updateData);
if (!campaign) {
return res.status(404).json({
success: false,
error: 'Campaign not found'
});
}
res.json({
success: true,
data: campaign,
message: 'Campaign updated successfully'
});
} catch (error) {
logger.error('Failed to update campaign:', error);
res.status(500).json({
success: false,
error: 'Failed to update campaign',
message: error.message
});
}
});
/**
* Delete a campaign
* DELETE /api/plugins/voting/campaigns/:id
*/
router.delete('/:id', async (req, res) => {
try {
const { id } = req.params;
const campaignRepo = new CampaignRepository();
const deleted = await campaignRepo.deleteById(id);
if (!deleted) {
return res.status(404).json({
success: false,
error: 'Campaign not found'
});
}
res.json({
success: true,
message: 'Campaign deleted successfully'
});
} catch (error) {
logger.error('Failed to delete campaign:', error);
res.status(500).json({
success: false,
error: 'Failed to delete campaign',
message: error.message
});
}
});
module.exports = router;

170
routes/forms.js Normal file
View file

@ -0,0 +1,170 @@
const express = require('express');
const router = express.Router();
const { FormRepository } = require('../repositories');
const logger = require('../../../src/utils/logger');
/**
* Get all forms for the site
* GET /api/plugins/voting/forms
*/
router.get('/', async (req, res) => {
try {
const siteId = req.query.site_id || req.user?.siteId;
const formRepo = new FormRepository();
const filters = { site_id: siteId };
const options = {
orderBy: 'created_at',
orderDirection: 'desc'
};
const forms = await formRepo.findWithFilters(filters, options);
res.json({
success: true,
data: forms
});
} catch (error) {
logger.error('Failed to get forms:', error);
res.status(500).json({
success: false,
error: 'Failed to retrieve forms',
message: error.message
});
}
});
/**
* Get a specific form by ID
* GET /api/plugins/voting/forms/:id
*/
router.get('/:id', async (req, res) => {
try {
const { id } = req.params;
const formRepo = new FormRepository();
const form = await formRepo.findOne({ id });
if (!form) {
return res.status(404).json({
success: false,
error: 'Form not found'
});
}
res.json({
success: true,
data: form
});
} catch (error) {
logger.error('Failed to get form:', error);
res.status(500).json({
success: false,
error: 'Failed to retrieve form',
message: error.message
});
}
});
/**
* Create or update a form
* POST /api/plugins/voting/forms
*/
router.post('/', async (req, res) => {
try {
const formRepo = new FormRepository();
const formData = {
...req.body,
created_by: req.user?.id,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
const form = await formRepo.create(formData);
res.status(201).json({
success: true,
data: form,
message: 'Form created successfully'
});
} catch (error) {
logger.error('Failed to create form:', error);
res.status(500).json({
success: false,
error: 'Failed to create form',
message: error.message
});
}
});
/**
* Update a form
* PUT /api/plugins/voting/forms/:id
*/
router.put('/:id', async (req, res) => {
try {
const { id } = req.params;
const formRepo = new FormRepository();
const updateData = {
...req.body,
updated_at: new Date().toISOString()
};
const form = await formRepo.updateById(id, updateData);
if (!form) {
return res.status(404).json({
success: false,
error: 'Form not found'
});
}
res.json({
success: true,
data: form,
message: 'Form updated successfully'
});
} catch (error) {
logger.error('Failed to update form:', error);
res.status(500).json({
success: false,
error: 'Failed to update form',
message: error.message
});
}
});
/**
* Delete a form
* DELETE /api/plugins/voting/forms/:id
*/
router.delete('/:id', async (req, res) => {
try {
const { id } = req.params;
const formRepo = new FormRepository();
const deleted = await formRepo.deleteById(id);
if (!deleted) {
return res.status(404).json({
success: false,
error: 'Form not found'
});
}
res.json({
success: true,
message: 'Form deleted successfully'
});
} catch (error) {
logger.error('Failed to delete form:', error);
res.status(500).json({
success: false,
error: 'Failed to delete form',
message: error.message
});
}
});
module.exports = router;

261
routes/groups.js Normal file
View file

@ -0,0 +1,261 @@
const express = require('express');
const router = express.Router();
const { GroupRepository, GroupMemberRepository } = require('../repositories');
const logger = require('../../../src/utils/logger');
/**
* Get all groups for the site
* GET /api/plugins/voting/groups
*/
router.get('/', async (req, res) => {
try {
const siteId = req.query.site_id || req.user?.siteId;
const groupRepo = new GroupRepository();
const filters = { site_id: siteId };
const options = {
orderBy: 'created_at',
orderDirection: 'desc'
};
const groups = await groupRepo.findWithFilters(filters, options);
res.json({
success: true,
data: groups
});
} catch (error) {
logger.error('Failed to get groups:', error);
res.status(500).json({
success: false,
error: 'Failed to retrieve groups',
message: error.message
});
}
});
/**
* Get a specific group by ID
* GET /api/plugins/voting/groups/:id
*/
router.get('/:id', async (req, res) => {
try {
const { id } = req.params;
const groupRepo = new GroupRepository();
const group = await groupRepo.findOne({ id });
if (!group) {
return res.status(404).json({
success: false,
error: 'Group not found'
});
}
res.json({
success: true,
data: group
});
} catch (error) {
logger.error('Failed to get group:', error);
res.status(500).json({
success: false,
error: 'Failed to retrieve group',
message: error.message
});
}
});
/**
* Create a new group
* POST /api/plugins/voting/groups
*/
router.post('/', async (req, res) => {
try {
const groupRepo = new GroupRepository();
const groupData = {
...req.body,
created_by: req.user?.id,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
const group = await groupRepo.create(groupData);
res.status(201).json({
success: true,
data: group,
message: 'Group created successfully'
});
} catch (error) {
logger.error('Failed to create group:', error);
res.status(500).json({
success: false,
error: 'Failed to create group',
message: error.message
});
}
});
/**
* Update a group
* PUT /api/plugins/voting/groups/:id
*/
router.put('/:id', async (req, res) => {
try {
const { id } = req.params;
const groupRepo = new GroupRepository();
const updateData = {
...req.body,
updated_at: new Date().toISOString()
};
const group = await groupRepo.updateById(id, updateData);
if (!group) {
return res.status(404).json({
success: false,
error: 'Group not found'
});
}
res.json({
success: true,
data: group,
message: 'Group updated successfully'
});
} catch (error) {
logger.error('Failed to update group:', error);
res.status(500).json({
success: false,
error: 'Failed to update group',
message: error.message
});
}
});
/**
* Delete a group
* DELETE /api/plugins/voting/groups/:id
*/
router.delete('/:id', async (req, res) => {
try {
const { id } = req.params;
const groupRepo = new GroupRepository();
const deleted = await groupRepo.deleteById(id);
if (!deleted) {
return res.status(404).json({
success: false,
error: 'Group not found'
});
}
res.json({
success: true,
message: 'Group deleted successfully'
});
} catch (error) {
logger.error('Failed to delete group:', error);
res.status(500).json({
success: false,
error: 'Failed to delete group',
message: error.message
});
}
});
/**
* Add member to group
* POST /api/plugins/voting/groups/:id/members
*/
router.post('/:id/members', async (req, res) => {
try {
const { id } = req.params;
const { person_id } = req.body;
if (!person_id) {
return res.status(400).json({
success: false,
error: 'Missing required field: person_id'
});
}
const memberRepo = new GroupMemberRepository();
const member = await memberRepo.addMember(id, person_id);
res.status(201).json({
success: true,
data: member,
message: 'Member added successfully'
});
} catch (error) {
logger.error('Failed to add member:', error);
res.status(500).json({
success: false,
error: 'Failed to add member',
message: error.message
});
}
});
/**
* Remove member from group
* DELETE /api/plugins/voting/groups/:id/members/:personId
*/
router.delete('/:id/members/:personId', async (req, res) => {
try {
const { id, personId } = req.params;
const memberRepo = new GroupMemberRepository();
const deleted = await memberRepo.removeMember(id, personId);
if (!deleted) {
return res.status(404).json({
success: false,
error: 'Member not found'
});
}
res.json({
success: true,
message: 'Member removed successfully'
});
} catch (error) {
logger.error('Failed to remove member:', error);
res.status(500).json({
success: false,
error: 'Failed to remove member',
message: error.message
});
}
});
/**
* Get all members of a group
* GET /api/plugins/voting/groups/:id/members
*/
router.get('/:id/members', async (req, res) => {
try {
const { id } = req.params;
const memberRepo = new GroupMemberRepository();
const members = await memberRepo.getGroupMembers(id);
res.json({
success: true,
data: members
});
} catch (error) {
logger.error('Failed to get group members:', error);
res.status(500).json({
success: false,
error: 'Failed to retrieve group members',
message: error.message
});
}
});
module.exports = router;

58
routes/index.js Normal file
View file

@ -0,0 +1,58 @@
const express = require('express');
const router = express.Router();
const auth = require('../../../src/middleware/auth');
const {
checkPluginPermission,
checkPluginSubscription,
scopeToSite,
pluginRateLimit
} = require('../../../src/middleware/pluginAuth');
// Import plugin-specific route modules
const campaignsRoutes = require('./campaigns');
const formsRoutes = require('./forms');
const responsesRoutes = require('./responses');
const groupsRoutes = require('./groups');
// Mount plugin-specific routes
router.use('/campaigns', campaignsRoutes);
router.use('/forms', formsRoutes);
router.use('/responses', responsesRoutes);
router.use('/groups', groupsRoutes);
// Plugin health check endpoint
router.get('/health', (req, res) => {
res.json({
success: true,
plugin: 'voting',
version: '1.0.0',
status: 'healthy',
timestamp: new Date().toISOString()
});
});
// Plugin info endpoint
router.get('/info', (req, res) => {
res.json({
success: true,
plugin: {
name: 'voting',
displayName: 'Voting Plugin',
version: '1.0.0',
description: 'Comprehensive voting system for HOA board elections, decision-making, and community surveys',
permissions: [
'view_campaigns',
'create_campaigns',
'edit_campaigns',
'delete_campaigns',
'view_results',
'manage_forms',
'export_data',
'view_analytics'
]
}
});
});
module.exports = router;

124
routes/responses.js Normal file
View file

@ -0,0 +1,124 @@
const express = require('express');
const router = express.Router();
const { ResponseRepository, ResponseAnswerRepository, ResponseChangeLogRepository } = require('../repositories');
const logger = require('../../../src/utils/logger');
/**
* Get all responses for a campaign
* GET /api/plugins/voting/responses?campaign_id=xxx
*/
router.get('/', async (req, res) => {
try {
const { campaign_id, person_id } = req.query;
const responseRepo = new ResponseRepository();
const filters = {};
if (campaign_id) filters.campaign_id = campaign_id;
if (person_id) filters.person_id = person_id;
const options = {
orderBy: 'created_at',
orderDirection: 'desc'
};
const responses = await responseRepo.findWithFilters(filters, options);
res.json({
success: true,
data: responses
});
} catch (error) {
logger.error('Failed to get responses:', error);
res.status(500).json({
success: false,
error: 'Failed to retrieve responses',
message: error.message
});
}
});
/**
* Get a specific response by ID
* GET /api/plugins/voting/responses/:id
*/
router.get('/:id', async (req, res) => {
try {
const { id } = req.params;
const responseRepo = new ResponseRepository();
const response = await responseRepo.findOne({ id });
if (!response) {
return res.status(404).json({
success: false,
error: 'Response not found'
});
}
res.json({
success: true,
data: response
});
} catch (error) {
logger.error('Failed to get response:', error);
res.status(500).json({
success: false,
error: 'Failed to retrieve response',
message: error.message
});
}
});
/**
* Submit a response
* POST /api/plugins/voting/responses
*/
router.post('/', async (req, res) => {
try {
const responseRepo = new ResponseRepository();
const { campaign_id, person_id, answers } = req.body;
if (!campaign_id || !person_id) {
return res.status(400).json({
success: false,
error: 'Missing required fields: campaign_id and person_id'
});
}
// Get or create response
let response = await responseRepo.getOrCreate(campaign_id, person_id);
// Mark as answered
response = await responseRepo.markAnswered(response.id);
// Store response answers in response_answers table
if (answers && Array.isArray(answers)) {
const answerRepo = new ResponseAnswerRepository();
for (const answer of answers) {
await answerRepo.create({
response_id: response.id,
question_id: answer.question_id,
answer_value: answer.answer_value,
answer_options: answer.answer_options,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
});
}
}
res.status(201).json({
success: true,
data: response,
message: 'Response submitted successfully'
});
} catch (error) {
logger.error('Failed to submit response:', error);
res.status(500).json({
success: false,
error: 'Failed to submit response',
message: error.message
});
}
});
module.exports = router;

45
version.txt Normal file
View file

@ -0,0 +1,45 @@
# Voting Plugin Development Notes
This file tracks only recent development activities. For complete historical changelog, see `CHANGELOG.md`.
---
**Last Updated:** 2025-11-02
**Current Version:** 1.0.0
---
## Recent Changes (Current Development Cycle)
### 2025-11-02: Plugin Structure Enhancement (v1.0.0)
- **Enhanced plugin.json**: Added coreApiVersion, build configuration, frontend configuration, and dependencies
- **Core API Compatibility**: Declared compatibility with Core API v2.3.0+ (max v3.0.0)
- **Build Configuration**: Added build.exclude and build.include for packaging
- **Frontend Support**: Enabled frontend bundle configuration
- **Migration Support**: Added database migrations path configuration
- **Modified**: `plugin.json`
---
## Active Development Focus
### Core Features
- Campaign management (board elections, decisions, surveys)
- Form builder with SurveyJS integration
- Response collection and analytics
- Group management for custom recipients
- Results export (CSV/PDF)
---
## Next Steps
- Plugin separation and independent repository
- Build script for zip packaging
- Frontend bundle creation
- Migration scripts for database updates
---
**Note**: This plugin is ready for separation into independent repository and zip-based deployment.