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:
@@ -25,12 +25,8 @@ type PortalSessionResponse struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// GetNetworkUsage returns the freemium quota state for the authenticated
|
||||
// caller's current network: how many messages they've used today, the daily
|
||||
// limit (null for pro), and when the counter resets.
|
||||
//
|
||||
// Authorization: any network member may read (not admin-only) since the UI
|
||||
// surfaces this to every sender.
|
||||
// GetNetworkUsage reports today's usage, daily limit (nil on pro), and reset
|
||||
// time. Open to any network member since the UI surfaces it to every sender.
|
||||
func (h *Handler) GetNetworkUsage(w http.ResponseWriter, r *http.Request) {
|
||||
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
||||
if !ok {
|
||||
@@ -170,8 +166,8 @@ func (h *Handler) HandleStripeWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// loadNetworkForAdmin resolves the {id} path param and verifies the caller
|
||||
// is the network's admin. On failure it writes the HTTP error and returns ok=false.
|
||||
// Resolves {id}, verifies the caller is admin. On failure writes the HTTP
|
||||
// error and returns ok=false.
|
||||
func (h *Handler) loadNetworkForAdmin(w http.ResponseWriter, r *http.Request) (*network.Network, string, bool) {
|
||||
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
||||
if !ok {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
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 token; ON CONFLICT transparently re-binds
|
||||
// a token to a new human after a device-level account switch.
|
||||
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 returns 204 whether or not the token existed (idempotent).
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user