package models import ( "time" "github.com/google/uuid" ) // NotificationCategory identifies a kind of in-app notification. The set is // fixed (mirrors the settings UI); new categories are added here + in the // preference struct + default merge. type NotificationCategory string const ( NotifInboundReply NotificationCategory = "inbound_reply" NotifInboundOOO NotificationCategory = "inbound_out_of_office" NotifHealthBounce NotificationCategory = "health_bounce" NotifHealthComplaint NotificationCategory = "health_complaint" NotifWorkerDowntime NotificationCategory = "health_worker_downtime" NotifSecuritySignIn NotificationCategory = "security_new_signin" NotifBillingAlert NotificationCategory = "billing_alert" NotifTeamActivity NotificationCategory = "team_activity" // NotifCampaignPaused fires when the platform pauses a campaign on its // own — today, when an auto-pause guardrail band is breached. Rare and // always actionable, so it defaults on with email. NotifCampaignPaused NotificationCategory = "campaign_paused" // NotifDomainAuth fires when a sending domain starts failing SPF/DMARC. // It is the warning shot before the send/warmup gate applies, so it must // reach the owner while there is still time to fix the DNS records. NotifDomainAuth NotificationCategory = "health_domain_auth" ) // ChannelPrefs is the per-category delivery toggles: in-app feed, account // email, a connected Slack workspace, and mobile push (APNs). type ChannelPrefs struct { InApp bool `json:"in_app"` Email bool `json:"email"` Slack bool `json:"slack"` Push bool `json:"push"` } // CategoryPref is the enable flag + channel toggles for one category. type CategoryPref struct { Enabled bool `json:"enabled"` Channels ChannelPrefs `json:"channels"` } // NotificationPreferences is the per-user singleton (jsonb on users). Always // returned fully populated via DefaultNotificationPreferences merge. type NotificationPreferences struct { InboundReply CategoryPref `json:"inbound_reply"` InboundOOO CategoryPref `json:"inbound_out_of_office"` HealthBounce CategoryPref `json:"health_bounce"` HealthComplaint CategoryPref `json:"health_complaint"` WorkerDowntime CategoryPref `json:"health_worker_downtime"` SecuritySignIn CategoryPref `json:"security_new_signin"` BillingAlert CategoryPref `json:"billing_alert"` TeamActivity CategoryPref `json:"team_activity"` CampaignPaused CategoryPref `json:"campaign_paused"` DomainAuth CategoryPref `json:"health_domain_auth"` // EmailDigestMinutes is the email-channel bundling window: pending // notification emails hold this long, then flush as one email. Bounded // by config.NotificationEmailWindow* (30 min floor, 24h ceiling). // Security sign-in alerts always go out immediately. EmailDigestMinutes int `json:"email_digest_minutes"` } // DefaultNotificationPreferences is the merge base. Health categories default ON // (operationally important + low volume); inbound categories default OFF (a big // campaign would otherwise flood the feed with a notification per recipient). // Push defaults on: it only fires for devices the user explicitly registered by // granting the OS notification permission, and enabled categories should reach // those devices without a second opt-in. func DefaultNotificationPreferences() NotificationPreferences { on := CategoryPref{Enabled: true, Channels: ChannelPrefs{InApp: true, Push: true}} off := CategoryPref{Enabled: false, Channels: ChannelPrefs{InApp: true, Push: true}} // Billing defaults to email on: rare, and a paused workspace must reach // whoever can fix it even when nobody is watching the dashboard. billing := CategoryPref{Enabled: true, Channels: ChannelPrefs{InApp: true, Push: true, Email: true}} // A campaign the platform stopped by itself has to reach whoever can // restart it, so this one emails by default too. campaignPaused := CategoryPref{Enabled: true, Channels: ChannelPrefs{InApp: true, Push: true, Email: true}} // A domain the platform will eventually stop sending from has to reach // whoever can edit the DNS, so this one emails by default too. domainAuth := CategoryPref{Enabled: true, Channels: ChannelPrefs{InApp: true, Push: true, Email: true}} return NotificationPreferences{ InboundReply: off, InboundOOO: off, HealthBounce: on, HealthComplaint: on, WorkerDowntime: on, SecuritySignIn: on, BillingAlert: billing, TeamActivity: on, CampaignPaused: campaignPaused, DomainAuth: domainAuth, EmailDigestMinutes: 30, } } // CategoryPref returns the preference for a category (zero value if unknown). func (p NotificationPreferences) CategoryPref(c NotificationCategory) CategoryPref { switch c { case NotifInboundReply: return p.InboundReply case NotifInboundOOO: return p.InboundOOO case NotifHealthBounce: return p.HealthBounce case NotifHealthComplaint: return p.HealthComplaint case NotifWorkerDowntime: return p.WorkerDowntime case NotifSecuritySignIn: return p.SecuritySignIn case NotifBillingAlert: return p.BillingAlert case NotifTeamActivity: return p.TeamActivity case NotifCampaignPaused: return p.CampaignPaused case NotifDomainAuth: return p.DomainAuth default: return CategoryPref{} } } // Notification is one row in the in-app feed. type Notification struct { ID uuid.UUID `json:"id"` UserID uuid.UUID `json:"user_id"` OrganizationID *uuid.UUID `json:"organization_id,omitempty"` Category NotificationCategory `json:"category"` Title string `json:"title"` Body string `json:"body,omitempty"` Link string `json:"link,omitempty"` Metadata map[string]any `json:"metadata,omitempty"` ReadAt *time.Time `json:"read_at,omitempty"` CreatedAt time.Time `json:"created_at"` // Email-channel digest bookkeeping — internal, never serialized to // clients. GroupKey ties the same org event across users so the flush // loop can coalesce it into one email with every recipient in To. GroupKey string `json:"-"` EmailState string `json:"-"` EmailDueAt *time.Time `json:"-"` EmailAttempts int `json:"-"` // PreRead inserts the row already read: used when the user has the // in-app channel off but email on, so the feed stays the delivery // record without ringing the bell. PreRead bool `json:"-"` } // UpdateNotificationPreferencesRequest is the PUT payload. type UpdateNotificationPreferencesRequest struct { Preferences NotificationPreferences `json:"preferences"` } // DeviceToken is one push-capable device registration (APNs). type DeviceToken struct { ID uuid.UUID `json:"id"` UserID uuid.UUID `json:"user_id"` Platform string `json:"platform"` Token string `json:"token"` Environment string `json:"environment"` CreatedAt time.Time `json:"created_at"` LastSeenAt time.Time `json:"last_seen_at"` } // RegisterDeviceTokenRequest is the POST payload from the mobile app. type RegisterDeviceTokenRequest struct { Token string `json:"token" binding:"required"` Platform string `json:"platform"` Environment string `json:"environment"` }