commit c619e27370e1fe439d21699165e2a6d92333b889 Author: mmabdalla <101379618+mmabdalla@users.noreply.github.com> Date: Mon Nov 3 14:01:07 2025 +0200 Initial commit: Voting plugin v1.0.0 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f0a44dd --- /dev/null +++ b/CHANGELOG.md @@ -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`. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..c61da74 --- /dev/null +++ b/README.md @@ -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 + diff --git a/__tests__/helpers/testHelper.js b/__tests__/helpers/testHelper.js new file mode 100644 index 0000000..8153efe --- /dev/null +++ b/__tests__/helpers/testHelper.js @@ -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; + diff --git a/__tests__/repositories/CampaignRepository.test.js b/__tests__/repositories/CampaignRepository.test.js new file mode 100644 index 0000000..6e3701f --- /dev/null +++ b/__tests__/repositories/CampaignRepository.test.js @@ -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'); + }); + }); +}); + diff --git a/__tests__/routes/campaigns.test.js b/__tests__/routes/campaigns.test.js new file mode 100644 index 0000000..0165251 --- /dev/null +++ b/__tests__/routes/campaigns.test.js @@ -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); + }); + }); +}); + diff --git a/database/schema.sql b/database/schema.sql new file mode 100644 index 0000000..5d15d1c --- /dev/null +++ b/database/schema.sql @@ -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); + diff --git a/plugin.json b/plugin.json new file mode 100644 index 0000000..701aacd --- /dev/null +++ b/plugin.json @@ -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" + } +} + diff --git a/repositories/CampaignRepository.js b/repositories/CampaignRepository.js new file mode 100644 index 0000000..5d95e7c --- /dev/null +++ b/repositories/CampaignRepository.js @@ -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; + diff --git a/repositories/FormRepository.js b/repositories/FormRepository.js new file mode 100644 index 0000000..e5e9ad4 --- /dev/null +++ b/repositories/FormRepository.js @@ -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; + diff --git a/repositories/GroupMemberRepository.js b/repositories/GroupMemberRepository.js new file mode 100644 index 0000000..a3ee3b8 --- /dev/null +++ b/repositories/GroupMemberRepository.js @@ -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; + diff --git a/repositories/GroupRepository.js b/repositories/GroupRepository.js new file mode 100644 index 0000000..23741c4 --- /dev/null +++ b/repositories/GroupRepository.js @@ -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; + diff --git a/repositories/ResponseAnswerRepository.js b/repositories/ResponseAnswerRepository.js new file mode 100644 index 0000000..b8bdf5c --- /dev/null +++ b/repositories/ResponseAnswerRepository.js @@ -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; + diff --git a/repositories/ResponseChangeLogRepository.js b/repositories/ResponseChangeLogRepository.js new file mode 100644 index 0000000..a53ae07 --- /dev/null +++ b/repositories/ResponseChangeLogRepository.js @@ -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; + diff --git a/repositories/ResponseRepository.js b/repositories/ResponseRepository.js new file mode 100644 index 0000000..da11527 --- /dev/null +++ b/repositories/ResponseRepository.js @@ -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; + diff --git a/repositories/index.js b/repositories/index.js new file mode 100644 index 0000000..ef670d7 --- /dev/null +++ b/repositories/index.js @@ -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 +}; + diff --git a/routes/campaigns.js b/routes/campaigns.js new file mode 100644 index 0000000..00369c5 --- /dev/null +++ b/routes/campaigns.js @@ -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; + diff --git a/routes/forms.js b/routes/forms.js new file mode 100644 index 0000000..0b4c9ad --- /dev/null +++ b/routes/forms.js @@ -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; + diff --git a/routes/groups.js b/routes/groups.js new file mode 100644 index 0000000..32c404f --- /dev/null +++ b/routes/groups.js @@ -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; + diff --git a/routes/index.js b/routes/index.js new file mode 100644 index 0000000..43f2334 --- /dev/null +++ b/routes/index.js @@ -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; + diff --git a/routes/responses.js b/routes/responses.js new file mode 100644 index 0000000..97022d3 --- /dev/null +++ b/routes/responses.js @@ -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; + diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..de9c017 --- /dev/null +++ b/version.txt @@ -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. +