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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user