# 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 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 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; 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; // {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 = { 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 { // 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 (

{t('surveys.settings.title')}

{t('surveys.settings.subtitle')}

), }, { id: 'survey-trigger-tab', value: 'triggers', label: t('surveys.settings.tabs.triggers'), content: (
), }, ]} />
); } ``` #### `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(null); const [filters, setFilters] = useState({ 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 (

{t('surveys.dashboard.title')}

{t('surveys.dashboard.subtitle')}

setFilters((prev) => ({ ...prev, start_date: range.start, end_date: range.end, })) } label={t('surveys.dashboard.filters.dateRange')} /> setFilters((prev) => ({ ...prev, company_id: companyId })) } options={companyOptions} /> setFilters((prev) => ({ ...prev, technician_id: technicianId })) } options={technicianOptions} />

{t('surveys.dashboard.summary.title')}

); } ``` `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(null); const [rating, setRating] = useState(null); const [comment, setComment] = useState(''); const [submitted, setSubmitted] = useState(false); const [error, setError] = useState(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 (
); } if (submitted) { return ( ); } if (error) { return (

{error}

); } return (

{survey?.prompt_text}

{t('surveys.response.subtitle', { company: survey?.company_name })}