Mobile notifications for iOS (#210)

* mobile: wire notification registration and listener

* implement backend components for push notifications

* refactor: agentic comment cleanup

* docs: use proper module name for particle processor

* set required env variables for push notifications

* bump version

* fix: always upsert push token on mobile start

* Revert "fix: always upsert push token on mobile start"

This reverts commit 90ff18a788.

* send push notifications regardless of online status
This commit was merged in pull request #210.
This commit is contained in:
Arjun Patel
2026-05-18 12:44:31 -07:00
committed by GitHub
parent a564ea819b
commit d262f734f0
61 changed files with 1682 additions and 531 deletions
+24 -41
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,
}
@@ -168,7 +172,7 @@ type DepotObject struct {
// Auth Handlers
// ============================================================================
// RequestSignInCode creates a human account if not already existent and sends a sign-in code
// RequestSignInCode auto-creates the human if missing, then emails a one-time code.
func (h *Handler) RequestSignInCode(w http.ResponseWriter, r *http.Request) {
var req RequestSignInCodeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -181,7 +185,6 @@ func (h *Handler) RequestSignInCode(w http.ResponseWriter, r *http.Request) {
return
}
// Auto-create human if doesn't exist
_, err := h.humanSvc.GetOrCreateByEmail(r.Context(), req.Email)
if err != nil {
slog.Error("failed to get or create human", "error", err, "email", req.Email)
@@ -189,7 +192,6 @@ func (h *Handler) RequestSignInCode(w http.ResponseWriter, r *http.Request) {
return
}
// Request sign-in code
if err := h.authSvc.RequestSignInCode(r.Context(), req.Email); err != nil {
slog.Error("failed to request sign-in code", "error", err, "email", req.Email)
http.Error(w, "internal server error", http.StatusInternalServerError)
@@ -199,7 +201,6 @@ func (h *Handler) RequestSignInCode(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// SignIn verifies the code and returns a session token
func (h *Handler) SignIn(w http.ResponseWriter, r *http.Request) {
var req SignInRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -212,7 +213,7 @@ func (h *Handler) SignIn(w http.ResponseWriter, r *http.Request) {
return
}
// Look up human first so we can store humanId in the session
// humanId is captured into the session so later requests don't re-resolve email → id.
hum, err := h.humanSvc.GetByEmail(r.Context(), req.Email)
if err != nil {
if errors.Is(err, human.ErrNotFound) {
@@ -244,9 +245,8 @@ func (h *Handler) SignIn(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(resp)
}
// FirebaseToken mints a Firebase custom token for the authenticated human so
// the client can signInWithCustomToken and have request.auth.uid populated in
// Firestore security rules.
// FirebaseToken mints a custom token so the client can signInWithCustomToken
// and have request.auth.uid populated in Firestore security rules.
func (h *Handler) FirebaseToken(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
@@ -265,7 +265,6 @@ func (h *Handler) FirebaseToken(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(FirebaseTokenResponse{Token: token})
}
// SignOut deletes the session from the token in headers
func (h *Handler) SignOut(w http.ResponseWriter, r *http.Request) {
token := extractBearerToken(r)
if token == "" {
@@ -282,7 +281,6 @@ func (h *Handler) SignOut(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// GetCurrentHuman returns the authenticated human
func (h *Handler) GetCurrentHuman(w http.ResponseWriter, r *http.Request) {
email, ok := middleware.EmailFromContext(r.Context())
if !ok {
@@ -310,7 +308,6 @@ type UpdateSettingsRequest struct {
EmailNotificationsEnabled *bool `json:"email_notifications_enabled"`
}
// UpdateSettings updates the authenticated human's settings
func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
@@ -339,7 +336,6 @@ func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
// Network Handlers
// ============================================================================
// CreateNetwork creates a new network
func (h *Handler) CreateNetwork(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
@@ -376,7 +372,6 @@ func (h *Handler) CreateNetwork(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(resp)
}
// ListNetworks retrieves networks for the authenticated human
func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
@@ -405,7 +400,6 @@ func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(resp)
}
// GetNetwork retrieves a specific network
func (h *Handler) GetNetwork(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
@@ -452,8 +446,8 @@ func (h *Handler) GetNetwork(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(resp)
}
// AddMembersToNetwork adds members to a network. Registered users are added as members,
// unregistered users receive email invitations.
// AddMembersToNetwork routes registered users into membership and emails an
// invitation to the rest.
func (h *Handler) AddMembersToNetwork(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
@@ -489,7 +483,6 @@ func (h *Handler) AddMembersToNetwork(w http.ResponseWriter, r *http.Request) {
return
}
// Resolve emails: registered users become members, unregistered get invitations
var memberHumanIds []string
var inviteEmails []string
for _, email := range req.EmailAddresses {
@@ -527,7 +520,6 @@ func (h *Handler) AddMembersToNetwork(w http.ResponseWriter, r *http.Request) {
}
}
// Return updated network
net, err := h.networkSvc.GetByID(r.Context(), networkID)
if err != nil {
slog.Error("failed to get network after adding members", "error", err, "network_id", networkID)
@@ -546,9 +538,8 @@ func (h *Handler) AddMembersToNetwork(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(resp)
}
// RemoveMemberFromNetwork removes a member from a network. Admin-only.
// Admins cannot remove themselves — doing so would leave networks.admin_human_id
// dangling. Removal of a non-member is a no-op (204).
// RemoveMemberFromNetwork is admin-only. Admins cannot remove themselves
// (would orphan networks.admin_human_id); removing a non-member is a no-op (204).
func (h *Handler) RemoveMemberFromNetwork(w http.ResponseWriter, r *http.Request) {
net, _, ok := h.loadNetworkForAdmin(w, r)
if !ok {
@@ -575,7 +566,6 @@ func (h *Handler) RemoveMemberFromNetwork(w http.ResponseWriter, r *http.Request
w.WriteHeader(http.StatusNoContent)
}
// ListInvitationsForNetwork returns pending invitations for a network
func (h *Handler) ListInvitationsForNetwork(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
@@ -621,7 +611,6 @@ func (h *Handler) ListInvitationsForNetwork(w http.ResponseWriter, r *http.Reque
json.NewEncoder(w).Encode(resp)
}
// ListMyInvitations returns pending invitations for the authenticated user
func (h *Handler) ListMyInvitations(w http.ResponseWriter, r *http.Request) {
email, ok := middleware.EmailFromContext(r.Context())
if !ok {
@@ -650,7 +639,6 @@ func (h *Handler) ListMyInvitations(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(resp)
}
// AcceptInvitation accepts a pending network invitation for the authenticated user
func (h *Handler) AcceptInvitation(w http.ResponseWriter, r *http.Request) {
email, ok := middleware.EmailFromContext(r.Context())
if !ok {
@@ -683,7 +671,6 @@ func (h *Handler) AcceptInvitation(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// RevokeInvitation revokes a pending invitation from a network
func (h *Handler) RevokeInvitation(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
@@ -728,7 +715,7 @@ func (h *Handler) RevokeInvitation(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// DownloadParticleMedia returns a fresh signed download URL for media/file particles
// DownloadParticleMedia returns a fresh signed URL for media/file particles.
func (h *Handler) DownloadParticleMedia(w http.ResponseWriter, r *http.Request) {
_, ok := middleware.EmailFromContext(r.Context())
if !ok {
@@ -755,7 +742,7 @@ func (h *Handler) DownloadParticleMedia(w http.ResponseWriter, r *http.Request)
// Depot Handlers
// ============================================================================
// PrepareUpload prepares an upload and returns a signed URL for direct upload to GCS
// PrepareUpload returns a signed URL for direct upload to GCS.
func (h *Handler) PrepareUpload(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
@@ -813,7 +800,6 @@ func (h *Handler) PrepareUpload(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(resp)
}
// ConfirmUpload confirms that an upload has been completed
func (h *Handler) ConfirmUpload(w http.ResponseWriter, r *http.Request) {
_, ok := middleware.EmailFromContext(r.Context())
if !ok {
@@ -881,7 +867,7 @@ type InviteWaitlistEntrantRequest struct {
// Waitlist Handlers
// ============================================================================
// AddToWaitlist adds an email to the waitlist (public, no auth)
// AddToWaitlist is public no auth required.
func (h *Handler) AddToWaitlist(w http.ResponseWriter, r *http.Request) {
var req AddToWaitlistRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -908,7 +894,7 @@ func (h *Handler) AddToWaitlist(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
}
// GetWaitlist returns all waitlist entries (admin-only)
// GetWaitlist is admin-only.
func (h *Handler) GetWaitlist(w http.ResponseWriter, r *http.Request) {
if !middleware.IsAdminFromContext(r.Context()) {
http.Error(w, "forbidden", http.StatusForbidden)
@@ -940,7 +926,7 @@ func (h *Handler) GetWaitlist(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(resp)
}
// GetWaitlistEntry returns a single waitlist entry by email (admin-only)
// GetWaitlistEntry is admin-only.
func (h *Handler) GetWaitlistEntry(w http.ResponseWriter, r *http.Request) {
if !middleware.IsAdminFromContext(r.Context()) {
http.Error(w, "forbidden", http.StatusForbidden)
@@ -968,7 +954,7 @@ func (h *Handler) GetWaitlistEntry(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(waitlistEntryToDTO(entry))
}
// InviteWaitlistEntrant marks a waitlist entry as invited (admin-only)
// InviteWaitlistEntrant is admin-only.
func (h *Handler) InviteWaitlistEntrant(w http.ResponseWriter, r *http.Request) {
if !middleware.IsAdminFromContext(r.Context()) {
http.Error(w, "forbidden", http.StatusForbidden)
@@ -1079,7 +1065,7 @@ func (h *Handler) GetLivekitToken(w http.ResponseWriter, r *http.Request) {
return
}
// Compose room name encoding both network and stream IDs for webhook resolution
// Encode both IDs in the room name so the webhook handler can resolve them.
roomName := req.NetworkId + "/" + req.StreamId
token, err := h.livekitClient.GetJoinToken(roomName, humanId, humanEmail)
@@ -1093,9 +1079,8 @@ func (h *Handler) GetLivekitToken(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(GetLivekitTokenResponse{Token: token, ServerUrl: h.livekitClient.ServerUrl()})
}
// HandleLivekitWebhook processes LiveKit webhook events for huddle presence.
// It verifies the webhook signature (not user auth), then updates the stream
// particle's huddle_active_participants field in Firestore.
// HandleLivekitWebhook verifies the webhook signature (not user auth) and
// reconciles huddle_active_participants on the stream particle in Firestore.
func (h *Handler) HandleLivekitWebhook(w http.ResponseWriter, r *http.Request) {
event, err := webhook.ReceiveWebhookEvent(r, h.livekitClient.KeyProvider())
if err != nil {
@@ -1109,13 +1094,12 @@ func (h *Handler) HandleLivekitWebhook(w http.ResponseWriter, r *http.Request) {
switch eventType {
case "participant_joined", "participant_left", "room_finished":
// Handle these events
// fall through
default:
w.WriteHeader(http.StatusOK)
return
}
// Parse room name to extract networkId and streamId
roomName := event.GetRoom().GetName()
parts := strings.SplitN(roomName, "/", 2)
if len(parts) != 2 {
@@ -1131,14 +1115,13 @@ func (h *Handler) HandleLivekitWebhook(w http.ResponseWriter, r *http.Request) {
var participantIds []string
if eventType == "room_finished" {
// Room is done — clear the participants
participantIds = []string{}
} else {
// Use ListParticipants for authoritative state (avoids drift from missed webhooks)
// Authoritative list avoids drift from missed/out-of-order webhooks.
participants, err := h.livekitClient.ListParticipants(ctx, roomName)
if err != nil {
slog.Error("failed to list participants", "error", err, "room", roomName)
// Return 200 so LiveKit doesn't retry
// 200 to suppress LiveKit retries.
w.WriteHeader(http.StatusOK)
return
}