implement backend components for push notifications

This commit is contained in:
talksik
2026-05-18 10:31:16 -07:00
parent 1618cb58cb
commit 6f476fe773
16 changed files with 904 additions and 60 deletions
+4
View File
@@ -15,6 +15,7 @@ import (
"github.com/flowy-live/llink/internal/billing"
"github.com/flowy-live/llink/internal/depot"
"github.com/flowy-live/llink/internal/human"
"github.com/flowy-live/llink/internal/human/pushnotify"
"github.com/flowy-live/llink/internal/livekit"
"github.com/flowy-live/llink/internal/middleware"
"github.com/flowy-live/llink/internal/network"
@@ -32,6 +33,7 @@ type Handler struct {
depotSvc depot.Service
waitlistSvc waitlist.Service
billingSvc billing.Service
pushTokenSvc pushnotify.Service
livekitClient livekit.Client
firestoreClient *firestore.Client
}
@@ -44,6 +46,7 @@ func NewHandler(
depotSvc depot.Service,
waitlistSvc waitlist.Service,
billingSvc billing.Service,
pushTokenSvc pushnotify.Service,
livekitClient livekit.Client,
firestoreClient *firestore.Client,
) *Handler {
@@ -55,6 +58,7 @@ func NewHandler(
depotSvc: depotSvc,
waitlistSvc: waitlistSvc,
billingSvc: billingSvc,
pushTokenSvc: pushTokenSvc,
livekitClient: livekitClient,
firestoreClient: firestoreClient,
}
+85
View File
@@ -0,0 +1,85 @@
package handler
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"github.com/flowy-live/llink/internal/human/pushnotify"
"github.com/flowy-live/llink/internal/middleware"
)
type RegisterPushTokenRequest struct {
Token string `json:"token"`
Platform string `json:"platform"`
AppVersion string `json:"app_version"`
}
type UnregisterPushTokenRequest struct {
Token string `json:"token"`
}
// RegisterPushToken upserts an Expo push token against the authenticated human.
// Re-binding a token to a new human (e.g., after a device-level account switch)
// happens transparently via ON CONFLICT.
func (h *Handler) RegisterPushToken(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req RegisterPushTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
err := h.pushTokenSvc.Register(r.Context(), humanId, pushnotify.RegisterInput{
Token: req.Token,
Platform: pushnotify.Platform(req.Platform),
AppVersion: req.AppVersion,
})
if err != nil {
if errors.Is(err, pushnotify.ErrInvalidPlatform) || errors.Is(err, pushnotify.ErrInvalidToken) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
slog.Error("failed to register push token", "error", err, "humanId", humanId)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// UnregisterPushToken removes a push token belonging to the authenticated human.
// Returns 204 even if the token wasn't found — idempotent from the client's POV.
func (h *Handler) UnregisterPushToken(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req UnregisterPushTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.Token == "" {
http.Error(w, "token is required", http.StatusBadRequest)
return
}
err := h.pushTokenSvc.Unregister(r.Context(), humanId, req.Token)
if err != nil && !errors.Is(err, pushnotify.ErrNotFound) {
slog.Error("failed to unregister push token", "error", err, "humanId", humanId)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
+140
View File
@@ -0,0 +1,140 @@
package pushnotify
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const (
expoPushAPIURL = "https://exp.host/--/api/v2/push/send"
// expoMaxBatchSize is the documented per-request cap on push messages.
expoMaxBatchSize = 100
// Ticket error codes returned by Expo Push API. The only one we act on is
// DeviceNotRegistered — others are logged but not retried (per product call).
ExpoErrorDeviceNotRegistered = "DeviceNotRegistered"
)
// Message is one push to one device. Sound defaults to "default" when empty.
type Message struct {
To string `json:"to"`
Title string `json:"title,omitempty"`
Body string `json:"body,omitempty"`
Data map[string]any `json:"data,omitempty"`
Sound string `json:"sound,omitempty"`
}
// Ticket is the synchronous response Expo returns per message. Status is
// either "ok" or "error". On error, Details["error"] carries the code (e.g.
// "DeviceNotRegistered", "MessageTooBig", "InvalidCredentials").
type Ticket struct {
Status string `json:"status"`
ID string `json:"id,omitempty"`
Message string `json:"message,omitempty"`
Details map[string]any `json:"details,omitempty"`
}
// ExpoClient is a minimal HTTP client for the Expo Push API. It does NOT poll
// receipts and does NOT retry.
type ExpoClient struct {
http *http.Client
accessToken string
}
func NewExpoClient(accessToken string) *ExpoClient {
return &ExpoClient{
http: &http.Client{Timeout: 15 * time.Second},
accessToken: accessToken,
}
}
type expoSendResponse struct {
Data []Ticket `json:"data"`
Errors []map[string]any `json:"errors,omitempty"`
}
// Send delivers messages in batches of up to expoMaxBatchSize. Returned tickets
// preserve the input order across batches: tickets[i] corresponds to msgs[i].
// A request-level error (network, 5xx, malformed body) aborts the remaining
// batches and is returned to the caller along with whatever tickets succeeded.
func (c *ExpoClient) Send(ctx context.Context, msgs []Message) ([]Ticket, error) {
if len(msgs) == 0 {
return nil, nil
}
for i := range msgs {
if msgs[i].Sound == "" {
msgs[i].Sound = "default"
}
}
tickets := make([]Ticket, 0, len(msgs))
for start := 0; start < len(msgs); start += expoMaxBatchSize {
end := start + expoMaxBatchSize
if end > len(msgs) {
end = len(msgs)
}
batch := msgs[start:end]
batchTickets, err := c.sendBatch(ctx, batch)
tickets = append(tickets, batchTickets...)
if err != nil {
return tickets, fmt.Errorf("expo push batch [%d:%d]: %w", start, end, err)
}
}
return tickets, nil
}
func (c *ExpoClient) sendBatch(ctx context.Context, batch []Message) ([]Ticket, error) {
body, err := json.Marshal(batch)
if err != nil {
return nil, fmt.Errorf("marshal batch: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, expoPushAPIURL, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Accept-Encoding", "gzip, deflate")
if c.accessToken != "" {
req.Header.Set("Authorization", "Bearer "+c.accessToken)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("expo push api returned %d: %s", resp.StatusCode, truncate(string(raw), 512))
}
var parsed expoSendResponse
if err := json.Unmarshal(raw, &parsed); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
if len(parsed.Data) != len(batch) {
return parsed.Data, fmt.Errorf("expo returned %d tickets for %d messages", len(parsed.Data), len(batch))
}
return parsed.Data, nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
+194
View File
@@ -0,0 +1,194 @@
package pushnotify
import (
"context"
"errors"
"fmt"
"log/slog"
pbpusher "github.com/flowy-live/llink/genproto/llink/pusher"
"github.com/flowy-live/llink/internal/network"
"google.golang.org/grpc"
)
// PusherClient is the subset of the pusher gRPC service the notifier needs.
// Defined here (rather than depending on the generated client interface) so
// tests can supply a fake without standing up a gRPC server.
type PusherClient interface {
IsOnline(ctx context.Context, in *pbpusher.IsOnlineRequest, opts ...grpc.CallOption) (*pbpusher.IsOnlineResponse, error)
}
// NotifyInput is everything the notifier needs to know about a single newly-created particle.
type NotifyInput struct {
NetworkID string
SenderHumanID string
SenderEmailPrefix string
ParticleID string
// One of "text", "media", "file", "quest", "paper". Containers (stream,
// folder) are dropped by the caller before reaching the notifier.
ParticleKind string
// Parent stream context — drives the title and the recipient set.
StreamID string
StreamName string
StreamVisibleTo []string
// Body — already formatted by the caller (e.g. truncated text, "Sent a
// voice message"). Title is derived inside the notifier.
Body string
}
// Notifier orchestrates the per-particle fanout:
// 1. Resolve recipients (stream visibility ∩ network members, minus sender).
// 2. Filter out anyone with an active WebSocket connection.
// 3. Look up each remaining human's push tokens.
// 4. POST a single batched request to Expo.
// 5. Delete any token Expo reports as DeviceNotRegistered.
type Notifier struct {
networkR network.Reader
tokens Service
pusher PusherClient
expo *ExpoClient
}
func NewNotifier(networkR network.Reader, tokens Service, pusher PusherClient, expo *ExpoClient) *Notifier {
return &Notifier{
networkR: networkR,
tokens: tokens,
pusher: pusher,
expo: expo,
}
}
func (n *Notifier) NotifyParticleCreated(ctx context.Context, in NotifyInput) error {
if in.NetworkID == "" || in.ParticleID == "" {
return nil
}
members, err := n.networkR.ListMembers(ctx, in.NetworkID)
if err != nil {
return fmt.Errorf("list network members: %w", err)
}
recipients := network.ResolveVisibility(in.StreamVisibleTo, members)
recipients = filterOut(recipients, in.SenderHumanID)
if len(recipients) == 0 {
return nil
}
online, err := n.queryOnline(ctx, recipients)
if err != nil {
return fmt.Errorf("presence lookup: %w", err)
}
offline := make([]string, 0, len(recipients))
for _, id := range recipients {
if !online[id] {
offline = append(offline, id)
}
}
if len(offline) == 0 {
return nil
}
tokens, err := n.tokens.ListForHumans(ctx, offline)
if err != nil {
return fmt.Errorf("token lookup: %w", err)
}
if len(tokens) == 0 {
return nil
}
msgs := buildMessages(tokens, in)
tickets, sendErr := n.expo.Send(ctx, msgs)
slog.Info("pushnotify: dispatch",
"networkID", in.NetworkID,
"particleID", in.ParticleID,
"recipients", len(recipients),
"offline", len(offline),
"tokens", len(tokens),
"sent", len(tickets),
)
n.cleanupDeadTokens(ctx, msgs, tickets)
if sendErr != nil {
return fmt.Errorf("expo send: %w", sendErr)
}
return nil
}
func (n *Notifier) queryOnline(ctx context.Context, humanIDs []string) (map[string]bool, error) {
resp, err := n.pusher.IsOnline(ctx, &pbpusher.IsOnlineRequest{HumanIds: humanIDs})
if err != nil {
return nil, err
}
return resp.Online, nil
}
// cleanupDeadTokens DELETEs any token Expo reports as DeviceNotRegistered.
// This is the one feedback signal we honor — other ticket errors (e.g.
// MessageTooBig, RateLimit) are logged but never retried.
func (n *Notifier) cleanupDeadTokens(ctx context.Context, msgs []Message, tickets []Ticket) {
for i, t := range tickets {
if i >= len(msgs) {
break
}
if t.Status != "error" || t.Details == nil {
continue
}
code, _ := t.Details["error"].(string)
if code != ExpoErrorDeviceNotRegistered {
if t.Status == "error" {
slog.Warn("pushnotify: ticket error", "code", code, "message", t.Message, "to", msgs[i].To)
}
continue
}
if err := n.tokens.DeleteByToken(ctx, msgs[i].To); err != nil && !errors.Is(err, ErrNotFound) {
slog.Error("pushnotify: failed to delete dead token", "error", err, "token", msgs[i].To)
} else {
slog.Info("pushnotify: removed unregistered token", "token", msgs[i].To)
}
}
}
func buildMessages(tokens []*PushToken, in NotifyInput) []Message {
title := in.SenderEmailPrefix
if in.StreamName != "" {
title = in.SenderEmailPrefix + " in " + in.StreamName
}
data := map[string]any{
"kind": "particle_created",
"network_id": in.NetworkID,
"stream_id": in.StreamID,
"particle_id": in.ParticleID,
"sender_human_id": in.SenderHumanID,
"particle_kind": in.ParticleKind,
}
msgs := make([]Message, 0, len(tokens))
for _, t := range tokens {
msgs = append(msgs, Message{
To: t.Token,
Title: title,
Body: in.Body,
Data: data,
})
}
return msgs
}
func filterOut(ids []string, exclude string) []string {
if exclude == "" {
return ids
}
out := ids[:0:len(ids)]
for _, id := range ids {
if id != exclude {
out = append(out, id)
}
}
return out
}
@@ -0,0 +1,95 @@
package pushnotify
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type repository interface {
upsert(ctx context.Context, t *PushToken) error
deleteForHuman(ctx context.Context, humanID, token string) error
deleteByToken(ctx context.Context, token string) error
listForHumans(ctx context.Context, humanIDs []string) ([]*PushToken, error)
}
type repositoryImpl struct {
pool *pgxpool.Pool
}
func newRepository(pool *pgxpool.Pool) repository {
return &repositoryImpl{pool: pool}
}
func (r *repositoryImpl) upsert(ctx context.Context, t *PushToken) error {
_, err := r.pool.Exec(ctx,
`INSERT INTO push_tokens (token, human_id, platform, app_version)
VALUES ($1, $2, $3, NULLIF($4, ''))
ON CONFLICT (token) DO UPDATE SET
human_id = EXCLUDED.human_id,
platform = EXCLUDED.platform,
app_version = EXCLUDED.app_version,
last_seen_at = NOW()`,
t.Token, t.HumanID, string(t.Platform), t.AppVersion,
)
return err
}
func (r *repositoryImpl) deleteForHuman(ctx context.Context, humanID, token string) error {
result, err := r.pool.Exec(ctx,
`DELETE FROM push_tokens WHERE human_id = $1 AND token = $2`,
humanID, token,
)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (r *repositoryImpl) deleteByToken(ctx context.Context, token string) error {
_, err := r.pool.Exec(ctx,
`DELETE FROM push_tokens WHERE token = $1`,
token,
)
return err
}
func (r *repositoryImpl) listForHumans(ctx context.Context, humanIDs []string) ([]*PushToken, error) {
if len(humanIDs) == 0 {
return nil, nil
}
rows, err := r.pool.Query(ctx,
`SELECT token, human_id, platform, app_version, created_at, last_seen_at
FROM push_tokens
WHERE human_id = ANY($1)`,
humanIDs,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
defer rows.Close()
var tokens []*PushToken
for rows.Next() {
var t PushToken
var appVersion *string
var platform string
if err := rows.Scan(&t.Token, &t.HumanID, &platform, &appVersion, &t.CreatedAt, &t.LastSeenAt); err != nil {
return nil, err
}
t.Platform = Platform(platform)
if appVersion != nil {
t.AppVersion = *appVersion
}
tokens = append(tokens, &t)
}
return tokens, rows.Err()
}
+70
View File
@@ -0,0 +1,70 @@
package pushnotify
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
)
// Service is the full surface for per-device push token storage. HTTP handlers
// use Register/Unregister; the worker's notifier uses ListForHumans and
// DeleteByToken. Both consumers share the same underlying repository.
type Service interface {
// Register upserts a token for the given human. Returns ErrInvalidToken /
// ErrInvalidPlatform on bad input.
Register(ctx context.Context, humanID string, in RegisterInput) error
// Unregister removes a token, scoped to the calling human so a user can't
// delete another user's token. Returns ErrNotFound if the token doesn't
// belong to humanID (or doesn't exist).
Unregister(ctx context.Context, humanID, token string) error
// ListForHumans returns every push token belonging to any of the given
// human IDs. Returns an empty slice when nothing matches.
ListForHumans(ctx context.Context, humanIDs []string) ([]*PushToken, error)
// DeleteByToken removes a token regardless of owning human. Used by the
// notifier to clean up after Expo returns DeviceNotRegistered.
DeleteByToken(ctx context.Context, token string) error
}
type RegisterInput struct {
Token string
Platform Platform
AppVersion string
}
type serviceImpl struct {
repo repository
}
func NewService(pool *pgxpool.Pool) Service {
return &serviceImpl{repo: newRepository(pool)}
}
func (s *serviceImpl) Register(ctx context.Context, humanID string, in RegisterInput) error {
if !in.Platform.Valid() {
return ErrInvalidPlatform
}
if !IsValidExpoToken(in.Token) {
return ErrInvalidToken
}
return s.repo.upsert(ctx, &PushToken{
Token: in.Token,
HumanID: humanID,
Platform: in.Platform,
AppVersion: in.AppVersion,
})
}
func (s *serviceImpl) Unregister(ctx context.Context, humanID, token string) error {
if token == "" {
return ErrInvalidToken
}
return s.repo.deleteForHuman(ctx, humanID, token)
}
func (s *serviceImpl) ListForHumans(ctx context.Context, humanIDs []string) ([]*PushToken, error) {
return s.repo.listForHumans(ctx, humanIDs)
}
func (s *serviceImpl) DeleteByToken(ctx context.Context, token string) error {
return s.repo.deleteByToken(ctx, token)
}
+43
View File
@@ -0,0 +1,43 @@
// Package pushnotify owns mobile push notification delivery: storage of per-device
// Expo push tokens, and the worker-side orchestration of sending notifications
// to offline recipients via the Expo Push API.
package pushnotify
import (
"errors"
"strings"
"time"
)
type Platform string
const (
PlatformIOS Platform = "ios"
PlatformAndroid Platform = "android"
)
func (p Platform) Valid() bool {
return p == PlatformIOS || p == PlatformAndroid
}
type PushToken struct {
Token string
HumanID string
Platform Platform
AppVersion string
CreatedAt time.Time
LastSeenAt time.Time
}
var (
ErrInvalidPlatform = errors.New("invalid platform")
ErrInvalidToken = errors.New("invalid expo push token")
ErrNotFound = errors.New("push token not found")
)
// IsValidExpoToken matches the two prefix formats Expo currently uses.
// We don't validate the inner contents — Expo's server will reject malformed
// tokens with a per-message error and we'll clean those up via DeviceNotRegistered.
func IsValidExpoToken(token string) bool {
return strings.HasPrefix(token, "ExponentPushToken[") || strings.HasPrefix(token, "ExpoPushToken[")
}
+9
View File
@@ -16,6 +16,9 @@ type Reader interface {
ListForHuman(ctx context.Context, humanId string) ([]*Network, error)
// IsMember returns ErrInvalidHumanId if humanId is empty.
IsMember(ctx context.Context, networkID, humanId string) (bool, error)
// ListMembers returns all human IDs belonging to the network.
// Returns an empty slice if the network doesn't exist.
ListMembers(ctx context.Context, networkID string) ([]string, error)
// ListAll returns all networks with their members
ListAll(ctx context.Context) ([]*Network, error)
// ListAllMemberships returns humanId -> networkIds for every human with at
@@ -69,6 +72,12 @@ func (r *readerImpl) IsMember(ctx context.Context, networkID, humanId string) (b
return r.repo.isMember(ctx, networkID, humanId)
}
func (r *readerImpl) ListMembers(ctx context.Context, networkID string) ([]string, error) {
// Admin is guaranteed to be in network_members: Create() calls AddMembers
// for the admin, and RemoveMemberFromNetwork rejects admin removal.
return r.repo.getMemberHumanIds(ctx, networkID)
}
func (r *readerImpl) ListAll(ctx context.Context) ([]*Network, error) {
return r.repo.listAll(ctx)
}
+41
View File
@@ -0,0 +1,41 @@
package network
import "strings"
// ResolveVisibility expands a stream particle's visible_to entries into the set
// of human IDs that should see (and thus be notified about) activity in that
// stream. Entries are formatted as `human:{id}` for a specific human or
// `network:{id}` to expand to every member of the surrounding network.
//
// networkMembers must contain every human currently in the network (members +
// admin). visible_to entries that point to humans no longer in the network are
// dropped — they may have been removed since the stream was created.
//
// Returns a deduped slice; ordering is not stable.
func ResolveVisibility(visibleTo []string, networkMembers []string) []string {
memberSet := make(map[string]bool, len(networkMembers))
for _, id := range networkMembers {
memberSet[id] = true
}
result := make(map[string]bool)
for _, entry := range visibleTo {
switch {
case strings.HasPrefix(entry, "human:"):
id := strings.TrimPrefix(entry, "human:")
if memberSet[id] {
result[id] = true
}
case strings.HasPrefix(entry, "network:"):
for id := range memberSet {
result[id] = true
}
}
}
out := make([]string, 0, len(result))
for id := range result {
out = append(out, id)
}
return out
}