Stage 7 · Master
Phase 14 — Notification Service
Push Notifications
Fan a notification out across a resident's registered devices, retire tokens the provider reports as dead, and let category preferences silence a channel without touching delivery code.
A Resident Is Not One Device
Unlike email or SMS, push delivery targets a device, not a person — and a resident may have a phone and a tablet both registered. The push port and the device registry are separate concerns: the registry tracks which tokens currently belong to which resident, and the sender fans a single logical notification out to every live token.
CREATE TABLE device_tokens (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL,
resident_id uuid NOT NULL,
token text NOT NULL,
platform text NOT NULL, -- 'ios' | 'android'
registered_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (organization_id, token)
);
CREATE INDEX idx_device_tokens_resident ON device_tokens (organization_id, resident_id);The uniqueness constraint is on the token itself, not (resident_id, token) — the same physical device token cannot simultaneously be registered to two different residents, which would otherwise let a shared or resold device leak notifications to the wrong person.
package domain
import "context"
type PushMessage struct {
TenantID string
Token string
Title string
Body string
// Silent marks a data-only push with no user-visible banner or sound —
// used for background sync signals rather than alerts.
Silent bool
}
// ErrTokenUnregistered means the provider reports this specific token as
// no longer valid — the app was uninstalled, or the token rotated. It is
// permanent for THIS token, but says nothing about the resident's other
// devices.
type PushResult struct {
MessageID ProviderMessageID
Unregistered bool
}
type PushSender interface {
Send(ctx context.Context, msg PushMessage) (PushResult, error)
}PushResult.Unregistered is a structured signal, not an error the caller has to pattern-match out of a generic failure — the fan-out logic below uses it directly to decide which tokens to retire.
Retire Tokens the Provider Tells You Are Dead
package application
import (
"context"
"github.com/hoa-platform/backend/services/notification-service/internal/domain"
)
type DeviceTokenStore interface {
TokensFor(ctx context.Context, tenantID, residentID string) ([]string, error)
Remove(ctx context.Context, tenantID, token string) error
}
type SendPush struct {
sender domain.PushSender
tokens DeviceTokenStore
}
func NewSendPush(sender domain.PushSender, tokens DeviceTokenStore) *SendPush {
return &SendPush{sender: sender, tokens: tokens}
}
// Execute fans one logical notification out to every device the resident
// currently has registered. A failure on one token never blocks delivery
// to the resident's other devices.
func (s *SendPush) Execute(ctx context.Context, tenantID, residentID, title, body string) []error {
deviceTokens, err := s.tokens.TokensFor(ctx, tenantID, residentID)
if err != nil {
return []error{err}
}
var errs []error
for _, token := range deviceTokens {
result, err := s.sender.Send(ctx, domain.PushMessage{TenantID: tenantID, Token: token, Title: title, Body: body})
if result.Unregistered {
if removeErr := s.tokens.Remove(ctx, tenantID, token); removeErr != nil {
errs = append(errs, removeErr)
}
continue
}
if err != nil {
errs = append(errs, err)
}
}
return errs
}Retiring a token happens inline, in the same call that discovered it was dead — waiting for a separate cleanup job would mean every future send to that resident keeps paying the cost of a doomed request to a token that will never accept it again.
Preferences Are Per Category, Not All-or-Nothing
A resident who mutes 'maintenance updates' should still receive 'security alerts' as push. The same notification_preferences table from the SMS lesson already models this — category is part of its primary key — so push reuses the identical PreferenceChecker interface instead of inventing a push-specific preference model.
- payment_reminders — muted by default is unusual, most residents keep this on
- maintenance_updates — frequently muted once a request is resolved
- security_alerts — cannot be muted; the preference check is skipped entirely for this category
- community_notices — muted independently of maintenance_updates
security_alerts bypassing the preference check is a deliberate exception, not an oversight — it must be documented exactly where the bypass happens, or a future refactor that 'cleans up' the special case could silently make a break-in alert mutable.
Confirm Multi-Device Fan-Out and Token Cleanup
Applied exercise
Add a quiet-hours override for non-urgent push
Push notifications for maintenance_updates are waking residents overnight, similar to the SMS complaint from the previous lesson.
- Decide whether quiet-hours logic belongs in SendPush directly or in a decorator wrapping it.
- Ensure security_alerts remains exempt from any quiet-hours deferral, matching its exemption from category preferences.
- Write a test proving a maintenance_updates push during quiet hours is deferred, not dropped.
- Write a second test proving security_alerts ignores the quiet-hours window entirely.
Deliverable
An updated push send path (or decorator) and two new tests distinguishing exempt from non-exempt categories.
Completion checks
- The two tests would fail if someone accidentally applied quiet hours uniformly to every category.
- Deferred notifications are provably delivered later, not dropped.
Why does the uniqueness constraint on device_tokens cover only the token column, rather than (resident_id, token)?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.