Survey-campagnes
Dit document legt uit wat een survey-campagne is, hoe de mailing-flow eromheen werkt en welke ontwerpkeuzes onder de motorkap zitten. Voor het concreet aanmaken, zie de how-to. Voor velden, validators en endpoints, zie de reference.
Probleem
Een survey verstuurt zelden in één klap. Voor een fatsoenlijke respons wil je een uitnodiging op een gepland moment, gevolgd door één of meer herinneringen aan respondenten die nog niets hebben ingevuld. Dat moet automatisch lopen, per ontvanger gepersonaliseerd, en zonder dat een beheerder per fase een aparte mail-template hoeft te onderhouden.
In de oorspronkelijke implementatie kreeg een campagne vier losse MessageTemplate-referenties (één voor de uitnodiging, drie voor de herinneringen). Dat dwong tot template-duplicatie, hield het onderwerp en de body vast in de template, en gaf geen ruimte om per fase een afwijkende boodschap te schrijven zonder een nieuwe template aan te maken.
Concept
Een survey-campagne kiest één MessageTemplate voor de hele looptijd. Per fase (uitnodiging plus reminder 1, 2 en 3) staat los daarvan een eigen subject en body op de campagne zelf. De template definieert de huisstijl en de structuur (header, footer, knoppen). De per-fase body wordt op de plek van het `` placeholder in de template-HTML geprikt, het per-fase subject vervangt de template-default.
Binnen de body en het onderwerp zijn twee placeholders bruikbaar:
- ``: de unieke link naar de survey voor déze ontvanger.
- ``: de display-naam van de ontvanger.
De backoffice-editor heeft knoppen die deze placeholders direct invoegen. Andere placeholders die elders in het mailstelsel werken (, etc.) blijven door Handlebars opgelost worden, maar krijgen geen UI-shortcut omdat ze voor campagne-mailings zelden nodig zijn.
Eén unieke link per (campagne, ontvanger)
`` is per ontvanger uniek, zelfs binnen dezelfde campagne. Bij de invite-fase genereert de processor één code via publicSurveyCodeService.issueCodeForRecipient en slaat die op het SurveyCampaignParticipant-record op (surveyCode). Reminders aan diezelfde ontvanger gebruiken die code opnieuw, zodat de respondent altijd dezelfde URL ziet en analytics over campagne-fases vergelijkbaar blijven.
De code wordt aangemaakt met maxUses: null (geen click-limiet). Reden: validateCode increment de usesCount bij elke landing op de survey-pagina, niet alleen bij submit. Een hard limiet van 1 zou een respondent die via een reminder opnieuw klikt buitensluiten. Privacy zit in de uniciteit van de code per ontvanger, niet in een teller.
De eindelijke URL is ${appUrl}/s/{publicId}?code={code}. Het publicId komt uit de bijbehorende PublicSurveyLink van de gekozen survey.
Per-recipient body-injectie zonder het mailstelsel te breken
messageService.create is generiek en gebruikt elders in het systeem een vaste body en data per call. Voor campagne-mailings moeten body en data juist per ontvanger variëren (de surveyUrl verschilt). Daarom is er een optionele hook toegevoegd:
perRecipientContext?: (recipient: User) => {
subject?: string;
body?: string;
data?: Partial<IMessageCreate['data']> & { surveyUrl?: string };
};
De hook wordt zowel in createMessageForRecipient (kleine batches) als in createBulk (vanaf 25 ontvangers) per ontvanger aangeroepen, gemerged over de message-level defaults, en doorgegeven aan substitutionHelper. Bestaande callers die de hook niet meegeven blijven exact werken zoals voorheen, omdat alle merging via optional chaining loopt.
substitutionHelper heeft een 8e parameter extra?: { surveyUrl?: string } die de waarde top-level in het substitutions-object zet, zodat zowel (campagne-shortcut) als (legacy convention elders) door Handlebars opgelost worden.
Fail-fast bij ontbrekende context
De campagne-processor draait binnen een BullMQ-job met tenant-context. Drie zaken moeten aanwezig zijn voordat hij gaat versturen:
- De gekozen
MessageTemplatebestaat. - De gekozen
Surveyheeft een actievePublicSurveyLink. - De BullMQ-job heeft een tenant-context (
mongooseConnectionManager.tenant.uuidis leesbaar).
Mist één van deze, dan returnt resolveCampaignSendContext null. De invite-fase reageert daarop met status: Cancelled en een logregel, in plaats van stilletjes ongeldige codes of mails zonder afzender te produceren. De reminder-fase logt en slaat de huidige tick over (de campagne blijft Running zodat een latere tick na herstel alsnog kan vuren).
Dezelfde gedachte geldt voor reminder-deelnemers zonder surveyCode. Als een non-respondent ergens in een eerdere fase geen code kreeg (data-inconsistentie, handmatige insert), slaat de processor die ontvanger over en logt een waarschuwing. Beter geen mail dan een mail met een onbruikbare link.
Verhouding tot het mailstelsel
Het bestaande mailstelsel verandert niet. Andere flows (mailbox composer, monitor-mails, notificaties) blijven messageService.create zonder perRecipientContext aanroepen en gebruiken substitutionHelper zonder extra-parameter. De campagne-flow voegt alleen optionele inputs toe.
De campagne-processor ziet er logisch zo uit:
processDuePhases
└─ findAll campaigns met status Scheduled of Running
└─ per campagne: processCampaign
├─ INVITE-fase: resolveCampaignSendContext, snapshot, code per deelnemer, messageService.create
└─ REMINDER-fases: resolveCampaignSendContext, non-respondents met code, messageService.create
resolveCampaignSendContext wordt eenmaal per processCampaign-tick aangeroepen voor de reminder-fases (niet per fase), zodat dezelfde template + link + tenant niet drie keer per tick uit de database gehaald wordt.
Wat is bewust niet gebouwd
- Per-ontvanger preview-pane in de UI. De editor toont de bron, niet het gerenderde resultaat. Een previewer kan later ingebouwd worden door de bestaande
messageService.renderPreviewaan te roepen. - Bewerken van invite-content na verzending. Zodra een campagne
Runningis, zijnsurvey,tags,template,inviteSendDate,inviteSubjecteninviteBodyvergrendeld. Reminder-content blijft bewerkbaar zolang de fase niet verstuurd is. - Meer dan drie reminders. De fasestructuur is voorlopig vast.
- Image upload of
<iframe>-sandbox in de body-editor. De editor is bewust minimaal (bold, italic, lists, link, plus de twee placeholder-knoppen). De detail-pagina toont de body via Angular’s gesanitiseerde[innerHTML], voldoende voor admin-input.
Verwante documenten
- How-to: Maak een survey-campagne aan
- Reference: Survey-campagnes (datamodel en API)
- Spec:
docs/superpowers/specs/2026-05-05-survey-campaign-template-restructure-design.md - Plan:
docs/superpowers/plans/2026-05-05-survey-campaign-template-restructure.md