Files

178 lines
7.2 KiB
Go

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"`
}