Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
58 KiB
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
-
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
surveyTokenServiceand validate against stored digests (Details §Token Service).
- Backend Actions & Entry Points
- Build
surveyActions.tsCRUD usingcreateTenantKnex/withTransactionpatterns (Details §Server Actions). - Add public submission + validation actions that run under
runWithTenantand update invitations atomically (Details §Server Actions). - Register event subscriber that listens to ticket closure events and invokes
sendSurveyInvitation(Details §Event Bus Integration).
- Build
- Email Delivery & Workflow
- Register
SURVEY_TICKET_CLOSEDtemplate 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).
- Register
- UI Components
- Build survey settings tabs (templates/triggers) using
server/src/components/uicomponents + 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).
- Build survey settings tabs (templates/triggers) using
- Database & Token Foundations
-
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).
- API Layer
-
Phase 3 – Enhancements (Details §Implementation Phases > Phase 3)
- Alerting & Automation
- Emit
SURVEY_NEGATIVE_RESPONSEevents 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).
- Emit
- 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).
- Alerting & Automation
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.mdserver/src/lib/email/README.mddocs/email-i18n-implementation-summary.mddocs/inbound-email/README.md- This plan’s 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
- Database Layer - Survey configuration, responses, and templates
- Event Integration - Hook into existing event bus for ticket lifecycle events
- Email Integration - Leverage existing email notification system
- API Layer - Survey response submission and management
- UI Components - Survey configuration, response viewing, and reporting
- 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.
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).
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.
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.
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:
{
token: string;
rating: number;
comment?: string;
}
Response:
{
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_responseevent if rating <= 2 - Return success/error
GET /api/surveys/templates
List survey templates for current tenant.
Response:
{
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:
{
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:
{
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 ticketcompany_id- Filter by companyrating- Filter by rating (e.g.,rating=1,2for negative)start_date,end_date- Date rangelimit,offset- Pagination
Response:
{
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 rangecompany_id- Filter by companyuser_id- Filter by technician
Response:
{
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
'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
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_invitationsjoined ontenant). Compare against the stored hashed token and fail fast with explicitErrormessages when the token is missing, expired, or already used.issueSurveyToken()synchronously returns{ plainToken, hashedToken }whereplainTokenis a base64url string generated withcrypto.randomBytes(32)andhashedTokenis 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 socreateTenantKnex()provides the correctly scoped connection.
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:
// 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
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:
import { registerSurveySubscriber } from './subscribers/surveySubscriber';
// In initialization
registerSurveySubscriber();
Email Integration
New Email Template
Template registration
- Add a
SURVEY_TICKET_CLOSEDrow tosystem_email_templates(categorySurveys, subtypeTicket 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.mdfor language key naming and/server/src/lib/email/templates/partialsfor reusable header/footer fragments.
Survey Service
server/src/services/surveyService.ts
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.sendTransactionalso 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). getTicketDetailsForSurveyandgetSurveyTemplateremain 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.
'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).
'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).
'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:
// 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.surveysnavigation.primary.surveyssurveys.settings.titlesurveys.settings.subtitlesurveys.settings.tabs.templatessurveys.settings.tabs.triggerssurveys.dashboard.titlesurveys.dashboard.subtitlesurveys.dashboard.filters.dateRangesurveys.dashboard.filters.companysurveys.dashboard.filters.techniciansurveys.dashboard.summary.titlesurveys.dashboard.summary.totalResponsessurveys.dashboard.summary.responseRateactions.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.loadingsurveys.response.errors.invalidTokensurveys.response.errors.genericsurveys.response.subtitlesurveys.response.thankYouFallbacksurveys.response.submit
Add to main navigation (optional - could just be in Reports):
// 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:
// 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:
// 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
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
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
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
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)
-
Database Setup ✅ COMPLETE
- Create migration files for all tables with proper tenant isolation
- Add RLS policies (note: currently disabled via migration
20251111120000_disable_rls_on_survey_tables.cjs) - Create indexes for efficient querying
- Add foreign keys with Citus multi-tenancy rules
-
Token Service ✅ COMPLETE
- Implement
surveyTokenService.tswith secure token generation and validation - Unit tests for token hashing and issuance
- Integration tests with real database
- Implement
-
Server Actions ✅ COMPLETE
- Implement
surveyActions.ts(CRUD for templates/triggers via server actions) - Implement
surveyResponseActions.ts(public response submission via server action) - Add event types to
events.ts(SURVEY_INVITATION_SENT, SURVEY_RESPONSE_SUBMITTED, SURVEY_NEGATIVE_RESPONSE)
- Implement
-
Services & Integration ✅ COMPLETE
- Create
surveyService.ts(email sending logic via server action) - Create
surveySubscriber.ts(listen to ticket closure events) - Register subscriber in event bus initialization
- Create
-
Email Integration ✅ COMPLETE
- Register
SURVEY_TICKET_CLOSEDtemplate insystem_email_templates - Design HTML email template with rating buttons
- Implement token-based one-click rating links
- Add email provider tests for localization and fallbacks
- Register
-
UI Components ✅ COMPLETE
- Build
SurveySettings.tsx(templates + triggers management) - Build
TemplateList.tsx+TemplateForm.tsx - Build
TriggerList.tsx+TriggerForm.tsx - Build
SurveyResponsePage.tsx(public survey page) - Add navigation integration to Settings menu
- Build
-
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
-
API Routes
GET /api/surveys/responses(with filtering)GET /api/surveys/stats(aggregated metrics)
-
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)
-
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
-
Negative Feedback Alerts
- Subscribe to
SURVEY_NEGATIVE_RESPONSEevent - Send notification to technician and manager
- Optionally create follow-up ticket
- Subscribe to
-
Advanced Features
- Export responses to CSV
- Email template customization per tenant
- Survey response tracking (email opens)
- Prevent duplicate surveys for same ticket
-
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:
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.