CatalyzeUpDocs
impact pulse / product

Survey Recurrence & Engagement

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:

  1. Admin assignment: An org admin assigns the survey to members. Each member gets an assignment record with a due date.

  2. 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_remind is 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_session exists with status: "in_progress"
  • The session has not been updated (no save call) 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

  1. Set allow_multiple_attempts = true on all existing surveys (or just ignore the field)
  2. Add min_days_between_attempts field (default 30) to all surveys
  3. Add auto_remind field (default false for existing, true for new) to all surveys
  4. Add incomplete_reminders_sent and last_incomplete_reminder_at to survey_sessions
  5. Add next_eligible_date to survey_responses
  6. Update the reminder engine to include the incomplete session check
  7. Update the submit endpoint to enforce min_days_between_attempts instead of max_attempts