Skip to content

Communication / Push Delivery / Mobile Engagement

Push notifications route time-sensitive coaching prompts to the right mobile app without leaking private coaching data.

The V1 baseline push contract covers Expo device registration, user preferences, quiet hours, notification intents, scheduled reminders, APNs/FCM delivery through a provider adapter, receipts, badge counts, deep links, and privacy-safe previews. Source contexts decide what happened; Communication decides whether, when, and how each recipient should be notified.

Tags: Communication, Expo mobile, Preferences, Deep links

Boundary decision

Communication owns push installations, notification preferences, notification intents, delivery attempts, receipts, badge projections, and mobile deep-link routing metadata. Programs, DailyExecution, Forms, Nutrition, CoachClientManagement, and ProgressAnalytics emit source events but do not store provider tokens or call APNs/FCM directly.

V1 baseline provider stance

use an adapter port so Expo push service can be the first mobile delivery path for both Expo apps, while keeping the option to route directly to APNs and FCM later. The domain model should store provider-neutral concepts and keep provider ticket/receipt details in delivery records.

Push Notification Flows

The feature starts with trustworthy device registration, then routes events through preferences, quiet hours, queues, and provider receipts.

Register Mobile Device

  1. Coach or client signs in on the relevant Expo app and the mobile shell creates a stable app installation id.
  2. App asks for notification permission at a contextual moment, then reads permission state, timezone, locale, app version, platform, and push token.
  3. Client calls the registration endpoint with app surface, platform, installation id, token, permission status, and device metadata.
  4. Backend binds the installation to the authenticated user, encrypts the provider token, stores a token hash for dedupe, and revokes replaced tokens for the same app installation.
  5. Server returns notification preferences, badge counts, and any channel/category configuration the app needs to finish local setup.

Deliver Coach Message

  1. Messaging persists the message transactionally and emits MessageSent through the outbox.
  2. Notification router creates one intent per recipient after excluding the sender's current user session and muted conversation participants.
  3. Preference policy checks message category, relationship mute state, private-preview mode, quiet hours, and workspace policy.
  4. Delivery worker chooses active installations for the recipient app surface, formats localized copy, sends through the provider adapter, and records tickets.
  5. Tap opens the conversation deep link, then the app refreshes the message window from REST so push payload content is never treated as source of truth.

Schedule Daily Task Reminder

  1. DailyExecution, Nutrition, Forms, or Programs emits a task due or reminder candidate event with recipient id, due time, timezone, source id, and notification category.
  2. Communication creates a scheduled notification intent with an idempotency key based on source context, source record id, recipient, category, and reminder slot.
  3. Scheduling policy applies user timezone, quiet hours, muted days, task completion state, and rate limits before queueing the job.
  4. If the source task is completed, rescheduled, archived, or removed before delivery, the owning context emits an update that cancels or supersedes the pending intent.
  5. Notification tap opens the exact Today task, form assignment, meal, workout, or check-in after the app verifies current access.

Handle Receipts And Invalid Tokens

  1. Provider adapter stores outbound tickets immediately and queues receipt polling or callback processing according to provider capability.
  2. Receipt worker marks deliveries as delivered, failed, transient-failed, throttled, or invalid-token.
  3. Transient failures retry with exponential backoff within the notification's usefulness window.
  4. Invalid or unregistered tokens deactivate the installation and stop future sends until the mobile app registers a new token.
  5. Operational dashboards aggregate failure codes by provider, app surface, build version, and category without exposing notification body text.

Domain Model

Push notifications are delivery records and recipient preferences around source-context events, not ownership of the source work item.

Aggregates & Entities

  • PushInstallation: registered app installation for one user, app surface, platform, provider token, permission state, locale, timezone, and lifecycle status.
  • NotificationPreference: per-user and optional relationship/category preference for push enablement, quiet hours, preview privacy, reminder windows, and digest behavior.
  • NotificationIntent: provider-neutral request to notify one recipient about one source event, with category, priority, copy keys, deep link, schedule time, and idempotency key.
  • NotificationDelivery: attempt to deliver one intent to one installation through one provider, including ticket, status, error code, retry count, and timing.
  • PushReceipt: provider response after send, used to mark success, failure, invalid token, throttling, or retry decisions.
  • BadgeCounter: recipient badge projection for unread messages, due tasks, coach inbox work, and app-surface-specific counts.
  • NotificationTemplate: code-owned or table-backed template metadata with translation keys, allowed interpolation fields, category, and preview sensitivity.

Value Objects & Rules

  • AppSurface: COACH_MOBILE or CLIENT_MOBILE. Web push is out of Public V1 unless explicitly added later.
  • NotificationCategory: MESSAGE, VOICE_NOTE, ATTACHMENT, FORM_DUE, WORKOUT_DUE, MEAL_DUE, CHECK_IN_DUE, RISK_FLAG, COACH_TASK, SYSTEM.
  • PreviewMode: FULL, PRIVATE, or HIDDEN, resolved from user, relationship, workspace, and device policy.
  • DeepLinkTarget: route key plus source identifiers. It contains no auth token, signed URL, raw object key, or sensitive body content.
  • QuietHours: local start/end time, timezone, allowed urgent categories, and next eligible delivery calculation with DST handling.
  • ProviderToken: encrypted raw token plus stable hash. Application code never logs or returns the raw token after registration.
  • Notification intents are idempotent by source context, source event id or source record id, recipient, category, and reminder slot.
  • Push payloads are hints. Mobile clients must fetch canonical state after launch, resume, or notification tap.
ModelOwned ByImportant MethodsEmits
PushInstallationCommunicationregister(), rotateToken(), updatePermission(), deactivate(), supportsCategory()PushInstallationRegistered, PushInstallationRevoked
NotificationPreferenceCommunicationenableCategory(), muteCategory(), muteRelationship(), resolveForIntent(), nextAllowedAt()NotificationPreferenceChanged
NotificationIntentCommunicationcreateFromSourceEvent(), schedule(), cancel(), markReady(), markExpired()NotificationIntentCreated, NotificationIntentCancelled
NotificationDeliveryCommunicationstartAttempt(), markSent(), markDelivered(), markFailed(), scheduleRetry()PushDeliveryAttempted, PushDeliveryFailed
BadgeCounterCommunication projectionrecalculate(), increment(), clearForSurface(), toProviderBadge()BadgeCounterChanged

Table Structure

Store device state, preferences, intents, delivery attempts, receipts, and badge projections with source records referenced by external ids.

TablePurposeImportant ColumnsConstraints & Indexes
communication_push_installationsActive and historical mobile app installations.id, user_id, app_surface, platform, environment, installation_id, device_id, provider, provider_token_encrypted, provider_token_hash, native_token_hash, permission_status, locale, timezone, app_version, last_seen_at, revoked_atUnique active (app_surface, environment, installation_id); unique active (provider, provider_token_hash); index (user_id, app_surface, revoked_at).
communication_notification_preferencesUser, relationship, and category-level notification settings.id, user_id, workspace_id, relationship_id, category, push_enabled, preview_mode, quiet_hours jsonb, reminder_window jsonb, muted_until, updated_atUnique active preference scope (user_id, workspace_id, relationship_id, category); index (user_id, category).
communication_notification_intentsProvider-neutral notification work items.id, recipient_user_id, workspace_id, relationship_id, source_context, source_record_id, source_event_id, category, priority, title_key, body_key, interpolation jsonb, preview_mode, deep_link jsonb, collapse_key, idempotency_key, scheduled_for, expires_at, status, cancelled_at, created_atUnique (idempotency_key); index (status, scheduled_for); index (recipient_user_id, created_at desc); source ids are external references.
communication_notification_deliveriesAttempt-level delivery records per installation and provider.id, intent_id, installation_id, provider, provider_ticket_id, status, attempt_count, next_retry_at, last_error_code, last_error_detail jsonb, sent_at, delivered_at, failed_atUnique (intent_id, installation_id); index (status, next_retry_at); index (provider, provider_ticket_id).
communication_push_receiptsProvider receipt payloads and normalized result codes.id, delivery_id, provider, provider_ticket_id, receipt_status, provider_error_code, raw_payload jsonb, received_atUnique nullable (provider, provider_ticket_id); index (receipt_status, received_at); raw payload redacted before long-term retention.
communication_badge_countersPer-user badge projection by app surface and category.id, user_id, app_surface, unread_messages_count, due_tasks_count, coach_inbox_count, risk_alert_count, total_badge_count, computed_atUnique (user_id, app_surface); recomputable projection, not source of truth.
communication_notification_audit_eventsAppend-only audit for preference changes, delivery decisions, cancellations, and support actions.id, intent_id, user_id, event_type, reason_code, metadata jsonb, created_atIndex (user_id, created_at desc); retain according to support and privacy policy.

Offline storage

SQLite stores installation id, permission state, last registered token hash, local notification settings cache, pending registration retry state, and last handled notification id. It must not store encrypted server tokens, provider tickets, raw receipt payloads, or source data beyond the target screen's own offline cache.

Lifecycle Policy

Push records mix security-sensitive provider tokens, user preferences, operational diagnostics, and short-lived notification work.

Installations And Preferences

  • Push installations are soft-revoked with revoked_at when users sign out, disable permission, rotate tokens, uninstall, or provider receipts report an invalid token.
  • Raw provider tokens are encrypted at rest, never returned from the API after registration, and never written to logs, analytics, notification payloads, or audit metadata.
  • Preference records are durable user settings. Changes write audit events and invalidate cached delivery policy for that user.
  • Relationship mutes and category mutes use muted_until where possible so temporary silencing can expire without manual cleanup.
  • Deleting a user or relationship triggers token revocation, preference anonymization or purge, and cancellation of pending intents for inaccessible targets.

Intents, Deliveries, And Receipts

  • Notification intents are append-only after creation except status transitions, cancellation, and expiration fields.
  • Scheduled intents expire when the underlying source item is no longer actionable or the usefulness window closes.
  • Delivery attempts and receipts are retained for a short support diagnostics window, then pruned or redacted to aggregate metrics.
  • Deep-link payloads keep route keys and ids only. Any target data is loaded through authenticated APIs after the tap.
  • Badge counters are projections and may be rebuilt from unread messages, due tasks, coach inbox items, and risk-alert sources.

Caution

Do not treat a successful push send as proof that the user saw the work item. Product state must depend on explicit reads, task completion, app foreground refresh, or source-context events.

API Contracts

Mobile APIs manage installations and preferences; source contexts feed notifications through events or internal intent creation contracts.

EndpointPurposeRequestResponse
POST /api/v1/communication/push/installationsRegister or update the current mobile app installation.App surface, platform, environment, installation id, device id, provider, push token, permission status, locale, timezone, app version.Installation id, active status, preference summary, badge counts, supported categories, registration refresh interval.
PATCH /api/v1/communication/push/installations/{installationId}Update permission state, token, locale, timezone, or app metadata.Changed token, permission status, locale, timezone, app version, last seen timestamp.Updated installation summary and badge counts.
DELETE /api/v1/communication/push/installations/{installationId}Revoke an installation on sign-out or user-controlled device removal.Reason code and optional app surface.Revoked installation id and remaining active installation count.
GET /api/v1/communication/notification-preferencesLoad notification settings for the signed-in user.Optional workspace, relationship, and category filters.Effective preferences, inherited defaults, quiet hours, preview mode, category states.
PUT /api/v1/communication/notification-preferencesReplace one or more notification preference settings.Category, optional relationship id, push enabled, quiet hours, preview mode, muted until, reminder windows.Saved preferences, effective policy, audit event ids.
POST /api/v1/communication/notifications/{notificationId}/openedRecord that a mobile app opened a notification and resolved its target.Installation id, app surface, opened at, action id, target resolution result.Updated badge counts and optional refresh hints.
GET /api/v1/communication/badge-countsReturn app-surface badge counts after foreground, resume, or notification receipt.App surface and optional last computed timestamp.Unread message, due task, coach inbox, risk alert, and total badge counts.
POST /api/v1/internal/communication/notification-intentsCreate or upsert notification intents from a trusted source context when an event bus is not enough.Source context, source record id, source event id, recipient ids, category, schedule time, copy keys, deep link, idempotency key.Created, deduped, scheduled, suppressed, or rejected intent summaries.
POST /api/v1/internal/communication/push/receiptsIngest provider receipt callbacks or worker-polled receipt results.Provider, ticket ids, statuses, normalized error codes, raw payload references.Updated delivery statuses, deactivated token ids, retry job ids.

API note

Notification DTOs should expose notificationId, category, preview, deepLink, createdAt, and badgeCount. They should not expose provider tokens, provider tickets, source payloads, signed URLs, message bodies when private preview is active, or raw receipt data.

Key Code Snippets

Implementation sketches show provider-neutral schema, device registration, intent routing, and delivery through an adapter.

Drizzle schema shape

apps/api/src/communication/db/push-notifications.schema.ts

ts
export const communicationPushInstallations = pgTable("communication_push_installations", {
  id: uuid("id").primaryKey().defaultRandom(),
  userId: uuid("user_id").notNull(),
  appSurface: text("app_surface").$type<AppSurface>().notNull(),
  platform: text("platform").$type<MobilePlatform>().notNull(),
  environment: text("environment").$type<PushEnvironment>().notNull(),
  installationId: text("installation_id").notNull(),
  provider: text("provider").$type<PushProvider>().notNull(),
  providerTokenEncrypted: text("provider_token_encrypted").notNull(),
  providerTokenHash: text("provider_token_hash").notNull(),
  permissionStatus: text("permission_status").$type<NotificationPermissionStatus>().notNull(),
  locale: text("locale").notNull(),
  timezone: text("timezone").notNull(),
  appVersion: text("app_version").notNull(),
  lastSeenAt: timestamp("last_seen_at", { withTimezone: true }).notNull().defaultNow(),
  revokedAt: timestamp("revoked_at", { withTimezone: true }),
}, (table) => ({
  installationUnique: uniqueIndex("push_installations_active_installation_unique")
    .on(table.appSurface, table.environment, table.installationId)
    .where(sql`${table.revokedAt} is null`),
  tokenUnique: uniqueIndex("push_installations_active_token_unique")
    .on(table.provider, table.providerTokenHash)
    .where(sql`${table.revokedAt} is null`),
  userSurfaceIdx: index("push_installations_user_surface_idx")
    .on(table.userId, table.appSurface, table.revokedAt),
}));

export const communicationNotificationIntents = pgTable("communication_notification_intents", {
  id: uuid("id").primaryKey().defaultRandom(),
  recipientUserId: uuid("recipient_user_id").notNull(),
  workspaceId: uuid("workspace_id"),
  relationshipId: uuid("relationship_id"),
  sourceContext: text("source_context").notNull(),
  sourceRecordId: uuid("source_record_id").notNull(),
  sourceEventId: uuid("source_event_id"),
  category: text("category").$type<NotificationCategory>().notNull(),
  priority: text("priority").$type<NotificationPriority>().notNull(),
  titleKey: text("title_key").notNull(),
  bodyKey: text("body_key").notNull(),
  interpolation: jsonb("interpolation").$type<NotificationInterpolation>().notNull().default({}),
  previewMode: text("preview_mode").$type<PreviewMode>().notNull(),
  deepLink: jsonb("deep_link").$type<DeepLinkTarget>().notNull(),
  idempotencyKey: text("idempotency_key").notNull(),
  scheduledFor: timestamp("scheduled_for", { withTimezone: true }).notNull(),
  expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
  status: text("status").$type<NotificationIntentStatus>().notNull().default("SCHEDULED"),
  cancelledAt: timestamp("cancelled_at", { withTimezone: true }),
}, (table) => ({
  idempotencyUnique: uniqueIndex("notification_intents_idempotency_unique")
    .on(table.idempotencyKey),
  dueIdx: index("notification_intents_due_idx")
    .on(table.status, table.scheduledFor),
  recipientIdx: index("notification_intents_recipient_idx")
    .on(table.recipientUserId, table.scheduledFor),
}));

Register installation

apps/api/src/communication/use-cases/register-push-installation.ts

ts
@Injectable()
export class RegisterPushInstallationUseCase {
  constructor(
    private readonly installations: PushInstallationRepository,
    private readonly preferences: NotificationPreferenceRepository,
    private readonly tokenVault: PushTokenVault,
    private readonly badges: BadgeCounterReadModel,
    private readonly tx: TransactionRunner,
  ) {}

  async execute(input: RegisterPushInstallationInput, actor: Actor): Promise<PushRegistrationDto> {
    return this.tx.run(async () => {
      const token = await this.tokenVault.encryptAndHash(input.providerToken);

      const installation = PushInstallation.register({
        userId: actor.userId,
        appSurface: input.appSurface,
        platform: input.platform,
        environment: input.environment,
        installationId: input.installationId,
        deviceId: input.deviceId,
        provider: input.provider,
        providerTokenEncrypted: token.encrypted,
        providerTokenHash: token.hash,
        permissionStatus: input.permissionStatus,
        locale: input.locale,
        timezone: input.timezone,
        appVersion: input.appVersion,
      });

      await this.installations.revokeTokenConflicts(installation);
      await this.installations.upsertActive(installation);

      const effectivePreferences = await this.preferences.getEffectiveForUser(actor.userId);
      const badgeCounts = await this.badges.forUser(actor.userId, input.appSurface);

      return PushRegistrationDto.from(installation, effectivePreferences, badgeCounts);
    });
  }
}

Create notification intent

apps/api/src/communication/use-cases/create-notification-intent.ts

ts
@Injectable()
export class CreateNotificationIntentUseCase {
  constructor(
    private readonly intents: NotificationIntentRepository,
    private readonly policy: NotificationPolicy,
    private readonly scheduler: NotificationScheduler,
    private readonly outbox: OutboxPort,
  ) {}

  async execute(input: CreateNotificationIntentInput): Promise<NotificationIntentResult> {
    const decision = await this.policy.evaluate(input);
    if (decision.suppressed) {
      return NotificationIntentResult.suppressed(decision.reason);
    }

    const intent = NotificationIntent.create({
      recipientUserId: input.recipientUserId,
      workspaceId: input.workspaceId,
      relationshipId: input.relationshipId,
      sourceContext: input.sourceContext,
      sourceRecordId: input.sourceRecordId,
      sourceEventId: input.sourceEventId,
      category: input.category,
      priority: decision.priority,
      titleKey: decision.template.titleKey,
      bodyKey: decision.template.bodyKey,
      interpolation: decision.safeInterpolation,
      previewMode: decision.previewMode,
      deepLink: input.deepLink,
      idempotencyKey: decision.idempotencyKey,
      scheduledFor: decision.nextAllowedAt,
      expiresAt: decision.expiresAt,
    });

    const saved = await this.intents.upsertByIdempotencyKey(intent);
    await this.scheduler.enqueue(saved);
    await this.outbox.publish(saved.pullDomainEvents());
    return NotificationIntentResult.scheduled(saved);
  }
}

Delivery worker

apps/api/src/communication/workers/send-push-notification.worker.ts

ts
@Injectable()
export class SendPushNotificationWorker {
  constructor(
    private readonly intents: NotificationIntentRepository,
    private readonly installations: PushInstallationRepository,
    private readonly deliveries: NotificationDeliveryRepository,
    private readonly templates: NotificationTemplateRenderer,
    private readonly tokenVault: PushTokenVault,
    private readonly provider: PushProviderPort,
  ) {}

  async handle(intentId: string): Promise<void> {
    const intent = await this.intents.lockReadyIntent(intentId);
    if (!intent || intent.isExpired()) return;

    const installations = await this.installations.activeForUser(intent.recipientUserId);
    const payload = this.templates.renderPushPayload(intent);

    for (const installation of installations) {
      if (!installation.canReceive(intent.category)) continue;

      const delivery = NotificationDelivery.start(intent, installation);
      const providerToken = await this.tokenVault.decrypt(installation.providerTokenEncrypted);
      const result = await this.provider.send({
        token: providerToken,
        provider: installation.provider,
        title: payload.title,
        body: payload.body,
        data: payload.deepLinkData,
        badge: payload.badgeCount,
        collapseKey: intent.collapseKey,
      });

      delivery.recordProviderResult(result);
      await this.deliveries.save(delivery);
    }

    intent.markDispatched();
    await this.intents.save(intent);
  }
}

Events & Background Jobs

Push delivery consumes source events, publishes durable intent records, and keeps receipts, badges, and token state accurate.

Domain And Integration Events

  • PushInstallationRegistered: initializes badge counts, validates token conflicts, and records app/build metadata.
  • NotificationPreferenceChanged: invalidates preference cache and optionally cancels newly disallowed pending intents.
  • NotificationIntentCreated: schedules delivery through BullMQ and records an audit decision.
  • NotificationIntentCancelled: prevents stale task, reminder, message, or form notifications from reaching users.
  • PushDeliveryAttempted: updates delivery diagnostics and provider ticket state.
  • PushDeliveryFailed: triggers retry, token revocation, or operational alerting depending on normalized error code.
  • BadgeCounterChanged: optionally sends a silent badge update when platform policy and app state allow it.

Jobs

  • Notification router consumes outbox events from messaging, forms, daily execution, nutrition, programs, progress, and coach inbox sources.
  • Scheduled delivery worker finds due intents, applies final source-state checks, and dispatches active installations.
  • Receipt polling worker fetches provider receipts, normalizes status codes, deactivates invalid tokens, and retries transient errors.
  • Reminder rescheduler recalculates pending reminders when timezone, quiet hours, due dates, or task status changes.
  • Badge projector rebuilds counts from source read models and writes platform-specific totals for push payloads.
  • Retention worker prunes old delivery attempts, receipts, and raw provider payloads after the diagnostics window.

Permissions & Access Rules

Push routing has to prove the recipient can still access the target and that payload content is safe for locked screens.

Actor Rules

  • A signed-in user can register, update, and revoke only their own app installations.
  • Coach and client mobile apps may register only for their matching app surface; admin or web sessions cannot create mobile push tokens.
  • Users can edit their own category, quiet-hour, preview, and relationship mute settings unless workspace policy locks a required operational category.
  • Support can inspect delivery state and normalized error codes, but cannot view raw provider tokens or force-send private payloads without audited elevated permission.

Payload And Target Rules

  • Every notification intent must pass current recipient access for the source relationship, conversation, task, form, workout, meal, or risk flag before delivery.
  • Private preview mode replaces message text, voice-note details, filenames, meal details, health details, and form answers with generic copy.
  • Deep links carry only route keys and ids. The app must authenticate, refresh session state, and fetch target data before rendering the destination.
  • High-priority or time-sensitive categories require explicit product policy and should bypass quiet hours only for urgent operational reasons.
  • Notification logs and analytics use category, result code, and ids, not rendered message body or sensitive interpolation text.

Web, Coach Mobile, Client Mobile

Mobile apps own registration and notification handling; web exposes settings and operational visibility where useful.

Coach Web

  • Shows notification preferences for coach account, coach task categories, message preview privacy, quiet hours, and relationship-level mutes.
  • Displays support diagnostics for delivery state when a coach reports missed notifications, without exposing provider tokens.
  • Does not receive browser push in Public V1; web updates badges through realtime and REST foreground refresh.

Coach Mobile

  • Registers an installation after sign-in, refreshes token on app start and permission changes, and retries failed registration from SQLite.
  • Handles coach-message, client-risk, coach-task, form-review, and schedule notifications with deep links into the exact client workspace screen.
  • Refreshes badge counts on foreground, notification receipt, notification open, and significant realtime reconnect.

Client Mobile

  • Registers separately from coach mobile and uses client-safe categories such as message, Today task, workout, meal, check-in, form, and system.
  • Notification taps open Today, a specific task, a form assignment, a workout session, a meal log, progress feedback, or a conversation.
  • Settings let the client choose quiet hours, reminder windows, private previews, and relationship message mute behavior.

Test Scenarios & V1 Baseline Decisions

Push needs coverage across token security, preferences, scheduling, provider failures, privacy payloads, and mobile deep links.

Tests

  • Domain: duplicate registration for the same installation updates token metadata and revokes conflicting active tokens.
  • Domain: preference resolution applies user, relationship, category, quiet-hour, mute, and workspace policy in a deterministic order.
  • Domain: duplicate source events produce one notification intent through idempotency key enforcement.
  • API: users cannot register push installations, update preferences, or revoke devices for another user.
  • API: private preview mode removes message body, attachment filenames, form details, meal details, and health-sensitive interpolation from payloads.
  • Worker: transient provider failures retry until expiration; invalid-token receipts revoke the installation and stop future sends.
  • Worker: completed, archived, or rescheduled source tasks cancel or supersede pending reminders before delivery.
  • Mobile E2E: notification tap cold-starts the app, restores auth, fetches target data, and opens the exact conversation or Today task.
  • Mobile E2E: permission denial leaves registration inactive but keeps in-app badge and preference screens functional.

V1 Baseline Decisions

  • Use Expo Push Service for Public V1 delivery for both Expo apps through a provider-neutral adapter. Do not build direct APNs/FCM fallback for launch, but store provider-neutral installation, ticket, receipt, and native-token metadata so direct providers can be added later without changing source event contracts.
  • Default previews are PRIVATE for coach mobile and client mobile. Locked-screen payloads hide message text, filenames, form details, meal details, health-sensitive fields, signed URLs, and source payloads; users may choose FULL only when workspace policy allows, and workspaces can force PRIVATE or HIDDEN.
  • Initial reminder windows use the recipient timezone and quiet hours: workouts send 60 minutes before scheduled time or 09:00 local when unscheduled; meal and supplement reminders send 15 minutes before configured times; check-ins and forms send at 09:00 local on the due date with one overdue reminder at 18:00; coach follow-ups send at 10:00 local on the planned follow-up date.
  • No Public V1 category bypasses quiet hours. Critical risk flags and coach-critical tasks still create inbox/priority work immediately, but push delivery waits for the next allowed window unless a future explicit urgent-notification policy is approved.
  • Keep notification intents, deliveries, tickets, normalized receipts, and redacted provider payload metadata for 30 days for diagnostics, then summarize, anonymize, or purge. Raw provider receipt payloads are redacted immediately into normalized fields and must not retain rendered body text, provider tokens, signed URLs, or sensitive interpolation.
  • Notification settings live in mobile settings for both apps, and coach notification settings also appear in coach web account settings for Public V1. Client preference editing remains client-mobile-only until a client web surface exists; support can view effective preferences without seeing provider tokens.

CoachMe internal planning documentation.