Survey Recurrence & Engagement Specification
Implementation Progress
| Feature | Status |
|---|---|
min_days_between_attempts cooldown model + enforcement |
✅ Done |
auto_remind field on survey model |
✅ Done |
| Cooldown enforcement on submit endpoint (409 if too soon) | ✅ Done |
| Cooldown enforcement on session start | ✅ Done |
Can-attempt endpoint returns next_eligible_date |
✅ Done |
| Drip Campaign 1: Recurrence reminders (3 emails when due) | 🔲 Not yet |
| Drip Campaign 2: Incomplete survey reminders (3 emails) | 🔲 Not yet |
| Self-enrollment on public survey submission | 🔲 Not yet |
Admin compliance view endpoint (GET /surveys/{id}/compliance) |
🔲 Not yet |
Respondent history endpoint (GET /surveys/{id}/respondent/{email}/history) |
🔲 Not yet |
| Frontend: Survey Builder shows min_days_between_attempts | 🔲 Not yet |
| Frontend: Admin compliance table UI | 🔲 Not yet |
| Frontend: Show next_eligible_date to respondents | 🔲 Not yet |
Overview
Impact Pulse exists to measure change over time. Every survey allows multiple attempts by default. The question is never "can they retake?" but "how long do they wait between attempts?" This spec defines the recurrence cycle, the two drip campaigns that drive engagement, and the admin compliance view.
Related Specs: Survey Sessions | Organizations | Impact Comparison | Structured Assessments
Core Principle: All Surveys Allow Multiple Attempts ✅
The allow_multiple_attempts toggle is removed. Every survey accepts unlimited retakes. The only control is the minimum wait period between attempts (default: 30 days).
Survey Recurrence Settings
interface SurveyRecurrenceSettings {
// Minimum days between attempts by the same respondent.
// Default: 30. Set to 0 for no restriction.
min_days_between_attempts: number
// If true, the system actively reminds respondents when they are due
// for their next attempt. If false, respondents can retake on their
// own but won't be prompted.
auto_remind: boolean
}
When a respondent submits a response, the backend calculates their next eligible date:
next_eligible_date = submitted_at + min_days_between_attempts days
If the respondent tries to submit before that date, the backend returns:
409 Conflict: "You can retake this survey after {next_eligible_date}"
Enrollment
There are two ways a respondent enters the recurrence cycle:
-
Admin assignment: An org admin assigns the survey to members. Each member gets an assignment record with a due date.
-
Self-enrollment: A respondent takes a public survey link and provides a validated email (they must create an account). Their first submission enrolls them in the recurrence cycle. If
auto_remindis true, the system will email them when their next attempt is due.
Both paths result in the same outcome: the respondent is tracked and reminded.
Drip Campaign 1: Recurrence Reminders 🔲 Not yet
After a respondent completes an attempt, if auto_remind is true, the system waits for min_days_between_attempts days, then sends up to 3 reminder emails to prompt the next attempt.
Timeline
Day 0: Respondent completes attempt #1
next_eligible_date = Day 0 + min_days_between_attempts (e.g., Day 30)
Day 30: Respondent is now eligible for attempt #2
Reminder 1 sent: "Your survey is due"
Day 37: If not taken, Reminder 2 sent (+7 days)
Day 44: If not taken, Reminder 3 sent (+14 days)
Day 45+: Status set to OVERDUE. No more automatic reminders.
Admin sees this respondent in the "Overdue" section.
Reminder Email Content
Each escalation level has a different tone:
Reminder 1 (due date):
Subject: Your [Survey Title] assessment is ready
It's been [X] days since your last assessment. Take a few minutes to complete it again so we can track your progress.
[Take Survey Now]
Reminder 2 (+7 days):
Subject: Reminder: [Survey Title] is waiting for you
You haven't completed your follow-up assessment yet. Your responses help us measure the impact of our programs.
[Complete Your Assessment]
Reminder 3 (+14 days, final):
Subject: Final reminder: [Survey Title]
This is your last reminder. After this, your assessment will be marked as overdue.
[Take Survey Now]
Configurable Intervals
The survey creator sets the reminder schedule:
interface ReminderSchedule {
// Days after due date to send each reminder.
// Default: [0, 7, 14] (on due date, +7 days, +14 days)
intervals: number[]
}
Drip Campaign 2: Incomplete Survey Reminders 🔲 Not yet
If a respondent starts a survey but does not finish it (an in-progress session exists), the system sends up to 3 reminders to complete it.
Timeline
Hour 0: Respondent starts survey (session created, auto-save active)
Respondent leaves before submitting.
Hour 24: Reminder 1 sent: "You have an unfinished survey"
Hour 48: If still incomplete, Reminder 2 sent
Day 7: If still incomplete, Reminder 3 sent (final)
Day 7+: No more reminders. Session remains resumable.
Trigger Conditions
- A
survey_sessionexists withstatus: "in_progress" - The session has not been updated (no
savecall) for 24+ hours - The respondent has an email on file (from their account or the session)
Reminder Email Content
Reminder 1 (24 hours):
Subject: Continue your [Survey Title] assessment
You started but didn't finish your assessment. Your progress is saved. Pick up where you left off.
[Continue Survey] (X% complete)
Reminder 2 (48 hours):
Subject: Your [Survey Title] is still waiting
You're X% through your assessment. It only takes a few more minutes to complete.
[Finish Your Assessment]
Reminder 3 (7 days, final):
Subject: Last chance to complete [Survey Title]
Your in-progress assessment will remain saved, but this is our final reminder.
[Complete Now]
Implementation
The reminder engine (which already runs every 60 seconds) adds a second check:
async def process_incomplete_sessions():
"""Send reminders for abandoned in-progress sessions."""
db = get_database()
now = datetime.utcnow()
# Find in-progress sessions not updated in 24+ hours
stale_threshold = now - timedelta(hours=24)
cursor = db.survey_sessions.find({
"status": "in_progress",
"updated_at": {"$lt": stale_threshold},
"incomplete_reminders_sent": {"$lt": 3},
})
async for session in cursor:
# Check cooldown (don't send two reminders within 24 hours)
last_reminded = session.get("last_incomplete_reminder_at")
if last_reminded and (now - last_reminded).total_seconds() < 86400:
continue
# Determine which reminder level (1, 2, or 3)
count = session.get("incomplete_reminders_sent", 0)
hours_since_update = (now - session["updated_at"]).total_seconds() / 3600
# Reminder 1: 24h, Reminder 2: 48h, Reminder 3: 168h (7 days)
thresholds = [24, 48, 168]
if hours_since_update >= thresholds[count]:
await send_incomplete_reminder(session, count + 1)
await db.survey_sessions.update_one(
{"id": session["id"]},
{"$set": {
"incomplete_reminders_sent": count + 1,
"last_incomplete_reminder_at": now,
}}
)
New Fields on survey_sessions
{
// ... existing fields
incomplete_reminders_sent: 0, // NEW: 0, 1, 2, or 3
last_incomplete_reminder_at: null, // NEW: timestamp of last reminder
}
Respondent Lifecycle
A respondent goes through these states for each survey:
┌─────────────────────────────────────────────────┐
│ │
▼ │
[NOT ENROLLED] ──► [ENROLLED] ──► [IN PROGRESS] ──► [COMPLETED] │
│ │ │ │
│ │ │ │
│ ▼ │ │
│ [INCOMPLETE] │ │
│ (3 reminders) │ │
│ │ │ │
│ ▼ ▼ │
│ [ABANDONED] [WAITING] │
│ (cooldown) │
│ │ │
│ ▼ │
│ [DUE] │
│ (3 reminders) │
│ │ │
│ ▼ │
│ [OVERDUE] ──────┘
│ (admin sees)
│
▼
[NEVER STARTED]
(assignment only,
no session created)
Status Definitions
| Status | Meaning |
|---|---|
| NOT ENROLLED | No assignment and no submission. Not in the system. |
| ENROLLED | Assigned by admin but hasn't started yet. |
| IN PROGRESS | Started the survey, session exists, actively answering. |
| INCOMPLETE | Started but stopped. Reminders being sent. |
| ABANDONED | Incomplete for 30+ days. No more reminders. Resumable. |
| COMPLETED | Submitted a response. Score calculated. |
| WAITING | Completed, in the cooldown period before next attempt is due. |
| DUE | Cooldown expired. Eligible for next attempt. Reminders active. |
| OVERDUE | Due and all 3 reminders sent. Admin action needed. |
Admin Compliance View 🔲 Not yet
The organization admin sees a Survey Compliance table showing every assigned member's status for a given survey.
Table Columns
| Column | Description |
|---|---|
| Member | Name and email |
| Status | Current lifecycle status (color-coded badge) |
| Attempts | Total completed attempts (e.g., "3") |
| Last Taken | Date of most recent completion |
| Next Due | When they can/should take it next |
| Reminders Sent | 0/3, 1/3, 2/3, 3/3 |
| Baseline Score | Score from attempt #1 |
| Latest Score | Score from most recent attempt |
| Delta | Latest minus Baseline (with direction arrow) |
| Actions | Extend, Waive, Remind, View History |
Filters
- Status filter: All, Due, Overdue, Incomplete, Completed
- Sort by: Name, Status, Last Taken, Delta
- Search by name or email
Admin Actions
| Action | What It Does |
|---|---|
| Extend | Add N days to the due date |
| Waive | Skip this cycle, reset to next due date |
| Remind | Send an immediate reminder email (regardless of schedule) |
| View History | See all attempts with scores and dates |
Before/After (Impact) Comparison ✅
The comparison is always attempt #1 (baseline) vs. latest attempt. This is already implemented in the impact comparison system.
The admin sees:
- Per-respondent: baseline score, latest score, delta, direction
- Per-question: average baseline, average latest, delta
- Aggregate: overall program impact percentage
This data is displayed on the existing Impact Report page (/surveys/{id}/impact).
Data Model Changes
surveys collection (changes)
{
// REMOVE these fields:
// allow_multiple_attempts: true, // REMOVED (always true now)
// max_attempts: null, // REMOVED (no limit)
// KEEP (already exists):
recurrence_enabled: true,
recurrence_interval_days: 30, // min days between attempts
reminder_intervals: [0, 7, 14], // days after due date for reminders
// RENAME for clarity:
min_days_between_attempts: 30, // replaces recurrence_interval_days
auto_remind: true, // replaces recurrence_enabled
}
survey_sessions collection (additions)
{
// ... existing fields
incomplete_reminders_sent: 0, // NEW
last_incomplete_reminder_at: null, // NEW
}
survey_responses collection (additions)
{
// ... existing fields
next_eligible_date: ISODate("..."), // NEW: when this respondent can retake
}
API Changes
Modified Endpoints
POST /surveys/{id}/respond
- Always allows submission (no allow_multiple_attempts check)
- Enforces min_days_between_attempts (409 if too soon)
- Stores next_eligible_date on the response
PUT /surveys/{id}
- Accepts min_days_between_attempts and auto_remind instead of
allow_multiple_attempts and max_attempts
New Endpoints
GET /surveys/{id}/compliance
- Returns the compliance table data for an org survey
- Per-member: status, attempts, last_taken, next_due, reminders_sent,
baseline_score, latest_score, delta
- Requires OWNER or ADMIN role
GET /surveys/{id}/respondent/{email}/history
- Returns all attempts by a specific respondent
- Includes scores, dates, group_results per attempt
- Requires OWNER or ADMIN role
Migration
- Set
allow_multiple_attempts = trueon all existing surveys (or just ignore the field) - Add
min_days_between_attemptsfield (default 30) to all surveys - Add
auto_remindfield (default false for existing, true for new) to all surveys - Add
incomplete_reminders_sentandlast_incomplete_reminder_atto survey_sessions - Add
next_eligible_dateto survey_responses - Update the reminder engine to include the incomplete session check
- Update the submit endpoint to enforce min_days_between_attempts instead of max_attempts