Skip to main content
Evaly’s notification system keeps organizers informed about important events in real-time. The system uses intelligent batching to group related events, preventing notification spam while ensuring you never miss critical updates.

Notification Architecture

Organization-Level Notifications

Notifications are created per organization, not per user. All organizers in the same organization see the same notification events.

Organizer-Level Preferences & Read Status

  • Preferences: Each organizer can customize which notification types they want to receive
  • Read Status: Read/unread state is tracked per organizer, so each person has their own unread count
This design ensures everyone on the team is aware of events, while allowing individual control over what they see.

Notification Types

Participant Activity

Type: participant_joinedWhen it fires: A participant starts taking a testBatching: Groups events within 5 minutes, shows up to 3 participant namesExample:
3 participants joined Mathematics Midterm
• John Smith
• Sarah Johnson
• Michael Chen
Type: participant_submittedWhen it fires: A participant completes all sections and submits their testBatching: Groups events within 5 minutes, shows up to 3 participant namesExample:
2 participants submitted Physics Final
• Emily Davis
• Robert Wilson
Type: suspicious_activityWhen it fires: Tab switching or window blur is detected during a testBatching: Groups events within 10 minutes, shows up to 5 participant namesExample:
Suspicious activity detected in Chemistry Quiz
• Alice Brown (switched tabs 3 times)
• David Lee (switched tabs 1 time)

Test Lifecycle

Type: test_auto_activatedWhen it fires: A scheduled test goes live automaticallyBatching: None (each event is important)Example:
Mathematics Final Exam has started (scheduled)
Type: test_auto_finishedWhen it fires: A scheduled test ends automaticallyBatching: None (each event is important)Example:
Physics Midterm has ended (scheduled)
Type: submissions_readyWhen it fires: Test has submissions that need manual gradingBatching: None (each event is important)Example:
Essay Test has 15 submissions ready for grading

Organization Events

Type: member_joinedWhen it fires: Someone accepts an invitation to join the organizationBatching: Groups events within 1 hour, shows up to 3 member namesExample:
2 new members joined your organization
• Jane Cooper ([email protected])
• Tom Anderson ([email protected])

Usage & Limits

Type: concurrent_participants_limitWhen it fires: Participant limit is reached during a testBatching: Groups events within 30 minutes (prevents spam)Example:
Concurrent participants limit reached (50/50)
Upgrade your plan for more capacity
Type: active_tests_limitWhen it fires: You can’t publish more tests due to plan limitsBatching: Groups events within 30 minutesExample:
Active tests limit reached (5/5)
Finish or unpublish existing tests to publish new ones
Type: ai_generation_limitWhen it fires: AI question generation quota is reachedBatching: Groups events within 1 hourExample:
Monthly AI generation limit reached (100/100)
Upgrade your plan for more AI-generated questions

Notification Batching

How Batching Works

Evaly uses an Instagram-style batching system:
1

Time Window

Events of the same type within a time window are grouped together.Example: 5 participants joining within 5 minutes = 1 batch notification
2

Batch Key

Format: {type}:{testId}:{timeWindowBucket}
// Example batch keys:
"participant_joined:test_abc:1735689600000"
"participant_submitted:test_xyz:1735689900000"
3

Read Status Split

Once ANY organizer reads a batch, new events create a NEW batch.This ensures no one misses new activity after reading.

Batch Configuration

const BATCH_CONFIG = {
  participant_joined: { windowMinutes: 5, maxPreviewCount: 3 },
  participant_submitted: { windowMinutes: 5, maxPreviewCount: 3 },
  suspicious_activity: { windowMinutes: 10, maxPreviewCount: 5 },
  test_auto_activated: { windowMinutes: 0, maxPreviewCount: 0 },
  test_auto_finished: { windowMinutes: 0, maxPreviewCount: 0 },
  submissions_ready: { windowMinutes: 0, maxPreviewCount: 0 },
  member_joined: { windowMinutes: 60, maxPreviewCount: 3 },
  concurrent_participants_limit: { windowMinutes: 30, maxPreviewCount: 0 },
  active_tests_limit: { windowMinutes: 30, maxPreviewCount: 0 },
  ai_generation_limit: { windowMinutes: 60, maxPreviewCount: 0 }
};
windowMinutes: 0 means no batching - each event creates a separate notification.

Managing Preferences

Get Current Preferences

const prefs = await getPreferences();

// Returns:
{
  participantJoined: true,
  participantSubmitted: true,
  suspiciousActivity: true,
  testAutoActivated: true,
  testAutoFinished: true,
  submissionsReadyForGrading: true,
  memberJoined: true,
  concurrentParticipantsLimitReached: true,
  activeTestsLimitReached: true,
  aiGenerationLimitReached: true
}
Default: All notification types are enabled (opt-out model)

Update Preferences

await updatePreferences({
  participantJoined: false,      // Disable participant join notifications
  suspiciousActivity: true,       // Keep suspicious activity notifications
  testAutoActivated: false        // Disable auto-activation notifications
});
Only provide the preferences you want to change. Omitted fields remain unchanged.

Notification Queries

Get Notifications (Paginated)

const { page, continueCursor, isDone } = await getNotifications({
  paginationOpts: {
    numItems: 20,
    cursor: null
  }
});

// Each notification batch includes:
{
  _id: string,
  type: NotificationType,
  organizationId: string,
  testId?: string,
  testName?: string,
  count: number,              // Number of events in this batch
  previewNames: string[],     // Up to N participant names
  lastEventAt: number,        // Timestamp of most recent event
  isRead: boolean,            // Read status for current organizer
  readAt?: number             // When current organizer read it
}

Get Unread Count

const unreadCount = await getUnreadCount();
// Returns: number (0 if not authenticated)
This is used for the notification badge in the UI.

Mark as Read

// Mark single notification
await markAsRead({ batchId });

// Mark all notifications
await markAllAsRead();

Creating Notifications

Notifications are created asynchronously via the scheduler:
// From a mutation:
await ctx.scheduler.runAfter(0, internal.internal.notifications.createNotification, {
  organizationId,
  type: NOTIFICATION_TYPES.PARTICIPANT_JOINED,
  testId,
  testName: "Mathematics Final",
  participantName: "John Smith",
  participantEmail: "[email protected]",
  metadata: {
    sectionName: "Algebra",
    // Other optional fields
  }
});
Always use ctx.scheduler.runAfter(0, ...) to create notifications asynchronously. This prevents blocking the main mutation flow.

Database Schema

notification

Individual notification events:
{
  organizationId: Id<"organization">,
  type: string,                  // From NOTIFICATION_TYPES
  batchKey: string,              // Grouping key
  testId?: Id<"test">,
  testName?: string,
  participantId?: Id<"users">,
  participantName?: string,
  participantEmail?: string,
  metadata?: {
    sectionName?: string,
    reason?: string,
    limit?: number,
    current?: number,
    memberName?: string,
    memberEmail?: string
  },
  createdAt: number
}

// Indexes:
// - by_organization
// - by_batch_key
// - by_organization_created

notificationBatch

Batched notifications for display:
{
  organizationId: Id<"organization">,
  type: string,
  batchKey: string,
  testId?: Id<"test">,
  testName?: string,
  count: number,
  previewNames: string[],
  lastEventAt: number,
  // Other aggregated fields
}

// Indexes:
// - by_organization
// - by_organization_last_event (for sorting)

notificationRead

Per-organizer read status:
{
  organizerId: Id<"organizer">,
  notificationBatchId: Id<"notificationBatch">,
  readAt: number
}

// Indexes:
// - by_organizer
// - by_organizer_batch (for uniqueness)
// - by_batch

organizerNotificationPreferences

Per-organizer notification settings:
{
  organizerId: Id<"organizer">,
  participantJoined?: boolean,
  participantSubmitted?: boolean,
  suspiciousActivity?: boolean,
  testAutoActivated?: boolean,
  testAutoFinished?: boolean,
  submissionsReadyForGrading?: boolean,
  memberJoined?: boolean,
  concurrentParticipantsLimitReached?: boolean,
  activeTestsLimitReached?: boolean,
  aiGenerationLimitReached?: boolean,
  updatedAt: number
}

// Index:
// - by_organizer

Cleanup & Maintenance

Old notifications can be cleaned up periodically:
await cleanupOldNotifications({
  olderThanDays: 30  // Default: 30 days
});

// Deletes:
// - Old notification batches
// - Associated individual notifications
// - Associated read records
This can be scheduled as a cron job to run weekly or monthly.

Adding New Notification Types

To add a new notification type:
1

Define Type

Add to NOTIFICATION_TYPES in convex/common/notificationTypes.ts:
export const NOTIFICATION_TYPES = {
  // ...
  NEW_TYPE: "new_type"
};
2

Configure Batching

Add to BATCH_CONFIG:
new_type: { windowMinutes: 5, maxPreviewCount: 3 }
3

Map Preference

Add to TYPE_TO_PREFERENCE:
new_type: "newTypeEnabled"
4

Add to Category

Add to NOTIFICATION_CATEGORIES:
participantActivity: {
  types: [..., NOTIFICATION_TYPES.NEW_TYPE]
}
5

Add Labels

Add to NOTIFICATION_LABELS and NOTIFICATION_DESCRIPTIONS:
new_type: "New Event"
6

Update Schema

Add preference field to organizerNotificationPreferences schema:
newTypeEnabled: v.optional(v.boolean())
7

Update Mutations

Add to getPreferences and updatePreferences in convex/organizer/notifications.ts
8

Add Formatting

Add message formatting in frontend src/lib/notification-utils.ts

Best Practices

  1. Use batching for high-frequency events to prevent notification fatigue
  2. Disable batching for critical events that require immediate attention
  3. Set appropriate time windows based on expected event frequency
  4. Provide clear metadata to give organizers context about what happened
  5. Test notification preferences before deploying new types
  6. Schedule cleanup to prevent database bloat from old notifications
  7. Use descriptive names in preview lists to help organizers identify participants
  • Test Scheduling - Notifications fire when scheduled tests activate/finish
  • Access Control - Notifications when participants join restricted tests

Build docs developers (and LLMs) love