PSA/ee/docs/plans/customer-satisfaction-surveys.md
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

1703 lines
58 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Customer Satisfaction Survey System - Technical Implementation Plan
## Overview
### Current Status
- **Edition:** Community Edition (CE) feature available in all installations
- **Phase:** Phase 1 (Core Infrastructure) - In Progress
- **Completed:** Database schema, RLS policies, indexes, token service with tests
- **Next:** Server actions, event integration, email templates, UI components
### Goals
- Automate post-ticket CSAT feedback loops for MSP tenants inside Alga PSA.
- Centralize survey configuration, delivery, and analytics while maintaining tenant isolation.
- Reuse existing UI, email, and workflow foundations to minimize net-new surface area.
### Scope Snapshot
- **Touchpoints:** Database, server actions, event bus, email templates, UI surfaces (see Details for specs).
- **Edition:** Community Edition (CE) feature, available to all installations.
- **Standards:** Adhere to `docs/AI_coding_standards.md`, email templating conventions, and Citus multi-tenant rules.
### Phase Checklist
1. **Phase 1 Core Infrastructure** (Details §Implementation Phases > Phase 1)
- Database & Token Foundations
- Create migrations for templates/triggers/invitations/responses with tenant composite PKs and RLS (Details §Database Schema, §Migration Files).
- Add seed migration for default CSAT template with JSONB labels stored as strings (Details §Migration Files).
- Implement hashing + issuance helpers in `surveyTokenService` and validate against stored digests (Details §Token Service).
- Backend Actions & Entry Points
- Build `surveyActions.ts` CRUD using `createTenantKnex`/`withTransaction` patterns (Details §Server Actions).
- Add public submission + validation actions that run under `runWithTenant` and update invitations atomically (Details §Server Actions).
- Register event subscriber that listens to ticket closure events and invokes `sendSurveyInvitation` (Details §Event Bus Integration).
- Email Delivery & Workflow
- Register `SURVEY_TICKET_CLOSED` template variants (EN/FR/ES/DE/NL/IT) and document merge fields (Details §Email Integration).
- Implement invitation send pipeline that persists invites, queues Temporal workflow, and delegates to `TenantEmailService` (Details §Email Integration).
- Verify provider fallback + localization via unit tests under `server/src/lib/email/__tests__` (Details §Email Integration).
- UI Components
- Build survey settings tabs (templates/triggers) using `server/src/components/ui` components + translated copy (Details §UI Components > SurveySettings).
- Ship public response page with i18n + accessibility IDs (Details §UI Components > SurveyResponsePage).
- Add navigation integration to Settings menu (Details §UI Components > Navigation Integration).
2. **Phase 2 Reporting & Analytics** (Details §Implementation Phases > Phase 2)
- API Layer
- Implement stats + response listing endpoints with tenant-scoped filters and pagination (Details §API Implementation).
- Extend server actions to serve dashboard data and cache expensive aggregates where needed (Details §Server Actions > Response queries).
- UI Surfaces
- Build dashboard cards/charts leveraging existing components and formatters (Details §UI Components > SurveyDashboard).
- Embed response summaries on ticket + company detail views (Details §Integration with Existing Modules).
- Ensure filters persist via query params for shareable links (Details §UI Components > ResponseList).
- Observability & QA
- Add unit tests for CSAT calculations and trigger condition evaluation (Details §Testing Strategy).
- Extend manual checklist for analytics validation (Details §Testing Strategy > Manual).
3. **Phase 3 Enhancements** (Details §Implementation Phases > Phase 3)
- Alerting & Automation
- Emit `SURVEY_NEGATIVE_RESPONSE` events and hook into notification workflows (Details §Event Bus Integration > Negative Feedback).
- Optionally open follow-up tickets using existing automation patterns (Details §Implementation Phases > Phase 3).
- Advanced Features
- Implement CSV export + bulk trigger management (Details §Implementation Phases > Phase 3).
- Add survey preview, response time analytics, and mobile/responsive polish (Details §UI Components).
- Harden duplicate suppression + token expiry monitoring (Details §Server Actions, §Token Service).
### Critical Dependencies & Risks
- Multi-tenant schema requirements and RLS enforcement (Details §Database Schema).
- Email localization, provider routing, and template fallbacks (Details §Email Integration; `server/src/lib/email/README.md`).
- Event bus + Temporal workflow coordination (Details §Event Bus Integration).
- Secure token issuance/storage practices (Details §Token Service).
### Reference Documents
- `docs/AI_coding_standards.md`
- `server/src/lib/email/README.md`
- `docs/email-i18n-implementation-summary.md`
- `docs/inbound-email/README.md`
- This plans Details section for implementation specifics.
---
## Details
### System Overview
This plan outlines the technical implementation of a customer satisfaction (CSAT) survey system for Alga PSA, targeting MSPs with 5-10 employees and 50-100 customers. The system will automatically send surveys after ticket closure, collect responses, and provide reporting capabilities.
### Technical Architecture
### Core Components
1. **Database Layer** - Survey configuration, responses, and templates
2. **Event Integration** - Hook into existing event bus for ticket lifecycle events
3. **Email Integration** - Leverage existing email notification system
4. **API Layer** - Survey response submission and management
5. **UI Components** - Survey configuration, response viewing, and reporting
6. **Token Service** - Secure, unique, time-limited survey response links
### Data Flow
```
Ticket Closed → Event Bus → Survey Event Subscriber →
Email Service (with survey link) → Customer clicks rating →
API validates token → Store response → Display in UI/Reports
```
### Database Schema
### New Tables
#### `survey_templates`
Tenant-scoped survey templates with customizable rating scales and text. All new tables include `tenant UUID NOT NULL` in a composite primary key, enforce a foreign key to `tenants(tenant)`, and scope unique constraints/indexes by tenant to satisfy Citus distribution rules.
```sql
CREATE TABLE survey_templates (
template_id UUID NOT NULL DEFAULT gen_random_uuid(),
tenant UUID NOT NULL,
template_name VARCHAR(255) NOT NULL,
is_default BOOLEAN DEFAULT false,
rating_type VARCHAR(50) DEFAULT 'stars', -- 'stars', 'numbers', 'emojis'
rating_scale INTEGER DEFAULT 5, -- 3, 5, or 10
rating_labels JSONB DEFAULT '{}'::jsonb, -- {1: "Very Poor", 2: "Poor", ...}
prompt_text TEXT DEFAULT 'How would you rate your support experience?',
comment_prompt TEXT DEFAULT 'Additional comments (optional)',
thank_you_text TEXT DEFAULT 'Thank you for your feedback!',
enabled BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT survey_templates_pkey PRIMARY KEY (template_id, tenant),
CONSTRAINT survey_templates_tenant_fk FOREIGN KEY (tenant) REFERENCES tenants(tenant),
UNIQUE(tenant, template_name)
);
-- RLS policies
ALTER TABLE survey_templates ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON survey_templates USING (tenant = current_setting('app.current_tenant')::uuid);
```
#### `survey_triggers`
Configuration for when surveys are sent (e.g., ticket closed, project completed).
```sql
CREATE TABLE survey_triggers (
trigger_id UUID NOT NULL DEFAULT gen_random_uuid(),
tenant UUID NOT NULL,
template_id UUID NOT NULL,
trigger_type VARCHAR(50) NOT NULL, -- 'ticket_closed', 'project_completed'
trigger_conditions JSONB DEFAULT '{}'::jsonb, -- {board_id: [...], status_id: [...], priority: [...]}
enabled BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT survey_triggers_pkey PRIMARY KEY (trigger_id, tenant),
CONSTRAINT survey_triggers_tenant_fk FOREIGN KEY (tenant) REFERENCES tenants(tenant),
CONSTRAINT survey_triggers_template_fk FOREIGN KEY (template_id, tenant)
REFERENCES survey_templates(template_id, tenant)
ON DELETE CASCADE
);
-- RLS policies
ALTER TABLE survey_triggers ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON survey_triggers USING (tenant = current_setting('app.current_tenant')::uuid);
-- Indexes
CREATE INDEX idx_survey_triggers_tenant_type ON survey_triggers(tenant, trigger_type) WHERE enabled = true;
CREATE INDEX idx_survey_triggers_template ON survey_triggers(tenant, template_id);
```
#### `survey_responses`
Stores individual survey responses linked to tickets. Hash the survey token before persisting so plain tokens never touch the database.
```sql
CREATE TABLE survey_responses (
response_id UUID NOT NULL DEFAULT gen_random_uuid(),
tenant UUID NOT NULL,
ticket_id UUID NOT NULL,
client_id UUID,
contact_id UUID,
template_id UUID NOT NULL,
rating INTEGER NOT NULL, -- 1-5 (or based on scale)
comment TEXT,
survey_token_hash VARCHAR(255) NOT NULL,
token_expires_at TIMESTAMPTZ NOT NULL,
submitted_at TIMESTAMPTZ DEFAULT NOW(),
response_time_seconds INTEGER, -- Time from email sent to response
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT survey_responses_pkey PRIMARY KEY (response_id, tenant),
CONSTRAINT survey_responses_tenant_fk FOREIGN KEY (tenant) REFERENCES tenants(tenant),
CONSTRAINT survey_responses_template_fk FOREIGN KEY (template_id, tenant)
REFERENCES survey_templates(template_id, tenant),
CONSTRAINT survey_responses_ticket_fk FOREIGN KEY (ticket_id, tenant)
REFERENCES tickets(ticket_id, tenant)
ON DELETE CASCADE,
CONSTRAINT survey_responses_client_fk FOREIGN KEY (tenant, client_id)
REFERENCES clients(tenant, client_id),
CONSTRAINT survey_responses_contact_fk FOREIGN KEY (tenant, contact_id)
REFERENCES contacts(tenant, contact_name_id),
UNIQUE (tenant, survey_token_hash)
);
-- RLS policies
ALTER TABLE survey_responses ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON survey_responses USING (tenant = current_setting('app.current_tenant')::uuid);
-- Indexes
CREATE INDEX idx_survey_responses_tenant_ticket ON survey_responses(tenant, ticket_id);
CREATE INDEX idx_survey_responses_tenant_client ON survey_responses(tenant, client_id);
CREATE INDEX idx_survey_responses_tenant_submitted ON survey_responses(tenant, submitted_at DESC);
CREATE INDEX idx_survey_responses_token ON survey_responses(tenant, survey_token_hash) WHERE submitted_at IS NULL;
CREATE INDEX idx_survey_responses_rating ON survey_responses(tenant, rating);
```
#### `survey_invitations`
Track sent survey invitations (for analytics and preventing duplicates). Persist only the hashed token digest; the plain token is emailed to the respondent.
```sql
CREATE TABLE survey_invitations (
invitation_id UUID NOT NULL DEFAULT gen_random_uuid(),
tenant UUID NOT NULL,
ticket_id UUID NOT NULL,
client_id UUID,
contact_id UUID,
template_id UUID NOT NULL,
survey_token_hash VARCHAR(255) NOT NULL,
token_expires_at TIMESTAMPTZ NOT NULL,
sent_at TIMESTAMPTZ DEFAULT NOW(),
opened_at TIMESTAMPTZ, -- Track email opens via pixel
responded BOOLEAN DEFAULT false,
responded_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT survey_invitations_pkey PRIMARY KEY (invitation_id, tenant),
CONSTRAINT survey_invitations_tenant_fk FOREIGN KEY (tenant) REFERENCES tenants(tenant),
CONSTRAINT survey_invitations_template_fk FOREIGN KEY (template_id, tenant)
REFERENCES survey_templates(template_id, tenant),
CONSTRAINT survey_invitations_ticket_fk FOREIGN KEY (ticket_id, tenant)
REFERENCES tickets(ticket_id, tenant)
ON DELETE CASCADE,
CONSTRAINT survey_invitations_client_fk FOREIGN KEY (tenant, client_id)
REFERENCES clients(tenant, client_id),
CONSTRAINT survey_invitations_contact_fk FOREIGN KEY (tenant, contact_id)
REFERENCES contacts(tenant, contact_name_id),
UNIQUE (tenant, survey_token_hash)
);
-- RLS policies
ALTER TABLE survey_invitations ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON survey_invitations USING (tenant = current_setting('app.current_tenant')::uuid);
-- Indexes
CREATE INDEX idx_survey_invitations_tenant_ticket ON survey_invitations(tenant, ticket_id);
CREATE INDEX idx_survey_invitations_token ON survey_invitations(tenant, survey_token_hash);
CREATE INDEX idx_survey_invitations_sent ON survey_invitations(tenant, sent_at DESC);
```
### API Implementation
### New API Routes
#### `POST /api/surveys/respond` (Public - Token-based)
Submit a survey response without authentication.
**Request:**
```typescript
{
token: string;
rating: number;
comment?: string;
}
```
**Response:**
```typescript
{
success: boolean;
message: string;
response_id?: string;
}
```
**Implementation:**
- Validate token exists and hasn't expired
- Verify no response already submitted for this token
- Store response in `survey_responses`
- Update `survey_invitations.responded = true`
- Trigger `survey.negative_response` event if rating <= 2
- Return success/error
#### `GET /api/surveys/templates`
List survey templates for current tenant.
**Response:**
```typescript
{
templates: Array<{
template_id: string;
template_name: string;
is_default: boolean;
rating_type: string;
rating_scale: number;
enabled: boolean;
}>
}
```
#### `POST /api/surveys/templates`
Create new survey template.
**Request:**
```typescript
{
template_name: string;
rating_type: 'stars' | 'numbers' | 'emojis';
rating_scale: 3 | 5 | 10;
rating_labels?: Record<number, string>;
prompt_text?: string;
comment_prompt?: string;
thank_you_text?: string;
is_default?: boolean;
}
```
#### `PUT /api/surveys/templates/:id`
Update existing survey template.
#### `GET /api/surveys/triggers`
List configured survey triggers.
#### `POST /api/surveys/triggers`
Create new survey trigger.
**Request:**
```typescript
{
template_id: string;
trigger_type: 'ticket_closed' | 'project_completed';
trigger_conditions?: {
board_id?: string[];
status_id?: string[];
priority?: string[];
};
enabled?: boolean;
}
```
#### `GET /api/surveys/responses`
List survey responses with filtering.
**Query Parameters:**
- `ticket_id` - Filter by ticket
- `company_id` - Filter by company
- `rating` - Filter by rating (e.g., `rating=1,2` for negative)
- `start_date`, `end_date` - Date range
- `limit`, `offset` - Pagination
**Response:**
```typescript
{
responses: Array<{
response_id: string;
ticket_id: string;
ticket_number: string;
company_name: string;
contact_name: string;
rating: number;
comment: string | null;
submitted_at: string;
assigned_to: string; // Technician name
}>,
total: number;
}
```
#### `GET /api/surveys/stats`
Get aggregate statistics for dashboard.
**Query Parameters:**
- `start_date`, `end_date` - Date range
- `company_id` - Filter by company
- `user_id` - Filter by technician
**Response:**
```typescript
{
overall_csat: number; // Average rating
total_responses: number;
response_rate: number; // Percentage of invitations that got responses
rating_distribution: Record<number, number>; // {1: 5, 2: 3, 3: 10, ...}
trend: Array<{
date: string;
avg_rating: number;
response_count: number;
}>;
by_technician: Array<{
user_id: string;
user_name: string;
avg_rating: number;
response_count: number;
}>;
}
```
### Server Actions
### New Action Files
#### `server/src/lib/actions/surveyActions.ts`
```typescript
'use server';
import { getCurrentUser } from '@/lib/actions/user-actions/userActions';
import { withTransaction } from '@alga-psa/db';
import { createTenantKnex } from '@/lib/db';
// Template management
export async function getSurveyTemplates() {
const { knex, tenant } = await createTenantKnex();
return knex('survey_templates')
.where('tenant', tenant)
.orderBy('template_name');
}
export async function createSurveyTemplate(data: CreateTemplateInput) {
await getCurrentUser(); // ensure requester is authenticated
const { knex, tenant } = await createTenantKnex();
return withTransaction(knex, async (trx) => {
const [template] = await trx('survey_templates')
.insert({
tenant,
template_name: data.template_name,
rating_type: data.rating_type,
rating_scale: data.rating_scale,
rating_labels: JSON.stringify(data.rating_labels),
prompt_text: data.prompt_text,
comment_prompt: data.comment_prompt,
thank_you_text: data.thank_you_text,
enabled: data.enabled ?? true,
})
.returning('*');
return template;
});
}
export async function updateSurveyTemplate(id: string, data: UpdateTemplateInput) {
await getCurrentUser();
const { knex, tenant } = await createTenantKnex();
return withTransaction(knex, async (trx) => {
const updatePayload: Record<string, unknown> = {
updated_at: trx.fn.now(),
};
if (typeof data.template_name === 'string') {
updatePayload.template_name = data.template_name;
}
if (data.rating_type) {
updatePayload.rating_type = data.rating_type;
}
if (data.rating_scale) {
updatePayload.rating_scale = data.rating_scale;
}
if (data.rating_labels) {
updatePayload.rating_labels = JSON.stringify(data.rating_labels);
}
if (data.prompt_text) {
updatePayload.prompt_text = data.prompt_text;
}
if (data.comment_prompt) {
updatePayload.comment_prompt = data.comment_prompt;
}
if (data.thank_you_text) {
updatePayload.thank_you_text = data.thank_you_text;
}
if (typeof data.enabled === 'boolean') {
updatePayload.enabled = data.enabled;
}
return trx('survey_templates')
.where({ template_id: id, tenant })
.update(updatePayload);
});
}
export async function deleteSurveyTemplate(id: string) {
await getCurrentUser();
const { knex, tenant } = await createTenantKnex();
await knex('survey_templates')
.where({ template_id: id, tenant })
.del();
}
// Trigger management mirrors the same createTenantKnex + withTransaction pattern
// Response queries filter by tenant and include tenant in all joins
// Token generation (internal)
async function generateSurveyToken(): Promise<string> {
// Generate cryptographically secure random token
// Format: base64url encoded (URL-safe)
}
```
#### `server/src/lib/actions/surveyResponseActions.ts`
```typescript
import { createTenantKnex, runWithTenant } from '@/lib/db';
import { withTransaction } from '@alga-psa/db';
import { hashSurveyToken, resolveSurveyTenantFromToken } from '@/lib/actions/surveyTokenService';
// Public action - no auth required
export async function submitSurveyResponse(
token: string,
rating: number,
comment?: string
) {
const { tenant, invitation } = await resolveSurveyTenantFromToken(token);
await runWithTenant(tenant, async () => {
const { knex } = await createTenantKnex();
await withTransaction(knex, async (trx) => {
const [response] = await trx('survey_responses')
.insert({
tenant,
template_id: invitation.templateId,
ticket_id: invitation.ticketId,
client_id: invitation.clientId,
contact_id: invitation.contactId,
rating,
comment,
survey_token_hash: hashSurveyToken(token),
token_expires_at: invitation.tokenExpiresAt,
})
.returning('*');
await trx('survey_invitations')
.where({ tenant, invitation_id: invitation.invitationId })
.update({
responded: true,
responded_at: trx.fn.now(),
});
// Trigger events after transaction commits
return response;
});
});
}
export async function validateSurveyToken(token: string) {
const { tenant } = await resolveSurveyTenantFromToken(token);
return runWithTenant(tenant, async () => {
const { knex } = await createTenantKnex();
return knex('survey_invitations')
.select([
'survey_invitations.invitation_id',
'survey_invitations.tenant',
'survey_invitations.template_id',
'survey_templates.prompt_text',
'survey_templates.comment_prompt',
'survey_templates.thank_you_text',
'survey_templates.rating_type',
'survey_templates.rating_scale',
'survey_templates.rating_labels',
])
.innerJoin('survey_templates', function joinTemplates() {
this.on('survey_templates.template_id', '=', 'survey_invitations.template_id')
.andOn('survey_templates.tenant', '=', 'survey_invitations.tenant');
})
.where('survey_invitations.survey_token', hashSurveyToken(token))
.andWhere('survey_invitations.tenant', tenant)
.first();
});
}
```
#### `server/src/lib/actions/surveyTokenService.ts`
- `resolveSurveyTenantFromToken(token: string)` decodes the signed token payload, validates the HMAC, extracts the tenant identifier, and loads the invitation metadata in a single query (`survey_invitations` joined on `tenant`). Compare against the stored hashed token and fail fast with explicit `Error` messages when the token is missing, expired, or already used.
- `issueSurveyToken()` synchronously returns `{ plainToken, hashedToken }` where `plainToken` is a base64url string generated with `crypto.randomBytes(32)` and `hashedToken` is the SHA-256 digest.
- `hashSurveyToken(token: string)` returns a deterministic SHA-256 base64url digest used anywhere the token needs to be persisted.
- All helpers must call `runWithTenant(tenant)` before touching Knex so `createTenantKnex()` provides the correctly scoped connection.
```typescript
import { createHash, randomBytes } from 'crypto';
export function issueSurveyToken() {
const plainToken = randomBytes(32).toString('base64url');
return {
plainToken,
hashedToken: hashSurveyToken(plainToken),
} as const;
}
export function hashSurveyToken(token: string) {
return createHash('sha256').update(token, 'utf8').digest('base64url');
}
```
### Event Bus Integration
### New Event Types
Add to `server/src/lib/eventBus/events.ts`:
```typescript
// Survey invitation event
export const SURVEY_INVITATION_SENT = 'survey.invitation.sent';
export const surveyInvitationSentSchema = z.object({
tenant: z.string(),
invitation_id: z.string(),
ticket_id: z.string(),
company_id: z.string(),
contact_id: z.string(),
survey_token_hash: z.string(),
});
// Survey response event
export const SURVEY_RESPONSE_SUBMITTED = 'survey.response.submitted';
export const surveyResponseSubmittedSchema = z.object({
tenant: z.string(),
response_id: z.string(),
ticket_id: z.string(),
company_id: z.string(),
rating: z.number(),
has_comment: z.boolean(),
});
// Negative feedback alert
export const SURVEY_NEGATIVE_RESPONSE = 'survey.negative_response';
export const surveyNegativeResponseSchema = z.object({
tenant: z.string(),
response_id: z.string(),
ticket_id: z.string(),
ticket_number: z.string(),
company_id: z.string(),
company_name: z.string(),
contact_name: z.string(),
rating: z.number(),
comment: z.string().optional(),
assigned_to: z.string(), // user_id of technician
});
```
### New Event Subscriber
#### `server/src/lib/eventBus/subscribers/surveySubscriber.ts`
```typescript
import { eventBus } from '../index';
import { TICKET_CLOSED } from '../events';
import { getSurveyTriggers } from '../../actions/surveyActions';
import { sendSurveyInvitation } from '../../../services/surveyService';
export function registerSurveySubscriber() {
// Listen for ticket closed events
eventBus.subscribe(TICKET_CLOSED, async (event) => {
const { tenant, ticket_id, status_id, board_id, company_id, contact_id } = event;
// Check if any triggers match
const triggers = await getSurveyTriggers();
const matchingTrigger = triggers.find(trigger => {
if (!trigger.enabled) return false;
if (trigger.trigger_type !== 'ticket_closed') return false;
// Check conditions
const conditions = trigger.trigger_conditions;
if (conditions?.board_id && !conditions.board_id.includes(board_id)) return false;
if (conditions?.status_id && !conditions.status_id.includes(status_id)) return false;
return true;
});
if (matchingTrigger) {
await sendSurveyInvitation({
tenant,
ticket_id,
company_id,
contact_id,
template_id: matchingTrigger.template_id,
});
}
});
// Listen for negative survey responses
eventBus.subscribe(SURVEY_NEGATIVE_RESPONSE, async (event) => {
// Send alert to manager/technician
// Optionally create follow-up ticket
// Implementation in Phase 2
});
}
```
Register in `server/src/lib/eventBus/index.ts`:
```typescript
import { registerSurveySubscriber } from './subscribers/surveySubscriber';
// In initialization
registerSurveySubscriber();
```
### Email Integration
### New Email Template
#### Template registration
- Add a `SURVEY_TICKET_CLOSED` row to `system_email_templates` (category `Surveys`, subtype `Ticket Closed`). Provide localized subject/body variants for the supported locales (EN, FR, ES, DE, NL, IT). Follow the existing handlebars-style placeholder naming (`{{tenant_name}}`, `{{ticket_number}}`, `{{survey_url}}`, etc.).
- Provide a default HTML version (with inline styles that match `/server/src/lib/email/templates`) and a text fallback. Keep the markup minimal, delegate iconography to Unicode stars, and embed the hidden reply token partial so threading still works.
- No tenant-specific copies are required at launch, but tenants should be able to override via `tenant_email_templates`; document the required merge tags so CS can assist tenants later.
- Confirm the template works with the default fallback chain (tenant locale → tenant en → system locale → system en) by adding tests under `server/src/lib/email/__tests__`.
Template recommendations:
- Above the fold summary: ticket number, subject, technician, closed time.
- Primary call-to-action: five star-style buttons (links to `https://{domain}/surveys/respond/{{survey_token}}?rating={{rating}}`).
- Secondary CTA: full survey link + short note about optional comments.
- Footer: branding partial + unsubscribe per existing transactional email policy.
- Reference `/docs/email-i18n-implementation-summary.md` for language key naming and `/server/src/lib/email/templates/partials` for reusable header/footer fragments.
### Survey Service
#### `server/src/services/surveyService.ts`
```typescript
import { TenantEmailService } from '@/lib/email/services/TenantEmailService';
import { issueSurveyToken } from '@/lib/actions/surveyTokenService';
export async function sendSurveyInvitation({...}) {
// 1. Issue plain + hashed tokens (stored hashed)
// 2. Persist invitation + invitation audit inside transaction
// 3. Resolve ticket/contact metadata for template variables
// 4. Delegate email send to TenantEmailService with template_code SURVEY_TICKET_CLOSED
// - Pass requested locale (contact preferred language → tenant default → en)
// - Provide merge fields: tenant_name, ticket_subject, technician_name, survey_url, rating_links[]
// 5. Publish SURVEY_INVITATION_SENT after successful enqueue
}
```
Implementation notes:
- Use `TenantEmailService.sendTransactional` so the existing provider auto-selection, language fallback, and rate limiting are preserved.
- Build rating links in code (array of `{ rating: number, url: string }`) and let the template iterate with partials if needed; keep HTML generation out of the plan.
- Queue sends through the existing Temporal workflow used by other ticket notifications (follow the pattern in `ticketNotificationService`).
- `getTicketDetailsForSurvey` and `getSurveyTemplate` remain thin data access helpers that filter by tenant and run inside the transaction that logs invitations.
- Document the merge fields and translation keys in Confluence so future tenant-specific overrides stay aligned with the system templates.
- Keep this plan at the integration level—actual email/provider plumbing continues to live inside `/server/src/lib/email/services`, and we reuse that infrastructure rather than reinventing it here.
### UI Components
### Directory Structure
```
server/src/components/
surveys/
SurveySettings.tsx # Main settings page (templates + triggers)
templates/
TemplateList.tsx # List all templates
TemplateForm.tsx # Create/edit template
TemplatePreview.tsx # Preview survey appearance
triggers/
TriggerList.tsx # List all triggers
TriggerForm.tsx # Create/edit trigger
responses/
ResponseList.tsx # List all responses
ResponseDetail.tsx # Individual response view
ResponseFilters.tsx # Filter controls
dashboard/
SurveyDashboard.tsx # Main reporting dashboard
CSATMetric.tsx # Overall CSAT score display
RatingDistribution.tsx # Chart showing rating breakdown
TrendChart.tsx # Time series of CSAT
TechnicianLeaderboard.tsx # Performance by technician
public/
SurveyResponsePage.tsx # Public survey submission page
```
### Key Components
#### `SurveySettings.tsx`
Main settings page accessible from Settings menu.
All buttons, inputs, selects, and tabs receive stable kebab-case `id` attributes so the reflection system can target them (e.g., `survey-settings-tabs`, `survey-response-submit-button`). Reuse the existing UI primitives from `server/src/components/ui` (`Card`, `Button`, `CustomTabs`, etc.) to remain consistent with design standards.
```typescript
'use client';
import { useTranslation } from '@/lib/i18n/client';
import { Card } from '@/components/ui/Card';
import { CustomTabs } from '@/components/ui/CustomTabs';
import { TemplateList } from './templates/TemplateList';
import { TriggerList } from './triggers/TriggerList';
export default function SurveySettings() {
const { t } = useTranslation('common');
return (
<Card className="space-y-6 p-6">
<header className="space-y-1">
<h1 id="survey-settings-heading" className="text-2xl font-semibold tracking-tight">
{t('surveys.settings.title')}
</h1>
<p className="text-sm text-muted-foreground">
{t('surveys.settings.subtitle')}
</p>
</header>
<CustomTabs
id="survey-settings-tabs"
defaultValue="templates"
tabs={[
{
id: 'survey-template-tab',
value: 'templates',
label: t('surveys.settings.tabs.templates'),
content: (
<section id="survey-template-panel">
<TemplateList />
</section>
),
},
{
id: 'survey-trigger-tab',
value: 'triggers',
label: t('surveys.settings.tabs.triggers'),
content: (
<section id="survey-trigger-panel">
<TriggerList />
</section>
),
},
]}
/>
</Card>
);
}
```
#### `SurveyDashboard.tsx`
Main reporting dashboard (could be standalone page or integrated into existing Reports section).
```typescript
'use client';
import { useEffect, useState } from 'react';
import { useTranslation, useFormatters } from '@/lib/i18n/client';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { CustomSelect } from '@/components/ui/CustomSelect';
import { DateRangePicker } from '@/components/ui/DateRangePicker';
import { getSurveyStats } from '@/lib/actions/surveyActions';
import { CSATMetric } from './CSATMetric';
import { RatingDistribution } from './RatingDistribution';
import { TrendChart } from './TrendChart';
import { ResponseList } from '../responses/ResponseList';
import { TechnicianLeaderboard } from './TechnicianLeaderboard';
export default function SurveyDashboard() {
const { t } = useTranslation('common');
const { formatNumber, formatPercent } = useFormatters();
const [stats, setStats] = useState<SurveyStats | null>(null);
const [filters, setFilters] = useState<SurveyFilters>({
start_date: last30Days(),
end_date: today(),
company_id: 'all',
technician_id: 'all',
});
useEffect(() => {
void loadStats();
}, [filters]);
async function loadStats() {
const data = await getSurveyStats(filters);
setStats(data);
}
return (
<section className="space-y-6" aria-live="polite">
<header className="space-y-1">
<h1 id="survey-dashboard-heading" className="text-2xl font-semibold tracking-tight">
{t('surveys.dashboard.title')}
</h1>
<p className="text-sm text-muted-foreground">
{t('surveys.dashboard.subtitle')}
</p>
</header>
<Card className="p-4" aria-labelledby="survey-dashboard-filters-heading">
<div className="flex items-center justify-between gap-4 flex-wrap">
<DateRangePicker
id="survey-dashboard-date-range"
value={{ start: filters.start_date, end: filters.end_date }}
onChange={(range) =>
setFilters((prev) => ({
...prev,
start_date: range.start,
end_date: range.end,
}))
}
label={t('surveys.dashboard.filters.dateRange')}
/>
<CustomSelect
id="survey-dashboard-company-filter"
label={t('surveys.dashboard.filters.company')}
value={filters.company_id}
onValueChange={(companyId) =>
setFilters((prev) => ({ ...prev, company_id: companyId }))
}
options={companyOptions}
/>
<CustomSelect
id="survey-dashboard-technician-filter"
label={t('surveys.dashboard.filters.technician')}
value={filters.technician_id}
onValueChange={(technicianId) =>
setFilters((prev) => ({ ...prev, technician_id: technicianId }))
}
options={technicianOptions}
/>
<Button
id="survey-dashboard-refresh-button"
variant="secondary"
onClick={() => void loadStats()}
>
{t('actions.refresh')}
</Button>
</div>
</Card>
<section
id="survey-dashboard-summary"
className="grid gap-4 md:grid-cols-3"
aria-labelledby="survey-dashboard-summary-heading"
>
<Card className="p-4">
<h2 id="survey-dashboard-summary-heading" className="text-sm font-medium text-muted-foreground">
{t('surveys.dashboard.summary.title')}
</h2>
<CSATMetric id="survey-dashboard-csat" value={stats?.overall_csat ?? 0} />
</Card>
<MetricCard
id="survey-dashboard-total-responses"
label={t('surveys.dashboard.summary.totalResponses')}
value={formatNumber(stats?.total_responses ?? 0)}
/>
<MetricCard
id="survey-dashboard-response-rate"
label={t('surveys.dashboard.summary.responseRate')}
value={formatPercent((stats?.response_rate ?? 0) / 100)}
/>
</section>
<section className="grid gap-4 md:grid-cols-2">
<RatingDistribution
id="survey-dashboard-rating-distribution"
data={stats?.rating_distribution ?? {}}
/>
<TrendChart
id="survey-dashboard-trend-chart"
data={stats?.trend ?? []}
/>
</section>
<TechnicianLeaderboard
id="survey-dashboard-technician-leaderboard"
data={stats?.by_technician ?? []}
/>
<ResponseList
id="survey-dashboard-response-list"
filters={filters}
/>
</section>
);
}
```
`companyOptions` and `technicianOptions` come from tenant-scoped queries (via server actions) and must include an initial `all` option so the filter IDs remain stable for the reflection system. Ensure each option object supplies a unique `id` field to power `CustomSelect`.
#### `SurveyResponsePage.tsx`
Public page for survey submission (no authentication).
```typescript
'use client';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { useTranslation } from '@/lib/i18n/client';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Label } from '@/components/ui/Label';
import { TextArea } from '@/components/ui/TextArea';
import { LoadingIndicator } from '@/components/ui/LoadingIndicator';
import { validateSurveyToken, submitSurveyResponse } from '@/lib/actions/surveyResponseActions';
export default function SurveyResponsePage() {
const { t } = useTranslation('clientPortal');
const params = useParams<{ token: string }>();
const [isLoading, setIsLoading] = useState(true);
const [survey, setSurvey] = useState<SurveyInvitationView | null>(null);
const [rating, setRating] = useState<number | null>(null);
const [comment, setComment] = useState('');
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void loadSurvey();
}, [params.token]);
async function loadSurvey() {
setIsLoading(true);
setError(null);
try {
const data = await validateSurveyToken(params.token);
if (!data) {
setError(t('surveys.response.errors.invalidToken'));
return;
}
setSurvey(data);
} catch (err) {
setError(t('surveys.response.errors.generic'));
} finally {
setIsLoading(false);
}
}
async function handleSubmit() {
if (!rating || !survey) {
return;
}
await submitSurveyResponse(params.token, rating, comment.trim());
setSubmitted(true);
}
if (isLoading) {
return (
<div className="flex h-full items-center justify-center" aria-busy="true">
<LoadingIndicator
id="survey-response-loading-indicator"
layout="stacked"
text={t('surveys.response.loading')}
/>
</div>
);
}
if (submitted) {
return (
<ThankYouMessage
id="survey-response-thank-you"
text={survey?.thank_you_text ?? t('surveys.response.thankYouFallback')}
/>
);
}
if (error) {
return (
<Card className="mx-auto max-w-2xl p-6 text-center" role="alert" id="survey-response-error">
<p className="text-sm text-destructive">{error}</p>
<Button
id="survey-response-retry-button"
className="mt-4"
variant="secondary"
onClick={() => void loadSurvey()}
>
{t('actions.retry')}
</Button>
</Card>
);
}
return (
<Card className="mx-auto max-w-2xl space-y-6 p-6">
<header className="space-y-1">
<h1 id="survey-response-heading" className="text-2xl font-semibold tracking-tight">
{survey?.prompt_text}
</h1>
<p className="text-sm text-muted-foreground">
{t('surveys.response.subtitle', { company: survey?.company_name })}
</p>
</header>
<TicketSummary id="survey-response-ticket-summary" ticket={survey?.ticket} />
<RatingSelector
id="survey-response-rating-selector"
type={survey?.rating_type}
scale={survey?.rating_scale}
labels={survey?.rating_labels}
value={rating}
onChange={setRating}
/>
<div className="space-y-2">
<Label htmlFor="survey-response-comment-field">
{survey?.comment_prompt}
</Label>
<TextArea
id="survey-response-comment-field"
value={comment}
onChange={(event) => setComment(event.target.value)}
rows={4}
/>
</div>
<Button
id="survey-response-submit-button"
onClick={() => void handleSubmit()}
disabled={!rating}
>
{t('surveys.response.submit')}
</Button>
</Card>
);
}
```
### Navigation Integration
Add to Settings menu:
```typescript
// In settings navigation
{
id: 'settings-surveys-nav-item',
label: t('navigation.settings.surveys'),
href: '/msp/settings/surveys',
icon: MessageSquareIcon,
}
```
### Localization Requirements
Add the following keys to `server/public/locales/{locale}/common.json`:
- `navigation.settings.surveys`
- `navigation.primary.surveys`
- `surveys.settings.title`
- `surveys.settings.subtitle`
- `surveys.settings.tabs.templates`
- `surveys.settings.tabs.triggers`
- `surveys.dashboard.title`
- `surveys.dashboard.subtitle`
- `surveys.dashboard.filters.dateRange`
- `surveys.dashboard.filters.company`
- `surveys.dashboard.filters.technician`
- `surveys.dashboard.summary.title`
- `surveys.dashboard.summary.totalResponses`
- `surveys.dashboard.summary.responseRate`
- `actions.refresh` (reuse existing key if it already exists)
- `actions.retry` (reuse existing key if it already exists)
Add the following keys to `server/public/locales/{locale}/clientPortal.json` for the public survey form:
- `surveys.response.loading`
- `surveys.response.errors.invalidToken`
- `surveys.response.errors.generic`
- `surveys.response.subtitle`
- `surveys.response.thankYouFallback`
- `surveys.response.submit`
Add to main navigation (optional - could just be in Reports):
```typescript
// In main navigation
{
id: 'primary-surveys-nav-item',
label: t('navigation.primary.surveys'),
href: '/msp/surveys',
icon: StarIcon,
}
```
### Integration with Existing Modules
### Ticket Detail Page
Add survey response display to ticket detail view:
```typescript
// In TicketDetail.tsx
import { SurveyResponseCard } from '@/components/surveys/SurveyResponseCard';
// In ticket detail
{ticket.survey_response && (
<SurveyResponseCard response={ticket.survey_response} />
)}
```
### Company View
Show aggregate CSAT score on company detail page:
```typescript
// In CompanyDetail.tsx
import { CompanyCSATSummary } from '@/components/surveys/CompanyCSATSummary';
<CompanyCSATSummary company_id={company.company_id} />
```
### Migration Files
### Phase 1 Migrations
#### `server/migrations/YYYYMMDDHHMMSS_create_survey_tables.cjs`
```javascript
exports.up = async function up(knex) {
await knex.schema.createTable('survey_templates', (table) => {
table.uuid('template_id').notNullable().defaultTo(knex.raw('gen_random_uuid()'));
table.uuid('tenant').notNullable();
table.string('template_name').notNullable();
table.boolean('is_default').notNullable().defaultTo(false);
table.string('rating_type', 50).notNullable().defaultTo('stars');
table.integer('rating_scale').notNullable().defaultTo(5);
table.jsonb('rating_labels').notNullable().defaultTo(knex.raw("'{}'::jsonb"));
table.text('prompt_text').notNullable().defaultTo('How would you rate your support experience?');
table.text('comment_prompt').notNullable().defaultTo('Additional comments (optional)');
table.text('thank_you_text').notNullable().defaultTo('Thank you for your feedback!');
table.boolean('enabled').notNullable().defaultTo(true);
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.primary(['template_id', 'tenant']);
table
.foreign('tenant')
.references('tenant')
.inTable('tenants');
table.unique(['tenant', 'template_name']);
});
await knex.schema.createTable('survey_triggers', (table) => {
table.uuid('trigger_id').notNullable().defaultTo(knex.raw('gen_random_uuid()'));
table.uuid('tenant').notNullable();
table.uuid('template_id').notNullable();
table.string('trigger_type', 50).notNullable();
table.jsonb('trigger_conditions').notNullable().defaultTo(knex.raw("'{}'::jsonb"));
table.boolean('enabled').notNullable().defaultTo(true);
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.primary(['trigger_id', 'tenant']);
table
.foreign(['template_id', 'tenant'])
.references(['template_id', 'tenant'])
.inTable('survey_templates')
.onDelete('CASCADE');
});
await knex.schema.createTable('survey_invitations', (table) => {
table.uuid('invitation_id').notNullable().defaultTo(knex.raw('gen_random_uuid()'));
table.uuid('tenant').notNullable();
table.uuid('ticket_id').notNullable();
table.uuid('client_id');
table.uuid('contact_id');
table.uuid('template_id').notNullable();
table.string('survey_token_hash', 255).notNullable();
table.timestamp('token_expires_at', { useTz: true }).notNullable();
table.timestamp('sent_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.timestamp('opened_at', { useTz: true });
table.boolean('responded').notNullable().defaultTo(false);
table.timestamp('responded_at', { useTz: true });
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.primary(['invitation_id', 'tenant']);
table.unique(['tenant', 'survey_token_hash']);
table
.foreign(['template_id', 'tenant'])
.references(['template_id', 'tenant'])
.inTable('survey_templates');
table
.foreign(['ticket_id', 'tenant'])
.references(['ticket_id', 'tenant'])
.inTable('tickets')
.onDelete('CASCADE');
table
.foreign(['tenant', 'client_id'])
.references(['tenant', 'client_id'])
.inTable('clients');
table
.foreign(['tenant', 'contact_id'])
.references(['tenant', 'contact_name_id'])
.inTable('contacts');
});
await knex.schema.createTable('survey_responses', (table) => {
table.uuid('response_id').notNullable().defaultTo(knex.raw('gen_random_uuid()'));
table.uuid('tenant').notNullable();
table.uuid('ticket_id').notNullable();
table.uuid('client_id');
table.uuid('contact_id');
table.uuid('template_id').notNullable();
table.integer('rating').notNullable();
table.text('comment');
table.string('survey_token_hash', 255).notNullable();
table.timestamp('token_expires_at', { useTz: true }).notNullable();
table.timestamp('submitted_at', { useTz: true }).defaultTo(knex.fn.now());
table.integer('response_time_seconds');
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.primary(['response_id', 'tenant']);
table.unique(['tenant', 'survey_token_hash']);
table
.foreign(['template_id', 'tenant'])
.references(['template_id', 'tenant'])
.inTable('survey_templates');
table
.foreign(['ticket_id', 'tenant'])
.references(['ticket_id', 'tenant'])
.inTable('tickets')
.onDelete('CASCADE');
table
.foreign(['tenant', 'client_id'])
.references(['tenant', 'client_id'])
.inTable('clients');
table
.foreign(['tenant', 'contact_id'])
.references(['tenant', 'contact_name_id'])
.inTable('contacts');
});
await knex.raw(`
ALTER TABLE survey_templates ENABLE ROW LEVEL SECURITY;
ALTER TABLE survey_triggers ENABLE ROW LEVEL SECURITY;
ALTER TABLE survey_invitations ENABLE ROW LEVEL SECURITY;
ALTER TABLE survey_responses ENABLE ROW LEVEL SECURITY;
`);
await knex.raw(`
CREATE POLICY tenant_isolation ON survey_templates USING (tenant = current_setting('app.current_tenant')::uuid);
CREATE POLICY tenant_isolation ON survey_triggers USING (tenant = current_setting('app.current_tenant')::uuid);
CREATE POLICY tenant_isolation ON survey_invitations USING (tenant = current_setting('app.current_tenant')::uuid);
CREATE POLICY tenant_isolation ON survey_responses USING (tenant = current_setting('app.current_tenant')::uuid);
`);
await knex.raw(`
CREATE INDEX idx_survey_triggers_tenant_type
ON survey_triggers (tenant, trigger_type)
WHERE enabled = true;
`);
await knex.raw(`
CREATE INDEX idx_survey_triggers_template
ON survey_triggers (tenant, template_id);
`);
await knex.raw(`
CREATE INDEX idx_survey_invitations_tenant_ticket
ON survey_invitations (tenant, ticket_id);
`);
await knex.raw(`
CREATE INDEX idx_survey_invitations_token
ON survey_invitations (tenant, survey_token_hash);
`);
await knex.raw(`
CREATE INDEX idx_survey_invitations_sent
ON survey_invitations (tenant, sent_at);
`);
await knex.raw(`
CREATE INDEX idx_survey_responses_tenant_ticket
ON survey_responses (tenant, ticket_id);
`);
await knex.raw(`
CREATE INDEX idx_survey_responses_tenant_client
ON survey_responses (tenant, client_id);
`);
await knex.raw(`
CREATE INDEX idx_survey_responses_tenant_submitted
ON survey_responses (tenant, submitted_at);
`);
await knex.raw(`
CREATE INDEX idx_survey_responses_token
ON survey_responses (tenant, survey_token_hash)
WHERE submitted_at IS NULL;
`);
await knex.raw(`
CREATE INDEX idx_survey_responses_rating
ON survey_responses (tenant, rating);
`);
};
exports.down = async function down(knex) {
await knex.schema.dropTableIfExists('survey_responses');
await knex.schema.dropTableIfExists('survey_invitations');
await knex.schema.dropTableIfExists('survey_triggers');
await knex.schema.dropTableIfExists('survey_templates');
};
```
#### `server/migrations/YYYYMMDDHHMMSS_add_default_survey_template.cjs`
```javascript
exports.up = async function(knex) {
// Insert default survey template for each existing tenant
const tenants = await knex('tenants').select('tenant');
for (const { tenant } of tenants) {
await knex('survey_templates').insert({
tenant,
template_name: 'Default CSAT Survey',
is_default: true,
rating_type: 'stars',
rating_scale: 5,
rating_labels: JSON.stringify({
1: 'Very Poor',
2: 'Poor',
3: 'Okay',
4: 'Good',
5: 'Excellent',
}),
prompt_text: 'How would you rate your support experience?',
comment_prompt: 'Additional comments (optional)',
thank_you_text: 'Thank you for your feedback!',
enabled: true,
});
}
};
```
#### `server/migrations/YYYYMMDDHHMMSS_add_survey_email_template.cjs`
```javascript
exports.up = function(knex) {
return knex('system_email_templates').insert({
template_code: 'SURVEY_TICKET_CLOSED',
category: 'Surveys',
subtype: 'Ticket Closed',
name: 'Customer Satisfaction Survey - Ticket Closed',
description: 'Survey sent to customer after ticket is closed',
subject_template: 'How was your support experience? (Ticket #{{ticket_number}})',
html_template: `<!-- Full HTML template -->`,
text_template: `<!-- Plain text version -->`,
});
};
```
### TypeScript Types & Interfaces
### `server/src/types/survey.ts`
```typescript
export type RatingType = 'stars' | 'numbers' | 'emojis';
export type RatingScale = 3 | 5 | 10;
export type TriggerType = 'ticket_closed' | 'project_completed';
export interface SurveyTemplate {
template_id: string;
tenant: string;
template_name: string;
is_default: boolean;
rating_type: RatingType;
rating_scale: RatingScale;
rating_labels: Record<number, string>;
prompt_text: string;
comment_prompt: string;
thank_you_text: string;
enabled: boolean;
created_at: Date;
updated_at: Date;
}
export interface SurveyTrigger {
trigger_id: string;
tenant: string;
template_id: string;
trigger_type: TriggerType;
trigger_conditions?: {
board_id?: string[];
status_id?: string[];
priority?: string[];
};
enabled: boolean;
created_at: Date;
updated_at: Date;
}
export interface SurveyInvitation {
invitation_id: string;
tenant: string;
ticket_id: string;
company_id: string;
contact_id: string;
template_id: string;
survey_token: string; // hashed digest
token_expires_at: Date;
sent_at: Date;
opened_at?: Date;
responded: boolean;
responded_at?: Date;
created_at: Date;
}
export interface SurveyResponse {
response_id: string;
tenant: string;
ticket_id: string;
company_id: string;
contact_id: string;
template_id: string;
rating: number;
comment?: string;
survey_token: string; // hashed digest
token_expires_at: Date;
submitted_at: Date;
response_time_seconds?: number;
created_at: Date;
}
export interface SurveyStats {
overall_csat: number;
total_responses: number;
response_rate: number;
rating_distribution: Record<number, number>;
trend: Array<{
date: string;
avg_rating: number;
response_count: number;
}>;
by_technician: Array<{
user_id: string;
user_name: string;
avg_rating: number;
response_count: number;
}>;
}
export interface ResponseFilters {
ticket_id?: string;
company_id?: string;
rating?: number[];
start_date?: Date;
end_date?: Date;
limit?: number;
offset?: number;
}
export interface StatsFilters {
start_date?: Date;
end_date?: Date;
company_id?: string;
user_id?: string;
}
```
### Implementation Phases
### Phase 1: Core Infrastructure (MVP)
**Estimated: 3-5 days**
**Status: In Progress** (Database, Token Service, Server Actions, Services, Email templates, and UI ✅ Complete)
1. **Database Setup** ✅ COMPLETE
- [x] Create migration files for all tables with proper tenant isolation
- [x] Add RLS policies (note: currently disabled via migration `20251111120000_disable_rls_on_survey_tables.cjs`)
- [x] Create indexes for efficient querying
- [x] Add foreign keys with Citus multi-tenancy rules
2. **Token Service** ✅ COMPLETE
- [x] Implement `surveyTokenService.ts` with secure token generation and validation
- [x] Unit tests for token hashing and issuance
- [x] Integration tests with real database
3. **Server Actions** ✅ COMPLETE
- [x] Implement `surveyActions.ts` (CRUD for templates/triggers via server actions)
- [x] Implement `surveyResponseActions.ts` (public response submission via server action)
- [x] Add event types to `events.ts` (SURVEY_INVITATION_SENT, SURVEY_RESPONSE_SUBMITTED, SURVEY_NEGATIVE_RESPONSE)
4. **Services & Integration** ✅ COMPLETE
- [x] Create `surveyService.ts` (email sending logic via server action)
- [x] Create `surveySubscriber.ts` (listen to ticket closure events)
- [x] Register subscriber in event bus initialization
5. **Email Integration** ✅ COMPLETE
- [x] Register `SURVEY_TICKET_CLOSED` template in `system_email_templates`
- [x] Design HTML email template with rating buttons
- [x] Implement token-based one-click rating links
- [x] Add email provider tests for localization and fallbacks
6. **UI Components** ✅ COMPLETE
- [x] Build `SurveySettings.tsx` (templates + triggers management)
- [x] Build `TemplateList.tsx` + `TemplateForm.tsx`
- [x] Build `TriggerList.tsx` + `TriggerForm.tsx`
- [x] Build `SurveyResponsePage.tsx` (public survey page)
- [x] Add navigation integration to Settings menu
7. **API Routes** ⏳ PENDING (optional for external integrations)
- [ ] `POST /api/surveys/respond` (public, wraps surveyResponseActions)
- [ ] `GET /api/surveys/templates` (wraps surveyActions)
- [ ] `POST /api/surveys/templates` (wraps surveyActions)
- [ ] `GET /api/surveys/triggers` (wraps surveyActions)
- [ ] `POST /api/surveys/triggers` (wraps surveyActions)
**Deliverables:**
- Surveys can be configured to trigger on ticket closure
- Customers receive email with one-click rating
- Responses are stored in database
- Admin UI to manage templates and triggers
### Phase 2: Reporting & Analytics
**Estimated: 2-3 days**
1. **API Routes**
- `GET /api/surveys/responses` (with filtering)
- `GET /api/surveys/stats` (aggregated metrics)
2. **Dashboard Components**
- `SurveyDashboard.tsx` (main reporting page)
- `CSATMetric.tsx` (overall score display)
- `RatingDistribution.tsx` (chart)
- `TrendChart.tsx` (time series)
- `TechnicianLeaderboard.tsx` (performance breakdown)
- `ResponseList.tsx` (detailed response list)
3. **Integrations**
- Add survey response card to ticket detail page
- Add CSAT summary to company detail page
**Deliverables:**
- Comprehensive survey dashboard
- Filter responses by date, company, technician
- View CSAT trends over time
- See performance by technician
### Phase 3: Enhancements
**Estimated: 2-3 days**
1. **Negative Feedback Alerts**
- Subscribe to `SURVEY_NEGATIVE_RESPONSE` event
- Send notification to technician and manager
- Optionally create follow-up ticket
2. **Advanced Features**
- Export responses to CSV
- Email template customization per tenant
- Survey response tracking (email opens)
- Prevent duplicate surveys for same ticket
3. **Polish**
- Survey preview in template editor
- Bulk trigger configuration
- Response time analytics
- Mobile-responsive survey page
**Deliverables:**
- Proactive alerts on negative feedback
- Export capabilities
- Enhanced reporting features
### Testing Strategy
### Unit Tests
- Token generation and validation
- Survey trigger condition matching
- CSAT calculation logic
- Email template rendering
### Integration Tests
- End-to-end survey flow (trigger → email → response → storage)
- Event bus integration (ticket closed → survey sent)
- API endpoint security (token validation)
- Multi-tenant isolation
### Manual Testing Checklist
- [ ] Create survey template
- [ ] Configure trigger for ticket closure
- [ ] Close ticket and verify email sent
- [ ] Click rating in email and verify response stored
- [ ] Submit survey with comment
- [ ] View responses in dashboard
- [ ] Filter responses by date/company
- [ ] Verify CSAT calculations
- [ ] Test expired token rejection
- [ ] Test duplicate response prevention
- [ ] Verify tenant isolation
### Implementation Notes
**Edition Status:**
- This is a Community Edition (CE) feature available in all installations.
- No edition-specific guards or fallbacks are required.
- All survey functionality should be accessible to all tenants by default.
### Configuration
### Environment Variables
No new environment variables required. Uses existing:
- `DOMAIN` - For survey response URLs
- Email service configuration (already configured)
- Redis/event bus configuration (already configured)
### Feature Flag (Optional)
Could add optional feature flag in tenant settings:
```sql
ALTER TABLE tenant_settings ADD COLUMN surveys_enabled BOOLEAN DEFAULT true;
```
This allows disabling surveys per tenant if needed.
### Summary
This plan provides a complete technical implementation roadmap for a customer satisfaction survey system tailored to small MSPs. The phased approach allows for iterative development and testing, with Phase 1 delivering core functionality and subsequent phases adding reporting and enhancements.
The system leverages existing Alga PSA infrastructure (event bus, email notifications, multi-tenancy) while adding focused survey-specific features that match what competitors offer without unnecessary complexity.