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