Initial commit: Voting plugin v1.0.0
This commit is contained in:
commit
c619e27370
21 changed files with 2371 additions and 0 deletions
44
CHANGELOG.md
Normal file
44
CHANGELOG.md
Normal 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
174
README.md
Normal 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
|
||||||
|
|
||||||
254
__tests__/helpers/testHelper.js
Normal file
254
__tests__/helpers/testHelper.js
Normal 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;
|
||||||
|
|
||||||
208
__tests__/repositories/CampaignRepository.test.js
Normal file
208
__tests__/repositories/CampaignRepository.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
208
__tests__/routes/campaigns.test.js
Normal file
208
__tests__/routes/campaigns.test.js
Normal 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
170
database/schema.sql
Normal 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
102
plugin.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
74
repositories/CampaignRepository.js
Normal file
74
repositories/CampaignRepository.js
Normal 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;
|
||||||
|
|
||||||
45
repositories/FormRepository.js
Normal file
45
repositories/FormRepository.js
Normal 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;
|
||||||
|
|
||||||
70
repositories/GroupMemberRepository.js
Normal file
70
repositories/GroupMemberRepository.js
Normal 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;
|
||||||
|
|
||||||
28
repositories/GroupRepository.js
Normal file
28
repositories/GroupRepository.js
Normal 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;
|
||||||
|
|
||||||
44
repositories/ResponseAnswerRepository.js
Normal file
44
repositories/ResponseAnswerRepository.js
Normal 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;
|
||||||
|
|
||||||
41
repositories/ResponseChangeLogRepository.js
Normal file
41
repositories/ResponseChangeLogRepository.js
Normal 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;
|
||||||
|
|
||||||
63
repositories/ResponseRepository.js
Normal file
63
repositories/ResponseRepository.js
Normal 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
18
repositories/index.js
Normal 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
170
routes/campaigns.js
Normal 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
170
routes/forms.js
Normal 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
261
routes/groups.js
Normal 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
58
routes/index.js
Normal 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
124
routes/responses.js
Normal 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
45
version.txt
Normal 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.
|
||||||
|
|
||||||
Loading…
Reference in a new issue