Mobile notifications for iOS #210
+1
-1
@@ -46,7 +46,7 @@ migrate-prod:
|
|||||||
SKAFFOLD_DEFAULT_REPO=$(PROD_REPO) skaffold run -p migrations --kube-context prod --tail
|
SKAFFOLD_DEFAULT_REPO=$(PROD_REPO) skaffold run -p migrations --kube-context prod --tail
|
||||||
|
|
||||||
# ---- Deploy ----
|
# ---- Deploy ----
|
||||||
# Use MODULE=orion or MODULE=worker or MODULE=pusher or MODULE=emailnotifierjob to deploy a single service, e.g.:
|
# Use MODULE=orion or MODULE=particleprocessor or MODULE=pusher or MODULE=emailnotifierjob to deploy a single service, e.g.:
|
||||||
# make deploy-dev MODULE=orion
|
# make deploy-dev MODULE=orion
|
||||||
|
|
||||||
.PHONY: deploy-dev
|
.PHONY: deploy-dev
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
# Orion
|
# Orion
|
||||||
|
|
||||||
API server and worker services for llink.
|
API server, jobs, and worker services for llink.
|
||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ make deploy-prod
|
|||||||
|
|
||||||
# Deploy a single service
|
# Deploy a single service
|
||||||
make deploy-dev MODULE=orion
|
make deploy-dev MODULE=orion
|
||||||
make deploy-dev MODULE=worker
|
make deploy-dev MODULE=particleprocessor
|
||||||
make deploy-dev MODULE=pusher
|
make deploy-dev MODULE=pusher
|
||||||
make deploy-dev MODULE=emailnotifierjob
|
make deploy-dev MODULE=emailnotifierjob
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"cloud.google.com/go/firestore"
|
"cloud.google.com/go/firestore"
|
||||||
@@ -21,22 +20,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// Only notify about streams with activity in the last 24 hours
|
maxActivityAge = 24 * time.Hour // ignore streams idle longer than this
|
||||||
maxActivityAge = 24 * time.Hour
|
unreadThreshold = 10 * time.Minute // grace window before a message is "unread"
|
||||||
// Minimum time a message must be unread before we consider notifying
|
emailCooldown = 12 * time.Hour // min gap between emails to the same user
|
||||||
unreadThreshold = 10 * time.Minute
|
|
||||||
// Minimum time between emails to the same user
|
|
||||||
emailCooldown = 12 * time.Hour
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Initialize Postgres
|
|
||||||
db.Init()
|
db.Init()
|
||||||
defer db.Cleanup()
|
defer db.Cleanup()
|
||||||
|
|
||||||
// Initialize Firestore
|
|
||||||
gcpProject := utils.MustGetEnv("GCP_PROJECT")
|
gcpProject := utils.MustGetEnv("GCP_PROJECT")
|
||||||
firestoreClient, err := firestore.NewClient(ctx, gcpProject)
|
firestoreClient, err := firestore.NewClient(ctx, gcpProject)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -45,7 +39,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer firestoreClient.Close()
|
defer firestoreClient.Close()
|
||||||
|
|
||||||
// Initialize aero (email) gRPC client
|
|
||||||
aeroAddr := utils.MustGetEnv("AERO_ADDR")
|
aeroAddr := utils.MustGetEnv("AERO_ADDR")
|
||||||
aeroConn, err := grpc.NewClient(aeroAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
aeroConn, err := grpc.NewClient(aeroAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -55,7 +48,6 @@ func main() {
|
|||||||
defer aeroConn.Close()
|
defer aeroConn.Close()
|
||||||
aeroSvc := pbaero.NewPrimaryClient(aeroConn)
|
aeroSvc := pbaero.NewPrimaryClient(aeroConn)
|
||||||
|
|
||||||
// Initialize pusher gRPC client
|
|
||||||
pusherAddr := utils.MustGetEnv("PUSHER_GRPC_ADDR")
|
pusherAddr := utils.MustGetEnv("PUSHER_GRPC_ADDR")
|
||||||
pusherConn, err := grpc.NewClient(pusherAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
pusherConn, err := grpc.NewClient(pusherAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -65,7 +57,6 @@ func main() {
|
|||||||
defer pusherConn.Close()
|
defer pusherConn.Close()
|
||||||
pusherSvc := pbpusher.NewPusherServiceClient(pusherConn)
|
pusherSvc := pbpusher.NewPusherServiceClient(pusherConn)
|
||||||
|
|
||||||
// Initialize services
|
|
||||||
humanSvc := human.NewService(db.Pool())
|
humanSvc := human.NewService(db.Pool())
|
||||||
networkSvc := network.NewReader(db.Pool())
|
networkSvc := network.NewReader(db.Pool())
|
||||||
|
|
||||||
@@ -87,13 +78,11 @@ func runNotificationCycle(
|
|||||||
) error {
|
) error {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
// Load all networks
|
|
||||||
networks, err := networkReader.ListAll(ctx)
|
networks, err := networkReader.ListAll(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("listing networks: %w", err)
|
return fmt.Errorf("listing networks: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load all humans into a lookup map
|
|
||||||
allHumans, err := humanSvc.ListAll(ctx)
|
allHumans, err := humanSvc.ListAll(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("listing humans: %w", err)
|
return fmt.Errorf("listing humans: %w", err)
|
||||||
@@ -103,11 +92,9 @@ func runNotificationCycle(
|
|||||||
humansById[h.ID] = h
|
humansById[h.ID] = h
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track which streams each human is behind on, and the latest activity across those streams
|
|
||||||
behindCounts := map[string]int{}
|
behindCounts := map[string]int{}
|
||||||
latestActivity := map[string]time.Time{}
|
latestActivity := map[string]time.Time{}
|
||||||
|
|
||||||
// Get all currently connected humans
|
|
||||||
allOnline := map[string]bool{}
|
allOnline := map[string]bool{}
|
||||||
onlineResp, err := pusherSvc.GetOnlineHumanIds(ctx, &pbpusher.GetOnlineHumanIdsRequest{})
|
onlineResp, err := pusherSvc.GetOnlineHumanIds(ctx, &pbpusher.GetOnlineHumanIdsRequest{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -119,42 +106,29 @@ func runNotificationCycle(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, net := range networks {
|
for _, net := range networks {
|
||||||
// Build set of all member humanIds for this network (members + admin)
|
|
||||||
networkMembers := make(map[string]bool, len(net.MemberHumanIds)+1)
|
|
||||||
for _, id := range net.MemberHumanIds {
|
|
||||||
networkMembers[id] = true
|
|
||||||
}
|
|
||||||
networkMembers[net.AdminHumanId] = true
|
|
||||||
|
|
||||||
// Query Firestore for open streams in this network
|
|
||||||
streams, err := getOpenStreams(ctx, fsClient, net.ID)
|
streams, err := getOpenStreams(ctx, fsClient, net.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to query streams", "networkId", net.ID, "error", err)
|
slog.Error("failed to query streams", "networkId", net.ID, "error", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// net.MemberHumanIds already includes the admin.
|
||||||
for _, stream := range streams {
|
for _, stream := range streams {
|
||||||
if stream.LastChildCreatedAt == nil {
|
if stream.LastChildCreatedAt == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Skip streams with no recent activity
|
|
||||||
if now.Sub(*stream.LastChildCreatedAt) > maxActivityAge {
|
if now.Sub(*stream.LastChildCreatedAt) > maxActivityAge {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Skip if the latest message is too fresh (within threshold)
|
|
||||||
if now.Sub(*stream.LastChildCreatedAt) < unreadThreshold {
|
if now.Sub(*stream.LastChildCreatedAt) < unreadThreshold {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve members from visible_to
|
for _, humanId := range network.ResolveVisibility(stream.VisibleTo, net.MemberHumanIds) {
|
||||||
members := resolveMembers(stream.VisibleTo, networkMembers)
|
|
||||||
|
|
||||||
for humanId := range members {
|
|
||||||
marker, hasMarker := stream.PlaybackMarkers[humanId]
|
marker, hasMarker := stream.PlaybackMarkers[humanId]
|
||||||
if hasMarker && !marker.Before(*stream.LastChildCreatedAt) {
|
if hasMarker && !marker.Before(*stream.LastChildCreatedAt) {
|
||||||
continue // up to date
|
continue
|
||||||
}
|
}
|
||||||
// No marker or marker is behind → this human is behind on this stream
|
|
||||||
behindCounts[humanId]++
|
behindCounts[humanId]++
|
||||||
if stream.LastChildCreatedAt.After(latestActivity[humanId]) {
|
if stream.LastChildCreatedAt.After(latestActivity[humanId]) {
|
||||||
latestActivity[humanId] = *stream.LastChildCreatedAt
|
latestActivity[humanId] = *stream.LastChildCreatedAt
|
||||||
@@ -164,10 +138,8 @@ func runNotificationCycle(
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send notifications
|
|
||||||
sentCount := 0
|
sentCount := 0
|
||||||
for humanId, count := range behindCounts {
|
for humanId, count := range behindCounts {
|
||||||
// Skip online users
|
|
||||||
if allOnline[humanId] {
|
if allOnline[humanId] {
|
||||||
slog.Info("human online...skipping email", "humanId", humanId)
|
slog.Info("human online...skipping email", "humanId", humanId)
|
||||||
continue
|
continue
|
||||||
@@ -178,28 +150,24 @@ func runNotificationCycle(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if notifications disabled
|
|
||||||
if !h.EmailNotificationsEnabled {
|
if !h.EmailNotificationsEnabled {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if no new activity since last notification
|
// Skip if nothing new since the previous email.
|
||||||
if h.LastEmailNotificationSentAt != nil && !latestActivity[humanId].After(*h.LastEmailNotificationSentAt) {
|
if h.LastEmailNotificationSentAt != nil && !latestActivity[humanId].After(*h.LastEmailNotificationSentAt) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enforce cooldown between emails to the same user
|
|
||||||
if h.LastEmailNotificationSentAt != nil && now.Sub(*h.LastEmailNotificationSentAt) < emailCooldown {
|
if h.LastEmailNotificationSentAt != nil && now.Sub(*h.LastEmailNotificationSentAt) < emailCooldown {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send email
|
|
||||||
if err := sendNotificationEmail(ctx, aeroSvc, h, count); err != nil {
|
if err := sendNotificationEmail(ctx, aeroSvc, h, count); err != nil {
|
||||||
slog.Error("failed to send email", "humanId", humanId, "error", err)
|
slog.Error("failed to send email", "humanId", humanId, "error", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last sent timestamp
|
|
||||||
if err := humanSvc.UpdateLastEmailNotificationSentAt(ctx, humanId, now); err != nil {
|
if err := humanSvc.UpdateLastEmailNotificationSentAt(ctx, humanId, now); err != nil {
|
||||||
slog.Error("failed to update last_email_notification_sent_at", "humanId", humanId, "error", err)
|
slog.Error("failed to update last_email_notification_sent_at", "humanId", humanId, "error", err)
|
||||||
}
|
}
|
||||||
@@ -215,7 +183,6 @@ func runNotificationCycle(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getOpenStreams queries Firestore for all open stream particles in a network.
|
|
||||||
func getOpenStreams(ctx context.Context, client *firestore.Client, networkId string) ([]particle.FirestoreStreamParticle, error) {
|
func getOpenStreams(ctx context.Context, client *firestore.Client, networkId string) ([]particle.FirestoreStreamParticle, error) {
|
||||||
collPath := fmt.Sprintf("networks/%s/children", networkId)
|
collPath := fmt.Sprintf("networks/%s/children", networkId)
|
||||||
docs, err := client.Collection(collPath).
|
docs, err := client.Collection(collPath).
|
||||||
@@ -239,27 +206,6 @@ func getOpenStreams(ctx context.Context, client *firestore.Client, networkId str
|
|||||||
return streams, nil
|
return streams, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveMembers expands visible_to entries into a set of humanIds.
|
|
||||||
// "human:{id}" adds that id directly. "network:{id}" expands to all network members.
|
|
||||||
func resolveMembers(visibleTo []string, networkMembers map[string]bool) map[string]bool {
|
|
||||||
members := map[string]bool{}
|
|
||||||
for _, entry := range visibleTo {
|
|
||||||
if strings.HasPrefix(entry, "human:") {
|
|
||||||
humanId := strings.TrimPrefix(entry, "human:")
|
|
||||||
// human can be in visible_to, but no longer a member of the network
|
|
||||||
if _, ok := networkMembers[humanId]; ok {
|
|
||||||
members[humanId] = true
|
|
||||||
}
|
|
||||||
} else if strings.HasPrefix(entry, "network:") {
|
|
||||||
// Expand to all network members
|
|
||||||
for id := range networkMembers {
|
|
||||||
members[id] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return members
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendNotificationEmail(ctx context.Context, aeroSvc pbaero.PrimaryClient, h *human.Human, streamCount int) error {
|
func sendNotificationEmail(ctx context.Context, aeroSvc pbaero.PrimaryClient, h *human.Human, streamCount int) error {
|
||||||
streamsWord := "stream"
|
streamsWord := "stream"
|
||||||
if streamCount != 1 {
|
if streamCount != 1 {
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
// memberreconciler is a one-shot job (also safe to run on a cron) that makes
|
// memberreconciler reconciles the Firestore membership mirror
|
||||||
// the Firestore membership mirror (humans/{humanId}.networks) match the
|
// (humans/{humanId}.networks) against the authoritative Postgres
|
||||||
// authoritative Postgres network_members table.
|
// network_members table. Safe to run on a cron — only humans whose mirrored
|
||||||
//
|
// set differs from Postgres are written, so a steady-state run is nearly free.
|
||||||
// Run on a cron to heal any drift from a dropped mirror
|
|
||||||
// write in network.Service.
|
|
||||||
//
|
|
||||||
// The reconciler reads the current Firestore state and only writes humans
|
|
||||||
// whose mirrored networks differ from Postgres. Writes cost ~3x reads, and in
|
|
||||||
// steady state drift is rare, so read-first keeps the cron nearly free.
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -98,8 +92,7 @@ func reconcile(
|
|||||||
return written, scanned, nil
|
return written, scanned, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// snapshotMirror streams the humans collection once and returns a map of
|
// One iterator, N billed reads — returns humanId → mirrored networks.
|
||||||
// humanId -> current networks array. One iterator, N billed reads.
|
|
||||||
func snapshotMirror(ctx context.Context, fs *firestore.Client) (map[string][]string, error) {
|
func snapshotMirror(ctx context.Context, fs *firestore.Client) (map[string][]string, error) {
|
||||||
out := map[string][]string{}
|
out := map[string][]string{}
|
||||||
iter := fs.Collection("humans").Documents(ctx)
|
iter := fs.Collection("humans").Documents(ctx)
|
||||||
@@ -124,9 +117,8 @@ func snapshotMirror(ctx context.Context, fs *firestore.Client) (map[string][]str
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sameSet reports whether a and b contain the same elements, ignoring order
|
// Set equality (order- and duplicate-insensitive); Firestore array ops don't
|
||||||
// and duplicates. Firestore array ops don't preserve order, so set equality is
|
// preserve order.
|
||||||
// the right comparison for the networks array.
|
|
||||||
func sameSet(a, b []string) bool {
|
func sameSet(a, b []string) bool {
|
||||||
if len(a) == 0 && len(b) == 0 {
|
if len(a) == 0 && len(b) == 0 {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"github.com/flowy-live/llink/internal/depot"
|
"github.com/flowy-live/llink/internal/depot"
|
||||||
"github.com/flowy-live/llink/internal/handler"
|
"github.com/flowy-live/llink/internal/handler"
|
||||||
"github.com/flowy-live/llink/internal/human"
|
"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/livekit"
|
||||||
"github.com/flowy-live/llink/internal/livestore"
|
"github.com/flowy-live/llink/internal/livestore"
|
||||||
"github.com/flowy-live/llink/internal/middleware"
|
"github.com/flowy-live/llink/internal/middleware"
|
||||||
@@ -106,9 +107,10 @@ func main() {
|
|||||||
BucketName: gcsBucket,
|
BucketName: gcsBucket,
|
||||||
})
|
})
|
||||||
waitlistSvc := waitlist.NewService(db.Pool(), aeroSvc)
|
waitlistSvc := waitlist.NewService(db.Pool(), aeroSvc)
|
||||||
|
pushTokenSvc := pushnotify.NewService(db.Pool())
|
||||||
livekitClient := livekit.NewClient()
|
livekitClient := livekit.NewClient()
|
||||||
|
|
||||||
h := handler.NewHandler(authSvc, humanSvc, networkSvc, particleSvc, depotSvc, waitlistSvc, billingSvc, livekitClient, firestoreClient)
|
h := handler.NewHandler(authSvc, humanSvc, networkSvc, particleSvc, depotSvc, waitlistSvc, billingSvc, pushTokenSvc, livekitClient, firestoreClient)
|
||||||
|
|
||||||
withAuth := func(hf http.HandlerFunc) http.Handler {
|
withAuth := func(hf http.HandlerFunc) http.Handler {
|
||||||
return middleware.Auth(authSvc)(http.HandlerFunc(hf))
|
return middleware.Auth(authSvc)(http.HandlerFunc(hf))
|
||||||
@@ -143,6 +145,10 @@ func main() {
|
|||||||
// Settings
|
// Settings
|
||||||
mux.Handle("PATCH /humans/me/settings", withAuth(h.UpdateSettings))
|
mux.Handle("PATCH /humans/me/settings", withAuth(h.UpdateSettings))
|
||||||
|
|
||||||
|
// Push notification tokens (per-device)
|
||||||
|
mux.Handle("POST /humans/me/push-tokens", withAuth(h.RegisterPushToken))
|
||||||
|
mux.Handle("DELETE /humans/me/push-tokens", withAuth(h.UnregisterPushToken))
|
||||||
|
|
||||||
// Networks
|
// Networks
|
||||||
mux.Handle("POST /networks", withAuth(h.CreateNetwork))
|
mux.Handle("POST /networks", withAuth(h.CreateNetwork))
|
||||||
mux.Handle("GET /networks", withAuth(h.ListNetworks))
|
mux.Handle("GET /networks", withAuth(h.ListNetworks))
|
||||||
@@ -179,8 +185,7 @@ func main() {
|
|||||||
mux.Handle("GET /waitlist/{email}", withAuth(h.GetWaitlistEntry))
|
mux.Handle("GET /waitlist/{email}", withAuth(h.GetWaitlistEntry))
|
||||||
mux.Handle("POST /waitlist/invite", withAuth(h.InviteWaitlistEntrant))
|
mux.Handle("POST /waitlist/invite", withAuth(h.InviteWaitlistEntrant))
|
||||||
|
|
||||||
// Apply middleware
|
// nil = allow all origins (Electron app needs it).
|
||||||
// nil allows all origins (required for electron app)
|
|
||||||
muxWithCors := middleware.CORS(nil)(mux)
|
muxWithCors := middleware.CORS(nil)(mux)
|
||||||
|
|
||||||
addr := fmt.Sprintf("0.0.0.0:%s", port)
|
addr := fmt.Sprintf("0.0.0.0:%s", port)
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/flowy-live/llink/internal/billing"
|
"github.com/flowy-live/llink/internal/billing"
|
||||||
"github.com/flowy-live/llink/internal/db"
|
"github.com/flowy-live/llink/internal/db"
|
||||||
"github.com/flowy-live/llink/internal/depot"
|
"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/network"
|
||||||
"github.com/flowy-live/llink/internal/particle"
|
"github.com/flowy-live/llink/internal/particle"
|
||||||
"github.com/flowy-live/llink/internal/speech"
|
"github.com/flowy-live/llink/internal/speech"
|
||||||
"github.com/flowy-live/llink/internal/utils"
|
"github.com/flowy-live/llink/internal/utils"
|
||||||
@@ -31,13 +35,9 @@ func createClient(ctx context.Context) *firestore.Client {
|
|||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
// The purpose of the particle processor worker is to listen for new particles
|
// Listens for new particles and runs per-particle side effects: transcripts,
|
||||||
// across all streams and perform side effects such as
|
// transcode, parent stream's last_child_created_at, freemium usage, and push
|
||||||
// - generate transcript if the particle is of type media
|
// notifications for offline recipients.
|
||||||
// - send mobile notifications if a client is offline
|
|
||||||
// - update the parent stream's `last_child_created_at`
|
|
||||||
// - generate vector embedding
|
|
||||||
// - synthesize and decide whether ai should generate a particle as a response
|
|
||||||
func main() {
|
func main() {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -61,6 +61,14 @@ func main() {
|
|||||||
|
|
||||||
speechSvc := speech.NewSpeechService(ctx)
|
speechSvc := speech.NewSpeechService(ctx)
|
||||||
|
|
||||||
|
humanSvc := human.NewService(db.Pool())
|
||||||
|
networkReader := network.NewReader(db.Pool())
|
||||||
|
pushTokenSvc := pushnotify.NewService(db.Pool())
|
||||||
|
// EXPO_ACCESS_TOKEN is required: Enhanced Security is on for our Expo
|
||||||
|
// project (otherwise anyone holding one of our push tokens could spam users).
|
||||||
|
expoClient := pushnotify.NewExpoClient(utils.MustGetEnv("EXPO_ACCESS_TOKEN"))
|
||||||
|
notifier := pushnotify.NewNotifier(networkReader, pushTokenSvc, expoClient)
|
||||||
|
|
||||||
client := createClient(ctx)
|
client := createClient(ctx)
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
|
|
||||||
@@ -102,13 +110,14 @@ func main() {
|
|||||||
|
|
||||||
slog.Debug("processing particle", "particleID", particleID, "data", change.Doc.Data())
|
slog.Debug("processing particle", "particleID", particleID, "data", change.Doc.Data())
|
||||||
|
|
||||||
// --- Perform side effects ---
|
// Side effects below are best-effort — failures don't prevent
|
||||||
// All of them do not stop us from marking the particle as processed
|
// marking the particle as processed.
|
||||||
|
parentDoc := loadParentParticle(ctx, change.Doc)
|
||||||
updateParentLastChildCreatedAt(ctx, change.Doc)
|
updateParentLastChildCreatedAt(ctx, change.Doc, parentDoc)
|
||||||
transcribeMediaParticle(ctx, depotSvc, speechSvc, change.Doc)
|
transcript := transcribeMediaParticle(ctx, depotSvc, speechSvc, change.Doc)
|
||||||
particle.Transcode(ctx, depotSvc, change.Doc)
|
particle.Transcode(ctx, depotSvc, change.Doc)
|
||||||
recordFreemiumUsage(ctx, billingSvc, change.Doc)
|
recordFreemiumUsage(ctx, billingSvc, change.Doc)
|
||||||
|
notifyForParticle(ctx, notifier, humanSvc, change.Doc, parentDoc, transcript)
|
||||||
|
|
||||||
if err := processingRepo.MarkProcessed(ctx, particleID); err != nil {
|
if err := processingRepo.MarkProcessed(ctx, particleID); err != nil {
|
||||||
slog.Error("failed to mark particle as processed", "particleID", particleID, "error", err)
|
slog.Error("failed to mark particle as processed", "particleID", particleID, "error", err)
|
||||||
@@ -117,35 +126,37 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func transcribeMediaParticle(ctx context.Context, depotSvc depot.Service, speechSvc speech.SpeechService, doc *firestore.DocumentSnapshot) {
|
// Writes the structured transcript to Firestore and returns the raw text;
|
||||||
|
// returns "" for non-media particles or on any error (logged internally).
|
||||||
|
func transcribeMediaParticle(ctx context.Context, depotSvc depot.Service, speechSvc speech.SpeechService, doc *firestore.DocumentSnapshot) string {
|
||||||
var mediaParticle particle.FirestoreMediaParticle
|
var mediaParticle particle.FirestoreMediaParticle
|
||||||
err := doc.DataTo(&mediaParticle)
|
err := doc.DataTo(&mediaParticle)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("unable to marshal particle data", "error", err)
|
slog.Error("unable to marshal particle data", "error", err)
|
||||||
return
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
particleType, err := particle.ParseParticleType(mediaParticle.Type)
|
particleType, err := particle.ParseParticleType(mediaParticle.Type)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("invalid particle type", "error", err)
|
slog.Error("invalid particle type", "error", err)
|
||||||
return
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if particleType != particle.TypeMedia {
|
if particleType != particle.TypeMedia {
|
||||||
slog.Info("received a particle of type", "particle type", particleType)
|
slog.Info("received a particle of type", "particle type", particleType)
|
||||||
return
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadURL, err := depotSvc.GetDownloadURL(ctx, mediaParticle.Properties.ObjectId)
|
downloadURL, err := depotSvc.GetDownloadURL(ctx, mediaParticle.Properties.ObjectId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to get download URL", "error", err, "object_id", mediaParticle.Properties.ObjectId)
|
slog.Error("failed to get download URL", "error", err, "object_id", mediaParticle.Properties.ObjectId)
|
||||||
return
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := speechSvc.Transcribe(ctx, downloadURL)
|
result, err := speechSvc.Transcribe(ctx, downloadURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to transcribe media", "error", err, "particleID", doc.Ref.ID)
|
slog.Error("failed to transcribe media", "error", err, "particleID", doc.Ref.ID)
|
||||||
return
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
transcript := toFirestoreTranscript(result)
|
transcript := toFirestoreTranscript(result)
|
||||||
@@ -157,10 +168,11 @@ func transcribeMediaParticle(ctx context.Context, depotSvc depot.Service, speech
|
|||||||
}, firestore.MergeAll)
|
}, firestore.MergeAll)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to update transcript in firestore", "error", err, "particleID", doc.Ref.ID)
|
slog.Error("failed to update transcript in firestore", "error", err, "particleID", doc.Ref.ID)
|
||||||
return
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("transcribed media particle", "particleID", doc.Ref.ID)
|
slog.Info("transcribed media particle", "particleID", doc.Ref.ID)
|
||||||
|
return transcript.Transcript
|
||||||
}
|
}
|
||||||
|
|
||||||
func toFirestoreTranscript(result *speech.TranscriptResult) particle.FirestoreTranscript {
|
func toFirestoreTranscript(result *speech.TranscriptResult) particle.FirestoreTranscript {
|
||||||
@@ -197,10 +209,9 @@ func toFirestoreTranscript(result *speech.TranscriptResult) particle.FirestoreTr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// recordFreemiumUsage bumps the network's daily message counter for non-container
|
// Bumps the network's daily message counter for non-container particles.
|
||||||
// particles. Idempotent via the surrounding processed_particles guard: the worker
|
// The surrounding processed_particles guard keeps this idempotent across
|
||||||
// only reaches this path on first-seen particles, so a crash/restart won't
|
// crashes/restarts.
|
||||||
// double-count.
|
|
||||||
func recordFreemiumUsage(ctx context.Context, billingSvc billing.Service, doc *firestore.DocumentSnapshot) {
|
func recordFreemiumUsage(ctx context.Context, billingSvc billing.Service, doc *firestore.DocumentSnapshot) {
|
||||||
rawType, err := doc.DataAt("type")
|
rawType, err := doc.DataAt("type")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -217,7 +228,7 @@ func recordFreemiumUsage(ctx context.Context, billingSvc billing.Service, doc *f
|
|||||||
slog.Error("invalid particle type", "error", err, "particleID", doc.Ref.ID)
|
slog.Error("invalid particle type", "error", err, "particleID", doc.Ref.ID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Containers (stream/folder) don't count as "messages" for the daily cap.
|
// Containers don't count toward the daily message cap.
|
||||||
if particleType == particle.TypeStream || particleType == particle.TypeFolder {
|
if particleType == particle.TypeStream || particleType == particle.TypeFolder {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -233,31 +244,34 @@ func recordFreemiumUsage(ctx context.Context, billingSvc billing.Service, doc *f
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateParentLastChildCreatedAt updates the parent stream's last_child_created_at
|
// Returns nil (and logs) if the path has no parent or the read fails.
|
||||||
// to the child's actual created_at timestamp, so it stays directly comparable with
|
func loadParentParticle(ctx context.Context, doc *firestore.DocumentSnapshot) *firestore.DocumentSnapshot {
|
||||||
// playback markers (which also store child created_at values).
|
|
||||||
func updateParentLastChildCreatedAt(ctx context.Context, doc *firestore.DocumentSnapshot) {
|
|
||||||
parentChildrenCollectionRef := doc.Ref.Parent
|
parentChildrenCollectionRef := doc.Ref.Parent
|
||||||
if parentChildrenCollectionRef == nil {
|
if parentChildrenCollectionRef == nil {
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
parentParticleDocRef := parentChildrenCollectionRef.Parent
|
parentParticleDocRef := parentChildrenCollectionRef.Parent
|
||||||
if parentParticleDocRef == nil {
|
if parentParticleDocRef == nil {
|
||||||
slog.Error("particle has no parent document", "particleID", doc.Ref.ID)
|
slog.Error("particle has no parent document", "particleID", doc.Ref.ID)
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
parentParticleDoc, err := parentParticleDocRef.Get(ctx)
|
parentParticleDoc, err := parentParticleDocRef.Get(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to get parent particle", "error", err)
|
slog.Error("failed to get parent particle", "error", err, "particleID", doc.Ref.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return parentParticleDoc
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sets last_child_created_at to the child's created_at so it stays directly
|
||||||
|
// comparable with playback markers (which also store child created_at values).
|
||||||
|
func updateParentLastChildCreatedAt(ctx context.Context, doc *firestore.DocumentSnapshot, parent *firestore.DocumentSnapshot) {
|
||||||
|
if parent == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("parent particle is", "parent particle id", parentParticleDoc.Ref.ID)
|
|
||||||
|
|
||||||
var streamParticle particle.FirestoreStreamParticle
|
var streamParticle particle.FirestoreStreamParticle
|
||||||
if err := parentParticleDoc.DataTo(&streamParticle); err != nil {
|
if err := parent.DataTo(&streamParticle); err != nil {
|
||||||
slog.Error("failed to parse stream particle", "error", err)
|
slog.Error("failed to parse stream particle", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -272,21 +286,159 @@ func updateParentLastChildCreatedAt(ctx context.Context, doc *firestore.Document
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read the child's created_at — this is the same value that playback markers store
|
|
||||||
childCreatedAt, err := doc.DataAt("created_at")
|
childCreatedAt, err := doc.DataAt("created_at")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to read child created_at", "error", err, "particleID", doc.Ref.ID)
|
slog.Error("failed to read child created_at", "error", err, "particleID", doc.Ref.ID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("going to update the last_child_created_at for parent particle")
|
_, err = parent.Ref.Update(ctx, []firestore.Update{
|
||||||
_, err = parentParticleDocRef.Update(ctx, []firestore.Update{
|
|
||||||
{
|
{
|
||||||
Path: "last_child_created_at",
|
Path: "last_child_created_at",
|
||||||
Value: childCreatedAt,
|
Value: childCreatedAt,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("unable to update parent particle `last_child_created_at`")
|
slog.Error("unable to update parent particle `last_child_created_at`", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skips containers and particles whose parent isn't a stream — notifications
|
||||||
|
// are scoped to stream messages today. The transcript arg becomes the preview
|
||||||
|
// body for media particles when available.
|
||||||
|
func notifyForParticle(
|
||||||
|
ctx context.Context,
|
||||||
|
notifier *pushnotify.Notifier,
|
||||||
|
humanSvc human.Service,
|
||||||
|
doc *firestore.DocumentSnapshot,
|
||||||
|
parent *firestore.DocumentSnapshot,
|
||||||
|
transcript string,
|
||||||
|
) {
|
||||||
|
if parent == nil {
|
||||||
|
slog.Info("notify: skip — no parent", "particleID", doc.Ref.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
typeStr, _ := doc.DataAt("type")
|
||||||
|
typeName, _ := typeStr.(string)
|
||||||
|
pType, err := particle.ParseParticleType(typeName)
|
||||||
|
if err != nil {
|
||||||
|
slog.Info("notify: skip — unparseable particle type",
|
||||||
|
"particleID", doc.Ref.ID, "type", typeName, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pType == particle.TypeStream || pType == particle.TypeFolder {
|
||||||
|
slog.Info("notify: skip — container particle",
|
||||||
|
"particleID", doc.Ref.ID, "type", pType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var parentStream particle.FirestoreStreamParticle
|
||||||
|
if err := parent.DataTo(&parentStream); err != nil {
|
||||||
|
slog.Error("notify: failed to parse parent stream", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parentType, err := particle.ParseParticleType(parentStream.Type)
|
||||||
|
if err != nil || parentType != particle.TypeStream {
|
||||||
|
slog.Info("notify: skip — parent isn't a stream",
|
||||||
|
"particleID", doc.Ref.ID, "parentType", parentType, "parseErr", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
networkID, err := particle.NetworkIDFromParticlePath(doc.Ref.Path)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("notify: failed to derive network id", "error", err, "path", doc.Ref.Path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
senderHumanID := parentStream.CreatedByHumanId
|
||||||
|
if v, err := doc.DataAt("created_by_human_id"); err == nil {
|
||||||
|
if s, ok := v.(string); ok && s != "" {
|
||||||
|
senderHumanID = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
streamName := ""
|
||||||
|
if v, err := parent.DataAt("properties.name"); err == nil {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
streamName = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
senderEmailPrefix := ""
|
||||||
|
if senderHumanID != "" {
|
||||||
|
if sender, err := humanSvc.GetByID(ctx, senderHumanID); err == nil {
|
||||||
|
senderEmailPrefix = sender.EmailPrefix
|
||||||
|
} else {
|
||||||
|
slog.Warn("notify: failed to look up sender", "error", err, "humanID", senderHumanID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := notifier.NotifyParticleCreated(ctx, pushnotify.NotifyInput{
|
||||||
|
NetworkID: networkID,
|
||||||
|
SenderHumanID: senderHumanID,
|
||||||
|
SenderEmailPrefix: senderEmailPrefix,
|
||||||
|
ParticleID: doc.Ref.ID,
|
||||||
|
ParticleKind: string(pType),
|
||||||
|
StreamID: parent.Ref.ID,
|
||||||
|
StreamName: streamName,
|
||||||
|
StreamVisibleTo: parentStream.VisibleTo,
|
||||||
|
Body: previewForParticle(pType, doc, transcript),
|
||||||
|
}); err != nil {
|
||||||
|
slog.Error("notify: dispatch failed", "error", err, "particleID", doc.Ref.ID, "networkID", networkID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds the notification body. Kept short — lockscreens truncate aggressively.
|
||||||
|
// Media prefers transcript text and falls back to a generic "Sent a …" line.
|
||||||
|
func previewForParticle(pType particle.ParticleType, doc *firestore.DocumentSnapshot, transcript string) string {
|
||||||
|
switch pType {
|
||||||
|
case particle.TypeText:
|
||||||
|
if v, err := doc.DataAt("properties.content"); err == nil {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
return truncatePreview(s, 140)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Sent a message"
|
||||||
|
case particle.TypeMedia:
|
||||||
|
if t := strings.TrimSpace(transcript); t != "" {
|
||||||
|
return truncatePreview(t, 140)
|
||||||
|
}
|
||||||
|
mime := ""
|
||||||
|
if v, err := doc.DataAt("properties.mime_type"); err == nil {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
mime = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(mime, "video/") {
|
||||||
|
return "Sent a video"
|
||||||
|
}
|
||||||
|
return "Sent a voice message"
|
||||||
|
case particle.TypeFile:
|
||||||
|
return "Sent a file"
|
||||||
|
case particle.TypeQuest:
|
||||||
|
if v, err := doc.DataAt("properties.title"); err == nil {
|
||||||
|
if s, ok := v.(string); ok && s != "" {
|
||||||
|
return "Quest: " + truncatePreview(s, 120)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Added a quest"
|
||||||
|
case particle.TypePaper:
|
||||||
|
if v, err := doc.DataAt("properties.title"); err == nil {
|
||||||
|
if s, ok := v.(string); ok && s != "" {
|
||||||
|
return "Paper: " + truncatePreview(s, 120)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Added a paper"
|
||||||
|
default:
|
||||||
|
return "New activity"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncatePreview(s string, n int) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n] + "…"
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,29 +25,22 @@ func main() {
|
|||||||
port := utils.MustGetEnv("PORT")
|
port := utils.MustGetEnv("PORT")
|
||||||
grpcPort := utils.MustGetEnv("GRPC_PORT")
|
grpcPort := utils.MustGetEnv("GRPC_PORT")
|
||||||
|
|
||||||
// Initialize database (for network membership checks)
|
|
||||||
db.Init()
|
db.Init()
|
||||||
defer db.Cleanup()
|
defer db.Cleanup()
|
||||||
|
|
||||||
// Redis for auth session validation (same DB as orion)
|
authRedis := internal.ConnectAndTestRedis(db.RedisDBAuth) // shared with orion
|
||||||
authRedis := internal.ConnectAndTestRedis(db.RedisDBAuth)
|
pusherRedis := internal.ConnectAndTestRedis(db.RedisDBPusher) // presence + pub/sub
|
||||||
|
|
||||||
// Redis for pusher state (presence hashes, pub/sub)
|
sessionReader := auth.NewSessionReader(authRedis)
|
||||||
pusherRedis := internal.ConnectAndTestRedis(db.RedisDBPusher)
|
networkReader := network.NewReader(db.Pool())
|
||||||
|
|
||||||
// Services
|
// Hostname is the k8s pod name.
|
||||||
sessionReader := auth.NewSessionReader(authRedis) // pusher only validates sessions
|
|
||||||
networkReader := network.NewReader(db.Pool()) // pusher only checks membership
|
|
||||||
|
|
||||||
// Pod identity (use hostname in k8s, which is the pod name)
|
|
||||||
podID, err := os.Hostname()
|
podID, err := os.Hostname()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
podID = fmt.Sprintf("pod-%d", os.Getpid())
|
podID = fmt.Sprintf("pod-%d", os.Getpid())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pusher core
|
|
||||||
bridge := pusher.NewRedisBridge(pusherRedis, podID)
|
bridge := pusher.NewRedisBridge(pusherRedis, podID)
|
||||||
// Context for graceful shutdown
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -56,16 +49,11 @@ func main() {
|
|||||||
bridge.SetHub(hub)
|
bridge.SetHub(hub)
|
||||||
server := pusher.NewServer(ctx, hub, bridge, sessionReader)
|
server := pusher.NewServer(ctx, hub, bridge, sessionReader)
|
||||||
|
|
||||||
// Start hub event loop
|
|
||||||
go hub.Run(ctx)
|
go hub.Run(ctx)
|
||||||
|
|
||||||
// Start Redis Pub/Sub listener
|
|
||||||
go bridge.Listen(ctx)
|
go bridge.Listen(ctx)
|
||||||
|
|
||||||
// Start pod heartbeat + stale pod cleanup
|
|
||||||
go bridge.Heartbeat(ctx)
|
go bridge.Heartbeat(ctx)
|
||||||
|
|
||||||
// --- gRPC server (internal, for presence queries) ---
|
// --- gRPC server (internal presence queries) ---
|
||||||
grpcListener, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%s", grpcPort))
|
grpcListener, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%s", grpcPort))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to listen for gRPC", "port", grpcPort, "error", err)
|
slog.Error("failed to listen for gRPC", "port", grpcPort, "error", err)
|
||||||
@@ -104,7 +92,7 @@ func main() {
|
|||||||
<-sigCh
|
<-sigCh
|
||||||
|
|
||||||
slog.Info("shutting down...")
|
slog.Info("shutting down...")
|
||||||
cancel() // stops hub, bridge listener, heartbeat
|
cancel()
|
||||||
|
|
||||||
grpcServer.GracefulStop()
|
grpcServer.GracefulStop()
|
||||||
httpServer.Shutdown(context.Background())
|
httpServer.Shutdown(context.Background())
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
// transcodebackfill is a one-shot job that scans every doc under the
|
// transcodebackfill walks every doc under the "children" collection group and
|
||||||
// "children" Firestore collection group, identifies media particles missing a
|
// re-runs transcode for media particles missing a transcoded variant.
|
||||||
// transcoded variant, and re-runs the transcode + upload + Firestore-update
|
// Idempotent: particle.Transcode short-circuits on transcoded_object_id != "".
|
||||||
// flow against the configured GCS bucket. Idempotent — re-running the Job
|
|
||||||
// after a partial failure picks up where it left off via the existing
|
|
||||||
// `transcoded_object_id != ""` short-circuit inside particle.Transcode.
|
|
||||||
//
|
|
||||||
// Intended to back-fill the production iOS playback backlog created before we
|
|
||||||
// launched mobile / particle processor worker only transcodes new media.
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -104,9 +98,7 @@ func run(ctx context.Context, fs *firestore.Client, depotSvc depot.Service) (sta
|
|||||||
|
|
||||||
s.scanned++
|
s.scanned++
|
||||||
|
|
||||||
// Cheap pre-filter: most docs under the "children" collection group
|
// DataAt avoids unmarshalling the full doc; most children aren't media.
|
||||||
// are not media particles. DataAt avoids unmarshalling the full
|
|
||||||
// document for those.
|
|
||||||
rawType, err := doc.DataAt("type")
|
rawType, err := doc.DataAt("type")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.skippedNonMedia++
|
s.skippedNonMedia++
|
||||||
|
|||||||
@@ -18,9 +18,8 @@ type sessionReaderImpl struct {
|
|||||||
redisClient *redis.Client
|
redisClient *redis.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// newSessionReader returns the concrete reader. Used by NewAuthService to
|
// Exposes the concrete type so authServiceImpl can embed it without
|
||||||
// embed without going through the SessionReader interface (which would hide
|
// hiding redisClient behind the SessionReader interface.
|
||||||
// redisClient from the rest of authServiceImpl).
|
|
||||||
func newSessionReader(redisClient *redis.Client) *sessionReaderImpl {
|
func newSessionReader(redisClient *redis.Client) *sessionReaderImpl {
|
||||||
return &sessionReaderImpl{redisClient: redisClient}
|
return &sessionReaderImpl{redisClient: redisClient}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,17 +47,16 @@ type Session struct {
|
|||||||
type AuthService interface {
|
type AuthService interface {
|
||||||
SessionReader
|
SessionReader
|
||||||
|
|
||||||
// RequestSignInCode generates a code and emails it to the provided email.
|
// RequestSignInCode emails a one-time code; the client redeems it via VerifySignInCode.
|
||||||
// To retrieve a session, client must verify with VerifySignInCode.
|
|
||||||
RequestSignInCode(ctx context.Context, email string) error
|
RequestSignInCode(ctx context.Context, email string) error
|
||||||
// VerifySignInCode returns ErrInvalidCode if incorrect code, otherwise creates a session.
|
// VerifySignInCode returns ErrInvalidCode on a wrong code, otherwise creates
|
||||||
// humanId is stored in the session alongside the email.
|
// a session keyed to (email, humanId).
|
||||||
VerifySignInCode(ctx context.Context, email, code, humanId string) (sessionToken string, err error)
|
VerifySignInCode(ctx context.Context, email, code, humanId string) (sessionToken string, err error)
|
||||||
// ExtendSession returns ErrSessionNotFound if no valid session
|
// ExtendSession returns ErrSessionNotFound if no valid session.
|
||||||
ExtendSession(ctx context.Context, sessionToken string) error
|
ExtendSession(ctx context.Context, sessionToken string) error
|
||||||
SignOut(ctx context.Context, sessionToken string) error
|
SignOut(ctx context.Context, sessionToken string) error
|
||||||
|
|
||||||
// MintFirebaseCustomToken returns a Firebase custom token with uid=humanId and no custom claims.
|
// MintFirebaseCustomToken issues a Firebase custom token with uid=humanId and no claims.
|
||||||
MintFirebaseCustomToken(ctx context.Context, humanId string) (string, error)
|
MintFirebaseCustomToken(ctx context.Context, humanId string) (string, error)
|
||||||
|
|
||||||
IsSystemAdmin(ctx context.Context, email string) bool
|
IsSystemAdmin(ctx context.Context, email string) bool
|
||||||
|
|||||||
@@ -73,9 +73,8 @@ func NewService(ctx context.Context, pool *pgxpool.Pool, cfg Config) (Service, e
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServiceForWorker builds a minimal billing Service suitable for the
|
// NewServiceForWorker skips Stripe client setup since workers only exercise
|
||||||
// particle processor worker: only the usage-tracking path is exercised, so
|
// the usage-tracking path. No API key required.
|
||||||
// we skip Stripe client setup (no API key required).
|
|
||||||
func NewServiceForWorker(pool *pgxpool.Pool) Service {
|
func NewServiceForWorker(pool *pgxpool.Pool) Service {
|
||||||
return &serviceImpl{
|
return &serviceImpl{
|
||||||
usageRepo: newUsageRepository(pool),
|
usageRepo: newUsageRepository(pool),
|
||||||
|
|||||||
@@ -2,11 +2,9 @@ package billing
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// FreemiumDailyLimit is the per-network daily cap on usage,
|
// Per-network daily cap on the free plan. Unit-agnostic.
|
||||||
// agnostic of the units that this refer to. This is only relevant for the "free" plan.
|
|
||||||
const FreemiumDailyLimit = 50
|
const FreemiumDailyLimit = 50
|
||||||
|
|
||||||
// Usage describes a network's current freemium quota state for today.
|
|
||||||
type Usage struct {
|
type Usage struct {
|
||||||
Plan Plan `json:"plan"`
|
Plan Plan `json:"plan"`
|
||||||
Used int `json:"used"`
|
Used int `json:"used"`
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
// Shared database namespaces used across services
|
// FIX: move to a dedicated Redis instance. The high DB numbers exist because
|
||||||
// FIX: Use separate redis instance. We start with higher number because use this same instance in helios.
|
// this instance is shared with helios.
|
||||||
const (
|
const (
|
||||||
RedisDBAuth = 4 // auth sessions
|
RedisDBAuth = 4 // auth sessions
|
||||||
RedisDBPusher = 5 // dedicated to pusher state (presence, pub/sub)
|
RedisDBPusher = 5 // presence, pub/sub
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package depot
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// Object represents a stored object in the depot
|
|
||||||
type Object struct {
|
type Object struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
@@ -14,31 +13,26 @@ type Object struct {
|
|||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// PrepareUploadInput represents the input for preparing an upload
|
|
||||||
type PrepareUploadInput struct {
|
type PrepareUploadInput struct {
|
||||||
Prefix string // Optional prefix for organizing objects (e.g., network_id)
|
Prefix string // optional, e.g. network_id
|
||||||
Name string
|
Name string
|
||||||
ContentType string
|
ContentType string
|
||||||
ContentLength int64
|
ContentLength int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// PrepareUploadResult represents the result of preparing an upload
|
|
||||||
type PrepareUploadResult struct {
|
type PrepareUploadResult struct {
|
||||||
ObjectID string
|
ObjectID string
|
||||||
UploadURL string
|
UploadURL string
|
||||||
UploadHeaders map[string]string
|
UploadHeaders map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateFromReaderInput is for server-side direct uploads (no presigned URL).
|
// For server-side direct uploads (no presigned URL).
|
||||||
// Used by background workers that already have the bytes on hand and don't
|
|
||||||
// need a client round-trip.
|
|
||||||
type CreateFromReaderInput struct {
|
type CreateFromReaderInput struct {
|
||||||
Prefix string // Optional prefix for organizing objects (e.g., network_id)
|
Prefix string // optional, e.g. network_id
|
||||||
Name string
|
Name string
|
||||||
ContentType string
|
ContentType string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config holds configuration for the depot service
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
GoogleServiceAccountEmail string
|
GoogleServiceAccountEmail string
|
||||||
BucketName string
|
BucketName string
|
||||||
|
|||||||
@@ -74,10 +74,10 @@ func (s *serviceImpl) PrepareUpload(ctx context.Context, input PrepareUploadInpu
|
|||||||
return nil, errors.Join(ErrInvalidInput, errors.New("content_length must be positive"))
|
return nil, errors.Join(ErrInvalidInput, errors.New("content_length must be positive"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate object key: {prefix}/{uuid}/{filename}
|
// {prefix}/{uuid}/{filename}
|
||||||
objectKey := fmt.Sprintf("%s/%s/%s", input.Prefix, uuid.New().String(), input.Name)
|
objectKey := fmt.Sprintf("%s/%s/%s", input.Prefix, uuid.New().String(), input.Name)
|
||||||
|
|
||||||
// Create the database record (contains_content = false initially)
|
// Row is written first with contains_content=false; ConfirmUpload flips it.
|
||||||
obj := &Object{
|
obj := &Object{
|
||||||
Name: input.Name,
|
Name: input.Name,
|
||||||
ContentType: input.ContentType,
|
ContentType: input.ContentType,
|
||||||
@@ -92,8 +92,7 @@ func (s *serviceImpl) PrepareUpload(ctx context.Context, input PrepareUploadInpu
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a signed URL for uploading with Content-Length enforcement
|
// Content-Length is part of the signature, so the client must send it verbatim.
|
||||||
// The Headers field specifies headers that MUST be included in the upload request
|
|
||||||
contentLengthHeader := fmt.Sprintf("Content-Length:%d", input.ContentLength)
|
contentLengthHeader := fmt.Sprintf("Content-Length:%d", input.ContentLength)
|
||||||
uploadURL, err := s.storageClient.Bucket(s.bucketName).SignedURL(objectKey, &storage.SignedURLOptions{
|
uploadURL, err := s.storageClient.Bucket(s.bucketName).SignedURL(objectKey, &storage.SignedURLOptions{
|
||||||
GoogleAccessID: s.googleServiceAccountEmail,
|
GoogleAccessID: s.googleServiceAccountEmail,
|
||||||
@@ -104,7 +103,7 @@ func (s *serviceImpl) PrepareUpload(ctx context.Context, input PrepareUploadInpu
|
|||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to generate signed upload URL", "error", err, "bucket", s.bucketName, "object_key", objectKey)
|
slog.Error("failed to generate signed upload URL", "error", err, "bucket", s.bucketName, "object_key", objectKey)
|
||||||
// Clean up the database record if we can't generate the URL
|
// Roll back the placeholder row.
|
||||||
if delErr := s.repo.delete(ctx, created.ID); delErr != nil {
|
if delErr := s.repo.delete(ctx, created.ID); delErr != nil {
|
||||||
slog.Warn("failed to cleanup db record after signed URL failure", "error", delErr, "object_id", created.ID)
|
slog.Warn("failed to cleanup db record after signed URL failure", "error", delErr, "object_id", created.ID)
|
||||||
}
|
}
|
||||||
@@ -130,7 +129,6 @@ func (s *serviceImpl) ConfirmUpload(ctx context.Context, objectID string) (*Obje
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the object exists in GCS and check its size matches expected
|
|
||||||
attrs, err := s.storageClient.Bucket(obj.BucketName).Object(obj.ObjectKey).Attrs(ctx)
|
attrs, err := s.storageClient.Bucket(obj.BucketName).Object(obj.ObjectKey).Attrs(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, storage.ErrObjectNotExist) {
|
if errors.Is(err, storage.ErrObjectNotExist) {
|
||||||
@@ -140,12 +138,10 @@ func (s *serviceImpl) ConfirmUpload(ctx context.Context, objectID string) (*Obje
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify content length matches what was declared
|
|
||||||
if attrs.Size != obj.ContentLength {
|
if attrs.Size != obj.ContentLength {
|
||||||
return nil, errors.Join(ErrInvalidInput, fmt.Errorf("content length mismatch: expected %d, got %d", obj.ContentLength, attrs.Size))
|
return nil, errors.Join(ErrInvalidInput, fmt.Errorf("content length mismatch: expected %d, got %d", obj.ContentLength, attrs.Size))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark as containing content
|
|
||||||
if err := s.repo.setContainsContent(ctx, objectID, true); err != nil {
|
if err := s.repo.setContainsContent(ctx, objectID, true); err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
@@ -153,14 +149,12 @@ func (s *serviceImpl) ConfirmUpload(ctx context.Context, objectID string) (*Obje
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch and return the updated object
|
|
||||||
return s.repo.getByID(ctx, objectID)
|
return s.repo.getByID(ctx, objectID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateFromReader streams bytes directly to GCS using the storage client and
|
// CreateFromReader streams bytes straight to GCS and writes the row in one
|
||||||
// records the depot_objects row in one shot. Unlike PrepareUpload, there is no
|
// shot — no signed URL, no client round-trip. For server-side flows that
|
||||||
// signed URL or client round-trip — the caller already has the bytes. Intended
|
// already have the bytes (e.g. transcoded variants).
|
||||||
// for worker-side flows (e.g. transcoded media variants).
|
|
||||||
func (s *serviceImpl) CreateFromReader(ctx context.Context, input CreateFromReaderInput, body io.Reader) (*Object, error) {
|
func (s *serviceImpl) CreateFromReader(ctx context.Context, input CreateFromReaderInput, body io.Reader) (*Object, error) {
|
||||||
if input.Name == "" {
|
if input.Name == "" {
|
||||||
return nil, errors.Join(ErrInvalidInput, errors.New("name is required"))
|
return nil, errors.Join(ErrInvalidInput, errors.New("name is required"))
|
||||||
@@ -174,7 +168,7 @@ func (s *serviceImpl) CreateFromReader(ctx context.Context, input CreateFromRead
|
|||||||
w := s.storageClient.Bucket(s.bucketName).Object(objectKey).NewWriter(ctx)
|
w := s.storageClient.Bucket(s.bucketName).Object(objectKey).NewWriter(ctx)
|
||||||
w.ContentType = input.ContentType
|
w.ContentType = input.ContentType
|
||||||
if _, err := io.Copy(w, body); err != nil {
|
if _, err := io.Copy(w, body); err != nil {
|
||||||
// Close to release resources, then surface the original copy error.
|
// Always release the writer; surface the copy error, not Close's.
|
||||||
if cerr := w.Close(); cerr != nil {
|
if cerr := w.Close(); cerr != nil {
|
||||||
slog.Warn("failed to close GCS writer after copy failure", "error", cerr, "object_key", objectKey)
|
slog.Warn("failed to close GCS writer after copy failure", "error", cerr, "object_key", objectKey)
|
||||||
}
|
}
|
||||||
@@ -197,7 +191,7 @@ func (s *serviceImpl) CreateFromReader(ctx context.Context, input CreateFromRead
|
|||||||
|
|
||||||
created, err := s.repo.create(ctx, obj)
|
created, err := s.repo.create(ctx, obj)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Best-effort: clean up the GCS object since we can't track it in the DB.
|
// Best-effort: drop the now-untracked GCS object.
|
||||||
if delErr := s.storageClient.Bucket(s.bucketName).Object(objectKey).Delete(ctx); delErr != nil {
|
if delErr := s.storageClient.Bucket(s.bucketName).Object(objectKey).Delete(ctx); delErr != nil {
|
||||||
slog.Warn("failed to clean up GCS object after db create failure", "error", delErr, "object_key", objectKey)
|
slog.Warn("failed to clean up GCS object after db create failure", "error", delErr, "object_key", objectKey)
|
||||||
}
|
}
|
||||||
@@ -227,7 +221,6 @@ func (s *serviceImpl) GetDownloadURL(ctx context.Context, objectID string) (stri
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a signed URL for downloading
|
|
||||||
downloadURL, err := s.storageClient.Bucket(obj.BucketName).SignedURL(obj.ObjectKey, &storage.SignedURLOptions{
|
downloadURL, err := s.storageClient.Bucket(obj.BucketName).SignedURL(obj.ObjectKey, &storage.SignedURLOptions{
|
||||||
GoogleAccessID: s.googleServiceAccountEmail,
|
GoogleAccessID: s.googleServiceAccountEmail,
|
||||||
Method: "GET",
|
Method: "GET",
|
||||||
@@ -250,14 +243,13 @@ func (s *serviceImpl) Delete(ctx context.Context, objectID string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete from GCS (ignore not found errors)
|
// GCS first so we don't strand an object after the row vanishes; missing object is fine.
|
||||||
gcsErr := s.storageClient.Bucket(obj.BucketName).Object(obj.ObjectKey).Delete(ctx)
|
gcsErr := s.storageClient.Bucket(obj.BucketName).Object(obj.ObjectKey).Delete(ctx)
|
||||||
if gcsErr != nil && !errors.Is(gcsErr, storage.ErrObjectNotExist) {
|
if gcsErr != nil && !errors.Is(gcsErr, storage.ErrObjectNotExist) {
|
||||||
slog.Error("failed to delete object from GCS", "error", gcsErr, "bucket", obj.BucketName, "object_key", obj.ObjectKey)
|
slog.Error("failed to delete object from GCS", "error", gcsErr, "bucket", obj.BucketName, "object_key", obj.ObjectKey)
|
||||||
return gcsErr
|
return gcsErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete from database
|
|
||||||
if err := s.repo.delete(ctx, objectID); err != nil {
|
if err := s.repo.delete(ctx, objectID); err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
|
|||||||
@@ -25,12 +25,8 @@ type PortalSessionResponse struct {
|
|||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetNetworkUsage returns the freemium quota state for the authenticated
|
// GetNetworkUsage reports today's usage, daily limit (nil on pro), and reset
|
||||||
// caller's current network: how many messages they've used today, the daily
|
// time. Open to any network member since the UI surfaces it to every sender.
|
||||||
// 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.
|
|
||||||
func (h *Handler) GetNetworkUsage(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) GetNetworkUsage(w http.ResponseWriter, r *http.Request) {
|
||||||
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -170,8 +166,8 @@ func (h *Handler) HandleStripeWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadNetworkForAdmin resolves the {id} path param and verifies the caller
|
// Resolves {id}, verifies the caller is admin. On failure writes the HTTP
|
||||||
// is the network's admin. On failure it writes the HTTP error and returns ok=false.
|
// error and returns ok=false.
|
||||||
func (h *Handler) loadNetworkForAdmin(w http.ResponseWriter, r *http.Request) (*network.Network, string, bool) {
|
func (h *Handler) loadNetworkForAdmin(w http.ResponseWriter, r *http.Request) (*network.Network, string, bool) {
|
||||||
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/flowy-live/llink/internal/billing"
|
"github.com/flowy-live/llink/internal/billing"
|
||||||
"github.com/flowy-live/llink/internal/depot"
|
"github.com/flowy-live/llink/internal/depot"
|
||||||
"github.com/flowy-live/llink/internal/human"
|
"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/livekit"
|
||||||
"github.com/flowy-live/llink/internal/middleware"
|
"github.com/flowy-live/llink/internal/middleware"
|
||||||
"github.com/flowy-live/llink/internal/network"
|
"github.com/flowy-live/llink/internal/network"
|
||||||
@@ -32,6 +33,7 @@ type Handler struct {
|
|||||||
depotSvc depot.Service
|
depotSvc depot.Service
|
||||||
waitlistSvc waitlist.Service
|
waitlistSvc waitlist.Service
|
||||||
billingSvc billing.Service
|
billingSvc billing.Service
|
||||||
|
pushTokenSvc pushnotify.Service
|
||||||
livekitClient livekit.Client
|
livekitClient livekit.Client
|
||||||
firestoreClient *firestore.Client
|
firestoreClient *firestore.Client
|
||||||
}
|
}
|
||||||
@@ -44,6 +46,7 @@ func NewHandler(
|
|||||||
depotSvc depot.Service,
|
depotSvc depot.Service,
|
||||||
waitlistSvc waitlist.Service,
|
waitlistSvc waitlist.Service,
|
||||||
billingSvc billing.Service,
|
billingSvc billing.Service,
|
||||||
|
pushTokenSvc pushnotify.Service,
|
||||||
livekitClient livekit.Client,
|
livekitClient livekit.Client,
|
||||||
firestoreClient *firestore.Client,
|
firestoreClient *firestore.Client,
|
||||||
) *Handler {
|
) *Handler {
|
||||||
@@ -55,6 +58,7 @@ func NewHandler(
|
|||||||
depotSvc: depotSvc,
|
depotSvc: depotSvc,
|
||||||
waitlistSvc: waitlistSvc,
|
waitlistSvc: waitlistSvc,
|
||||||
billingSvc: billingSvc,
|
billingSvc: billingSvc,
|
||||||
|
pushTokenSvc: pushTokenSvc,
|
||||||
livekitClient: livekitClient,
|
livekitClient: livekitClient,
|
||||||
firestoreClient: firestoreClient,
|
firestoreClient: firestoreClient,
|
||||||
}
|
}
|
||||||
@@ -168,7 +172,7 @@ type DepotObject struct {
|
|||||||
// Auth Handlers
|
// 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) {
|
func (h *Handler) RequestSignInCode(w http.ResponseWriter, r *http.Request) {
|
||||||
var req RequestSignInCodeRequest
|
var req RequestSignInCodeRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-create human if doesn't exist
|
|
||||||
_, err := h.humanSvc.GetOrCreateByEmail(r.Context(), req.Email)
|
_, err := h.humanSvc.GetOrCreateByEmail(r.Context(), req.Email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to get or create human", "error", err, "email", req.Email)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request sign-in code
|
|
||||||
if err := h.authSvc.RequestSignInCode(r.Context(), req.Email); err != nil {
|
if err := h.authSvc.RequestSignInCode(r.Context(), req.Email); err != nil {
|
||||||
slog.Error("failed to request sign-in code", "error", err, "email", req.Email)
|
slog.Error("failed to request sign-in code", "error", err, "email", req.Email)
|
||||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
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)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SignIn verifies the code and returns a session token
|
|
||||||
func (h *Handler) SignIn(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) SignIn(w http.ResponseWriter, r *http.Request) {
|
||||||
var req SignInRequest
|
var req SignInRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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
|
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)
|
hum, err := h.humanSvc.GetByEmail(r.Context(), req.Email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, human.ErrNotFound) {
|
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)
|
json.NewEncoder(w).Encode(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FirebaseToken mints a Firebase custom token for the authenticated human so
|
// FirebaseToken mints a custom token so the client can signInWithCustomToken
|
||||||
// the client can signInWithCustomToken and have request.auth.uid populated in
|
// and have request.auth.uid populated in Firestore security rules.
|
||||||
// Firestore security rules.
|
|
||||||
func (h *Handler) FirebaseToken(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) FirebaseToken(w http.ResponseWriter, r *http.Request) {
|
||||||
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -265,7 +265,6 @@ func (h *Handler) FirebaseToken(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewEncoder(w).Encode(FirebaseTokenResponse{Token: token})
|
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) {
|
func (h *Handler) SignOut(w http.ResponseWriter, r *http.Request) {
|
||||||
token := extractBearerToken(r)
|
token := extractBearerToken(r)
|
||||||
if token == "" {
|
if token == "" {
|
||||||
@@ -282,7 +281,6 @@ func (h *Handler) SignOut(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCurrentHuman returns the authenticated human
|
|
||||||
func (h *Handler) GetCurrentHuman(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) GetCurrentHuman(w http.ResponseWriter, r *http.Request) {
|
||||||
email, ok := middleware.EmailFromContext(r.Context())
|
email, ok := middleware.EmailFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -310,7 +308,6 @@ type UpdateSettingsRequest struct {
|
|||||||
EmailNotificationsEnabled *bool `json:"email_notifications_enabled"`
|
EmailNotificationsEnabled *bool `json:"email_notifications_enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSettings updates the authenticated human's settings
|
|
||||||
func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -339,7 +336,6 @@ func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Network Handlers
|
// Network Handlers
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
// CreateNetwork creates a new network
|
|
||||||
func (h *Handler) CreateNetwork(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) CreateNetwork(w http.ResponseWriter, r *http.Request) {
|
||||||
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -376,7 +372,6 @@ func (h *Handler) CreateNetwork(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewEncoder(w).Encode(resp)
|
json.NewEncoder(w).Encode(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListNetworks retrieves networks for the authenticated human
|
|
||||||
func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) {
|
||||||
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -405,7 +400,6 @@ func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewEncoder(w).Encode(resp)
|
json.NewEncoder(w).Encode(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetNetwork retrieves a specific network
|
|
||||||
func (h *Handler) GetNetwork(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) GetNetwork(w http.ResponseWriter, r *http.Request) {
|
||||||
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -452,8 +446,8 @@ func (h *Handler) GetNetwork(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewEncoder(w).Encode(resp)
|
json.NewEncoder(w).Encode(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddMembersToNetwork adds members to a network. Registered users are added as members,
|
// AddMembersToNetwork routes registered users into membership and emails an
|
||||||
// unregistered users receive email invitations.
|
// invitation to the rest.
|
||||||
func (h *Handler) AddMembersToNetwork(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) AddMembersToNetwork(w http.ResponseWriter, r *http.Request) {
|
||||||
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -489,7 +483,6 @@ func (h *Handler) AddMembersToNetwork(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve emails: registered users become members, unregistered get invitations
|
|
||||||
var memberHumanIds []string
|
var memberHumanIds []string
|
||||||
var inviteEmails []string
|
var inviteEmails []string
|
||||||
for _, email := range req.EmailAddresses {
|
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)
|
net, err := h.networkSvc.GetByID(r.Context(), networkID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to get network after adding members", "error", err, "network_id", networkID)
|
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)
|
json.NewEncoder(w).Encode(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveMemberFromNetwork removes a member from a network. Admin-only.
|
// RemoveMemberFromNetwork is admin-only. Admins cannot remove themselves
|
||||||
// Admins cannot remove themselves — doing so would leave networks.admin_human_id
|
// (would orphan networks.admin_human_id); removing a non-member is a no-op (204).
|
||||||
// dangling. Removal of a non-member is a no-op (204).
|
|
||||||
func (h *Handler) RemoveMemberFromNetwork(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) RemoveMemberFromNetwork(w http.ResponseWriter, r *http.Request) {
|
||||||
net, _, ok := h.loadNetworkForAdmin(w, r)
|
net, _, ok := h.loadNetworkForAdmin(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -575,7 +566,6 @@ func (h *Handler) RemoveMemberFromNetwork(w http.ResponseWriter, r *http.Request
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListInvitationsForNetwork returns pending invitations for a network
|
|
||||||
func (h *Handler) ListInvitationsForNetwork(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ListInvitationsForNetwork(w http.ResponseWriter, r *http.Request) {
|
||||||
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -621,7 +611,6 @@ func (h *Handler) ListInvitationsForNetwork(w http.ResponseWriter, r *http.Reque
|
|||||||
json.NewEncoder(w).Encode(resp)
|
json.NewEncoder(w).Encode(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListMyInvitations returns pending invitations for the authenticated user
|
|
||||||
func (h *Handler) ListMyInvitations(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ListMyInvitations(w http.ResponseWriter, r *http.Request) {
|
||||||
email, ok := middleware.EmailFromContext(r.Context())
|
email, ok := middleware.EmailFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -650,7 +639,6 @@ func (h *Handler) ListMyInvitations(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewEncoder(w).Encode(resp)
|
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) {
|
func (h *Handler) AcceptInvitation(w http.ResponseWriter, r *http.Request) {
|
||||||
email, ok := middleware.EmailFromContext(r.Context())
|
email, ok := middleware.EmailFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -683,7 +671,6 @@ func (h *Handler) AcceptInvitation(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RevokeInvitation revokes a pending invitation from a network
|
|
||||||
func (h *Handler) RevokeInvitation(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) RevokeInvitation(w http.ResponseWriter, r *http.Request) {
|
||||||
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -728,7 +715,7 @@ func (h *Handler) RevokeInvitation(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
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) {
|
func (h *Handler) DownloadParticleMedia(w http.ResponseWriter, r *http.Request) {
|
||||||
_, ok := middleware.EmailFromContext(r.Context())
|
_, ok := middleware.EmailFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -755,7 +742,7 @@ func (h *Handler) DownloadParticleMedia(w http.ResponseWriter, r *http.Request)
|
|||||||
// Depot Handlers
|
// 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) {
|
func (h *Handler) PrepareUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -813,7 +800,6 @@ func (h *Handler) PrepareUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewEncoder(w).Encode(resp)
|
json.NewEncoder(w).Encode(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfirmUpload confirms that an upload has been completed
|
|
||||||
func (h *Handler) ConfirmUpload(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ConfirmUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
_, ok := middleware.EmailFromContext(r.Context())
|
_, ok := middleware.EmailFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -881,7 +867,7 @@ type InviteWaitlistEntrantRequest struct {
|
|||||||
// Waitlist Handlers
|
// 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) {
|
func (h *Handler) AddToWaitlist(w http.ResponseWriter, r *http.Request) {
|
||||||
var req AddToWaitlistRequest
|
var req AddToWaitlistRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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)
|
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) {
|
func (h *Handler) GetWaitlist(w http.ResponseWriter, r *http.Request) {
|
||||||
if !middleware.IsAdminFromContext(r.Context()) {
|
if !middleware.IsAdminFromContext(r.Context()) {
|
||||||
http.Error(w, "forbidden", http.StatusForbidden)
|
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)
|
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) {
|
func (h *Handler) GetWaitlistEntry(w http.ResponseWriter, r *http.Request) {
|
||||||
if !middleware.IsAdminFromContext(r.Context()) {
|
if !middleware.IsAdminFromContext(r.Context()) {
|
||||||
http.Error(w, "forbidden", http.StatusForbidden)
|
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))
|
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) {
|
func (h *Handler) InviteWaitlistEntrant(w http.ResponseWriter, r *http.Request) {
|
||||||
if !middleware.IsAdminFromContext(r.Context()) {
|
if !middleware.IsAdminFromContext(r.Context()) {
|
||||||
http.Error(w, "forbidden", http.StatusForbidden)
|
http.Error(w, "forbidden", http.StatusForbidden)
|
||||||
@@ -1079,7 +1065,7 @@ func (h *Handler) GetLivekitToken(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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
|
roomName := req.NetworkId + "/" + req.StreamId
|
||||||
|
|
||||||
token, err := h.livekitClient.GetJoinToken(roomName, humanId, humanEmail)
|
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()})
|
json.NewEncoder(w).Encode(GetLivekitTokenResponse{Token: token, ServerUrl: h.livekitClient.ServerUrl()})
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleLivekitWebhook processes LiveKit webhook events for huddle presence.
|
// HandleLivekitWebhook verifies the webhook signature (not user auth) and
|
||||||
// It verifies the webhook signature (not user auth), then updates the stream
|
// reconciles huddle_active_participants on the stream particle in Firestore.
|
||||||
// particle's huddle_active_participants field in Firestore.
|
|
||||||
func (h *Handler) HandleLivekitWebhook(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) HandleLivekitWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
event, err := webhook.ReceiveWebhookEvent(r, h.livekitClient.KeyProvider())
|
event, err := webhook.ReceiveWebhookEvent(r, h.livekitClient.KeyProvider())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1109,13 +1094,12 @@ func (h *Handler) HandleLivekitWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
switch eventType {
|
switch eventType {
|
||||||
case "participant_joined", "participant_left", "room_finished":
|
case "participant_joined", "participant_left", "room_finished":
|
||||||
// Handle these events
|
// fall through
|
||||||
default:
|
default:
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse room name to extract networkId and streamId
|
|
||||||
roomName := event.GetRoom().GetName()
|
roomName := event.GetRoom().GetName()
|
||||||
parts := strings.SplitN(roomName, "/", 2)
|
parts := strings.SplitN(roomName, "/", 2)
|
||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
@@ -1131,14 +1115,13 @@ func (h *Handler) HandleLivekitWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
var participantIds []string
|
var participantIds []string
|
||||||
|
|
||||||
if eventType == "room_finished" {
|
if eventType == "room_finished" {
|
||||||
// Room is done — clear the participants
|
|
||||||
participantIds = []string{}
|
participantIds = []string{}
|
||||||
} else {
|
} 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)
|
participants, err := h.livekitClient.ListParticipants(ctx, roomName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to list participants", "error", err, "room", roomName)
|
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)
|
w.WriteHeader(http.StatusOK)
|
||||||
return
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sound defaults to "default" when empty (set in Send).
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status is "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 does NOT poll receipts and does NOT retry — fire-and-forget,
|
||||||
|
// with DeviceNotRegistered handled out-of-band by the notifier.
|
||||||
|
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 batches msgs (cap expoMaxBatchSize) and preserves input order:
|
||||||
|
// tickets[i] corresponds to msgs[i]. A request-level failure aborts the
|
||||||
|
// remaining batches; tickets already collected are returned with the error.
|
||||||
|
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] + "…"
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package pushnotify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 fans out one particle to Expo:
|
||||||
|
// 1. Resolve recipients (visibility ∩ network members, minus sender).
|
||||||
|
// 2. Send a batched Expo request for every recipient's tokens.
|
||||||
|
// 3. Prune tokens Expo reports as DeviceNotRegistered.
|
||||||
|
//
|
||||||
|
// Online/offline presence is intentionally NOT consulted: a live WebSocket
|
||||||
|
// is a poor proxy for "user is actively consuming this particle right now"
|
||||||
|
// (backgrounded apps, idle desktops, etc. all look online), and the resulting
|
||||||
|
// false-negatives outweigh the duplicate-notification cost on a focused
|
||||||
|
// device, which the OS handles via Focus modes and per-app settings.
|
||||||
|
type Notifier struct {
|
||||||
|
networkR network.Reader
|
||||||
|
tokens Service
|
||||||
|
expo *ExpoClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNotifier(networkR network.Reader, tokens Service, expo *ExpoClient) *Notifier {
|
||||||
|
return &Notifier{
|
||||||
|
networkR: networkR,
|
||||||
|
tokens: tokens,
|
||||||
|
expo: expo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Notifier) NotifyParticleCreated(ctx context.Context, in NotifyInput) error {
|
||||||
|
if in.NetworkID == "" || in.ParticleID == "" {
|
||||||
|
slog.Info("pushnotify: skip — missing ids",
|
||||||
|
"networkID", in.NetworkID,
|
||||||
|
"particleID", 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)
|
||||||
|
recipientsBeforeSenderFilter := len(recipients)
|
||||||
|
recipients = filterOut(recipients, in.SenderHumanID)
|
||||||
|
if len(recipients) == 0 {
|
||||||
|
slog.Info("pushnotify: skip — no recipients",
|
||||||
|
"networkID", in.NetworkID,
|
||||||
|
"particleID", in.ParticleID,
|
||||||
|
"senderHumanID", in.SenderHumanID,
|
||||||
|
"members", len(members),
|
||||||
|
"visibleTo", in.StreamVisibleTo,
|
||||||
|
"resolved", recipientsBeforeSenderFilter,
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, err := n.tokens.ListForHumans(ctx, recipients)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("token lookup: %w", err)
|
||||||
|
}
|
||||||
|
if len(tokens) == 0 {
|
||||||
|
slog.Info("pushnotify: skip — no tokens for recipients",
|
||||||
|
"networkID", in.NetworkID,
|
||||||
|
"particleID", in.ParticleID,
|
||||||
|
"recipients", len(recipients),
|
||||||
|
"recipientIDs", recipients,
|
||||||
|
)
|
||||||
|
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),
|
||||||
|
"tokens", len(tokens),
|
||||||
|
"sent", len(tickets),
|
||||||
|
)
|
||||||
|
|
||||||
|
n.cleanupDeadTokens(ctx, msgs, tickets)
|
||||||
|
|
||||||
|
if sendErr != nil {
|
||||||
|
return fmt.Errorf("expo send: %w", sendErr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceNotRegistered is the one feedback signal we honor; other ticket
|
||||||
|
// errors (MessageTooBig, RateLimit, …) are logged and dropped.
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package pushnotify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service stores per-device Expo push tokens and exposes the operations
|
||||||
|
// needed by both the HTTP handlers and the worker-side notifier.
|
||||||
|
type Service interface {
|
||||||
|
// Register returns ErrInvalidToken / ErrInvalidPlatform on bad input.
|
||||||
|
Register(ctx context.Context, humanID string, in RegisterInput) error
|
||||||
|
// Unregister is scoped to humanID so a user can't delete another user's
|
||||||
|
// token. Returns ErrNotFound if the token isn't owned by humanID.
|
||||||
|
Unregister(ctx context.Context, humanID, token string) error
|
||||||
|
// ListForHumans returns an empty slice when nothing matches.
|
||||||
|
ListForHumans(ctx context.Context, humanIDs []string) ([]*PushToken, error)
|
||||||
|
// DeleteByToken removes a token regardless of owner — used to prune after
|
||||||
|
// Expo reports 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)
|
||||||
|
}
|
||||||
@@ -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[")
|
||||||
|
}
|
||||||
@@ -13,15 +13,12 @@ var ErrNotFound = errors.New("human not found")
|
|||||||
|
|
||||||
type Service interface {
|
type Service interface {
|
||||||
GetOrCreateByEmail(ctx context.Context, email string) (*Human, error)
|
GetOrCreateByEmail(ctx context.Context, email string) (*Human, error)
|
||||||
// GetByEmail returns ErrNotFound if no human found
|
// GetByEmail returns ErrNotFound if no human found.
|
||||||
GetByEmail(ctx context.Context, email string) (*Human, error)
|
GetByEmail(ctx context.Context, email string) (*Human, error)
|
||||||
// GetByID returns ErrNotFound if no human found
|
// GetByID returns ErrNotFound if no human found.
|
||||||
GetByID(ctx context.Context, id string) (*Human, error)
|
GetByID(ctx context.Context, id string) (*Human, error)
|
||||||
// ListAll returns all humans
|
|
||||||
ListAll(ctx context.Context) ([]*Human, error)
|
ListAll(ctx context.Context) ([]*Human, error)
|
||||||
// UpdateEmailNotificationsEnabled toggles email notification preference
|
|
||||||
UpdateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error
|
UpdateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error
|
||||||
// UpdateLastEmailNotificationSentAt records when the last notification email was sent
|
|
||||||
UpdateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error
|
UpdateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,13 +11,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Client interface {
|
type Client interface {
|
||||||
// GetJoinToken generates a JWT for a participant to join a room.
|
// GetJoinToken mints a participant JWT; name surfaces as the display name.
|
||||||
// Name will show up in the participant data.
|
|
||||||
GetJoinToken(roomId string, humanId string, name string) (string, error)
|
GetJoinToken(roomId string, humanId string, name string) (string, error)
|
||||||
ServerUrl() string
|
ServerUrl() string
|
||||||
// ListParticipants returns the current participants in a room.
|
|
||||||
ListParticipants(ctx context.Context, roomName string) ([]*livekit.ParticipantInfo, error)
|
ListParticipants(ctx context.Context, roomName string) ([]*livekit.ParticipantInfo, error)
|
||||||
// KeyProvider returns the key provider for verifying webhook signatures.
|
// KeyProvider is used by handlers to verify webhook signatures.
|
||||||
KeyProvider() auth.KeyProvider
|
KeyProvider() auth.KeyProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,9 @@ import (
|
|||||||
|
|
||||||
//go:generate go tool mockgen -source ./membershippublisher.go -destination ./mocks/membershippublisher.go
|
//go:generate go tool mockgen -source ./membershippublisher.go -destination ./mocks/membershippublisher.go
|
||||||
|
|
||||||
// MembershipPublisher publishes network membership changes to the live store
|
// MembershipPublisher fans network membership changes out to Firestore.
|
||||||
// (Firestore) that clients subscribe to. Postgres remains the source of truth;
|
// Postgres is the source of truth; the reconciler heals drift, so publish
|
||||||
// the membership reconciler heals any drift, so callers may log and ignore
|
// failures are safe to log and ignore.
|
||||||
// publish failures.
|
|
||||||
type MembershipPublisher interface {
|
type MembershipPublisher interface {
|
||||||
Add(ctx context.Context, humanId, networkID string) error
|
Add(ctx context.Context, humanId, networkID string) error
|
||||||
Remove(ctx context.Context, humanId, networkID string) error
|
Remove(ctx context.Context, humanId, networkID string) error
|
||||||
|
|||||||
@@ -30,17 +30,15 @@ type TranscodeInput struct {
|
|||||||
type TranscodeOutput struct {
|
type TranscodeOutput struct {
|
||||||
TempLocalFilePath string
|
TempLocalFilePath string
|
||||||
OutputMimeType string
|
OutputMimeType string
|
||||||
// Extension such as ".m4a" or ".mp4"
|
OutputExt string // e.g. ".m4a" or ".mp4"
|
||||||
OutputExt string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrInvalidInput error = errors.New("invalid input")
|
ErrInvalidInput error = errors.New("invalid input")
|
||||||
)
|
)
|
||||||
|
|
||||||
// TranscodeToMp4 takes in any audio or video source URL and
|
// TranscodeToMp4 writes the result to a temp file; caller is responsible
|
||||||
// returns the filepath of the transcoded media
|
// for deleting TempLocalFilePath.
|
||||||
// WARNING: caller responsible for deleting TempLocalFilePath
|
|
||||||
func TranscodeToMp4(ctx context.Context, input TranscodeInput) (*TranscodeOutput, error) {
|
func TranscodeToMp4(ctx context.Context, input TranscodeInput) (*TranscodeOutput, error) {
|
||||||
if input.SourceURL == "" || input.MimeType == "" {
|
if input.SourceURL == "" || input.MimeType == "" {
|
||||||
return nil, ErrInvalidInput
|
return nil, ErrInvalidInput
|
||||||
@@ -74,11 +72,9 @@ func TranscodeToMp4(ctx context.Context, input TranscodeInput) (*TranscodeOutput
|
|||||||
tmpPath,
|
tmpPath,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Cap encoder parallelism and lookahead to keep memory bounded — screen
|
// 1440p–4K screen recordings + libx264's lookahead buffers can OOM the
|
||||||
// recordings come in at native display resolution (often 1440p–4K) and
|
// worker. Bound parallelism/lookahead and downscale to 1080p; the
|
||||||
// libx264's per-thread lookahead/reference buffers blow past the worker's
|
// original WebM stays in GCS untouched.
|
||||||
// memory limit otherwise. Output is also downscaled to 1080p max, which
|
|
||||||
// mobile playback won't notice; the original WebM stays in GCS untouched.
|
|
||||||
args = []string{
|
args = []string{
|
||||||
"-y", "-i", input.SourceURL,
|
"-y", "-i", input.SourceURL,
|
||||||
"-vf", "scale='min(1920,iw)':-2:flags=lanczos",
|
"-vf", "scale='min(1920,iw)':-2:flags=lanczos",
|
||||||
|
|||||||
@@ -17,40 +17,35 @@ const (
|
|||||||
isAdminContextKey contextKey = "isAdmin"
|
isAdminContextKey contextKey = "isAdmin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// WithEmail adds the email to the context
|
|
||||||
func WithEmail(ctx context.Context, email string) context.Context {
|
func WithEmail(ctx context.Context, email string) context.Context {
|
||||||
return context.WithValue(ctx, emailContextKey, email)
|
return context.WithValue(ctx, emailContextKey, email)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmailFromContext extracts the email from the context
|
|
||||||
func EmailFromContext(ctx context.Context) (string, bool) {
|
func EmailFromContext(ctx context.Context) (string, bool) {
|
||||||
email, ok := ctx.Value(emailContextKey).(string)
|
email, ok := ctx.Value(emailContextKey).(string)
|
||||||
return email, ok
|
return email, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithHumanId adds the humanId to the context
|
|
||||||
func WithHumanId(ctx context.Context, humanId string) context.Context {
|
func WithHumanId(ctx context.Context, humanId string) context.Context {
|
||||||
return context.WithValue(ctx, humanIdContextKey, humanId)
|
return context.WithValue(ctx, humanIdContextKey, humanId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HumanIdFromContext extracts the id from the context
|
|
||||||
func HumanIdFromContext(ctx context.Context) (string, bool) {
|
func HumanIdFromContext(ctx context.Context) (string, bool) {
|
||||||
humanId, ok := ctx.Value(humanIdContextKey).(string)
|
humanId, ok := ctx.Value(humanIdContextKey).(string)
|
||||||
return humanId, ok
|
return humanId, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithIsAdmin adds the admin flag to the context
|
|
||||||
func WithIsAdmin(ctx context.Context, isAdmin bool) context.Context {
|
func WithIsAdmin(ctx context.Context, isAdmin bool) context.Context {
|
||||||
return context.WithValue(ctx, isAdminContextKey, isAdmin)
|
return context.WithValue(ctx, isAdminContextKey, isAdmin)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsAdminFromContext extracts the admin flag from the context
|
|
||||||
func IsAdminFromContext(ctx context.Context) bool {
|
func IsAdminFromContext(ctx context.Context) bool {
|
||||||
isAdmin, ok := ctx.Value(isAdminContextKey).(bool)
|
isAdmin, ok := ctx.Value(isAdminContextKey).(bool)
|
||||||
return ok && isAdmin
|
return ok && isAdmin
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth returns a middleware that validates the session token and adds the email to the context
|
// Auth validates the bearer session token and populates email/humanId/isAdmin
|
||||||
|
// into the request context for downstream handlers.
|
||||||
func Auth(authSvc auth.AuthService) func(http.Handler) http.Handler {
|
func Auth(authSvc auth.AuthService) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -66,7 +61,6 @@ func Auth(authSvc auth.AuthService) func(http.Handler) http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-extend session
|
|
||||||
if err := authSvc.ExtendSession(r.Context(), token); err != nil {
|
if err := authSvc.ExtendSession(r.Context(), token); err != nil {
|
||||||
slog.Warn("failed to extend session", "error", err)
|
slog.Warn("failed to extend session", "error", err)
|
||||||
}
|
}
|
||||||
@@ -79,7 +73,6 @@ func Auth(authSvc auth.AuthService) func(http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractBearerToken extracts the token from the Authorization header
|
|
||||||
func extractBearerToken(r *http.Request) string {
|
func extractBearerToken(r *http.Request) string {
|
||||||
authHeader := r.Header.Get("Authorization")
|
authHeader := r.Header.Get("Authorization")
|
||||||
if authHeader == "" {
|
if authHeader == "" {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package middleware
|
|||||||
|
|
||||||
import "net/http"
|
import "net/http"
|
||||||
|
|
||||||
// CORS wraps a handler to add CORS headers and handle preflight requests.
|
// CORS adds CORS headers and short-circuits preflight requests.
|
||||||
func CORS(allowedOrigins []string) func(http.Handler) http.Handler {
|
func CORS(allowedOrigins []string) func(http.Handler) http.Handler {
|
||||||
originSet := make(map[string]struct{}, len(allowedOrigins))
|
originSet := make(map[string]struct{}, len(allowedOrigins))
|
||||||
for _, o := range allowedOrigins {
|
for _, o := range allowedOrigins {
|
||||||
@@ -13,7 +13,7 @@ func CORS(allowedOrigins []string) func(http.Handler) http.Handler {
|
|||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
origin := r.Header.Get("Origin")
|
origin := r.Header.Get("Origin")
|
||||||
|
|
||||||
// Check if the origin is allowed (empty allowedOrigins means allow all)
|
// Empty allowedOrigins means allow all.
|
||||||
allowed := len(originSet) == 0
|
allowed := len(originSet) == 0
|
||||||
if !allowed {
|
if !allowed {
|
||||||
_, allowed = originSet[origin]
|
_, allowed = originSet[origin]
|
||||||
@@ -27,7 +27,6 @@ func CORS(allowedOrigins []string) func(http.Handler) http.Handler {
|
|||||||
w.Header().Set("Access-Control-Max-Age", "86400")
|
w.Header().Set("Access-Control-Max-Age", "86400")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle preflight
|
|
||||||
if r.Method == http.MethodOptions {
|
if r.Method == http.MethodOptions {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ type Reader interface {
|
|||||||
ListForHuman(ctx context.Context, humanId string) ([]*Network, error)
|
ListForHuman(ctx context.Context, humanId string) ([]*Network, error)
|
||||||
// IsMember returns ErrInvalidHumanId if humanId is empty.
|
// IsMember returns ErrInvalidHumanId if humanId is empty.
|
||||||
IsMember(ctx context.Context, networkID, humanId string) (bool, error)
|
IsMember(ctx context.Context, networkID, humanId string) (bool, error)
|
||||||
// ListAll returns all networks with their members
|
// ListMembers returns an empty slice if the network doesn't exist.
|
||||||
|
ListMembers(ctx context.Context, networkID string) ([]string, error)
|
||||||
ListAll(ctx context.Context) ([]*Network, error)
|
ListAll(ctx context.Context) ([]*Network, error)
|
||||||
// ListAllMemberships returns humanId -> networkIds for every human with at
|
// ListAllMemberships returns humanId -> networkIds for every human with at
|
||||||
// least one membership. Humans with zero memberships are absent from the map.
|
// least one membership. Humans with zero memberships are absent from the map.
|
||||||
// Used by the membership reconciler to diff the Firestore mirror.
|
|
||||||
ListAllMemberships(ctx context.Context) (map[string][]string, error)
|
ListAllMemberships(ctx context.Context) (map[string][]string, error)
|
||||||
|
|
||||||
CountSeats(ctx context.Context, networkID string) (int, error)
|
CountSeats(ctx context.Context, networkID string) (int, error)
|
||||||
@@ -34,8 +34,8 @@ type readerImpl struct {
|
|||||||
repo repository
|
repo repository
|
||||||
}
|
}
|
||||||
|
|
||||||
// newReader returns the concrete reader. Used by NewService to embed without
|
// newReader exposes the concrete type so the service can embed it without
|
||||||
// going through the Reader interface (which would hide pool/repo).
|
// hiding pool/repo behind the Reader interface.
|
||||||
func newReader(pool *pgxpool.Pool) *readerImpl {
|
func newReader(pool *pgxpool.Pool) *readerImpl {
|
||||||
return &readerImpl{
|
return &readerImpl{
|
||||||
pool: pool,
|
pool: pool,
|
||||||
@@ -69,6 +69,12 @@ func (r *readerImpl) IsMember(ctx context.Context, networkID, humanId string) (b
|
|||||||
return r.repo.isMember(ctx, networkID, humanId)
|
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) {
|
func (r *readerImpl) ListAll(ctx context.Context) ([]*Network, error) {
|
||||||
return r.repo.listAll(ctx)
|
return r.repo.listAll(ctx)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,8 @@ import (
|
|||||||
"go.jetify.com/typeid"
|
"go.jetify.com/typeid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// dbtx is the subset of pgx's query API shared by *pgxpool.Pool and pgx.Tx.
|
// dbtx is the subset of pgx's query API shared by *pgxpool.Pool and pgx.Tx,
|
||||||
// Used by repository helpers that the service layer may run either standalone
|
// so repository helpers can run standalone or inside a transaction.
|
||||||
// (against the pool) or inside a transaction.
|
|
||||||
type dbtx interface {
|
type dbtx interface {
|
||||||
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
|
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
|
||||||
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
||||||
@@ -57,8 +56,7 @@ type repository interface {
|
|||||||
deleteInvitation(ctx context.Context, db dbtx, networkID, email string) error
|
deleteInvitation(ctx context.Context, db dbtx, networkID, email string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// networkColumns lists every column selected when hydrating a Network.
|
// Centralized so SELECTs and scanNetwork stay in sync.
|
||||||
// Centralized to keep SELECTs and Scan() calls in sync.
|
|
||||||
const networkColumns = `id, name, admin_human_id, created_at`
|
const networkColumns = `id, name, admin_human_id, created_at`
|
||||||
|
|
||||||
func scanNetwork(row pgx.Row, n *Network) error {
|
func scanNetwork(row pgx.Row, n *Network) error {
|
||||||
@@ -283,8 +281,6 @@ func (r *repositoryImpl) listAll(ctx context.Context) ([]*Network, error) {
|
|||||||
return networks, nil
|
return networks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invitation methods
|
|
||||||
|
|
||||||
func (r *repositoryImpl) createInvitation(ctx context.Context, networkID, email string) error {
|
func (r *repositoryImpl) createInvitation(ctx context.Context, networkID, email string) error {
|
||||||
_, err := r.pool.Exec(ctx,
|
_, err := r.pool.Exec(ctx,
|
||||||
`INSERT INTO network_invitations (network_id, email) VALUES ($1, $2)
|
`INSERT INTO network_invitations (network_id, email) VALUES ($1, $2)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ var ErrInvalidRetentionHours = errors.New("message retention hours must be betwe
|
|||||||
type Service interface {
|
type Service interface {
|
||||||
Reader
|
Reader
|
||||||
|
|
||||||
// Create creates a network and adds adminHumanId as the first member. Returns ErrInvalidName if name is empty.
|
// Create adds adminHumanId as the first member. Returns ErrInvalidName if name is empty.
|
||||||
Create(ctx context.Context, name, adminHumanId string) (*Network, error)
|
Create(ctx context.Context, name, adminHumanId string) (*Network, error)
|
||||||
// SetName returns ErrNotFound or ErrInvalidName.
|
// SetName returns ErrNotFound or ErrInvalidName.
|
||||||
SetName(ctx context.Context, id, name string) error
|
SetName(ctx context.Context, id, name string) error
|
||||||
@@ -145,10 +145,8 @@ func (s *serviceImpl) RemoveMember(ctx context.Context, networkID, humanId strin
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// mirrorAddMembership / mirrorRemoveMembership keep the live store membership
|
// Mirror the live store membership projection (humans/{humanId}.networks).
|
||||||
// projection (humans/{humanId}.networks) in sync with Postgres. Called after
|
// Postgres is the source of truth: failures are logged and the reconciler heals drift.
|
||||||
// the Postgres transaction commits. Failures are logged but not returned:
|
|
||||||
// Postgres is the source of truth and the reconciler will heal drift.
|
|
||||||
func (s *serviceImpl) mirrorAddMembership(ctx context.Context, humanId, networkID string) {
|
func (s *serviceImpl) mirrorAddMembership(ctx context.Context, humanId, networkID string) {
|
||||||
if err := s.pub.Add(ctx, humanId, networkID); err != nil {
|
if err := s.pub.Add(ctx, humanId, networkID); err != nil {
|
||||||
slog.Error("membership publish add failed", "error", err, "humanId", humanId, "networkID", networkID)
|
slog.Error("membership publish add failed", "error", err, "humanId", humanId, "networkID", networkID)
|
||||||
@@ -161,8 +159,7 @@ func (s *serviceImpl) mirrorRemoveMembership(ctx context.Context, humanId, netwo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// mutateMembers runs fn in a tx, recounts seats, calls billing.SyncSeats,
|
// Runs fn in a tx and syncs seats to billing atomically. Any error rolls back.
|
||||||
// and commits. Any error rolls the membership change back.
|
|
||||||
func (s *serviceImpl) mutateMembers(ctx context.Context, networkID string, fn func(pgx.Tx) error) error {
|
func (s *serviceImpl) mutateMembers(ctx context.Context, networkID string, fn func(pgx.Tx) error) error {
|
||||||
tx, err := s.pool.Begin(ctx)
|
tx, err := s.pool.Begin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ParticleType represents the type of particle
|
|
||||||
type ParticleType string
|
type ParticleType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -20,7 +19,6 @@ const (
|
|||||||
// TypeThink ParticleType = "think"
|
// TypeThink ParticleType = "think"
|
||||||
)
|
)
|
||||||
|
|
||||||
// VisibilityMode represents how access to a particle is determined
|
|
||||||
type VisibilityMode string
|
type VisibilityMode string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -32,7 +30,6 @@ const (
|
|||||||
var ErrInvalidParticleType = errors.New("invalid particle type")
|
var ErrInvalidParticleType = errors.New("invalid particle type")
|
||||||
var ErrInvalidVisibilityMode = errors.New("invalid visibility mode")
|
var ErrInvalidVisibilityMode = errors.New("invalid visibility mode")
|
||||||
|
|
||||||
// ParseParticleType parses a string into a ParticleType
|
|
||||||
func ParseParticleType(s string) (ParticleType, error) {
|
func ParseParticleType(s string) (ParticleType, error) {
|
||||||
switch s {
|
switch s {
|
||||||
case string(TypeStream):
|
case string(TypeStream):
|
||||||
@@ -54,7 +51,6 @@ func ParseParticleType(s string) (ParticleType, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseVisibilityMode parses a string into a VisibilityMode
|
|
||||||
func ParseVisibilityMode(s string) (VisibilityMode, error) {
|
func ParseVisibilityMode(s string) (VisibilityMode, error) {
|
||||||
switch s {
|
switch s {
|
||||||
case "", string(VisibilityNetworkAll):
|
case "", string(VisibilityNetworkAll):
|
||||||
@@ -68,7 +64,6 @@ func ParseVisibilityMode(s string) (VisibilityMode, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream status values
|
|
||||||
type StreamStatus string
|
type StreamStatus string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -76,7 +71,6 @@ const (
|
|||||||
StreamStatusClosed StreamStatus = "closed"
|
StreamStatusClosed StreamStatus = "closed"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Particle represents a content particle in the system
|
|
||||||
type Particle struct {
|
type Particle struct {
|
||||||
ID string
|
ID string
|
||||||
Type ParticleType
|
Type ParticleType
|
||||||
@@ -89,7 +83,6 @@ type Particle struct {
|
|||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateInput represents the input for creating a new particle
|
|
||||||
type CreateInput struct {
|
type CreateInput struct {
|
||||||
Type ParticleType
|
Type ParticleType
|
||||||
NetworkID string
|
NetworkID string
|
||||||
@@ -99,18 +92,15 @@ type CreateInput struct {
|
|||||||
Visibility VisibilityMode
|
Visibility VisibilityMode
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListFilter represents filtering options for listing particles
|
|
||||||
type ListFilter struct {
|
type ListFilter struct {
|
||||||
Types []ParticleType
|
Types []ParticleType
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cursor represents a pagination cursor for bidirectional pagination
|
|
||||||
type Cursor struct {
|
type Cursor struct {
|
||||||
Position string // particle ID or timestamp
|
Position string // particle ID or timestamp
|
||||||
Direction string // "before" or "after"
|
Direction string // "before" or "after"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParticleList represents a paginated list of particles
|
|
||||||
type ParticleList struct {
|
type ParticleList struct {
|
||||||
Particles []*Particle
|
Particles []*Particle
|
||||||
HasMore bool
|
HasMore bool
|
||||||
@@ -118,57 +108,48 @@ type ParticleList struct {
|
|||||||
PrevCursor *Cursor
|
PrevCursor *Cursor
|
||||||
}
|
}
|
||||||
|
|
||||||
// StreamData represents the data stored for stream particles
|
|
||||||
type StreamData struct {
|
type StreamData struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Status string `json:"status"` // "open" or "closed"
|
Status string `json:"status"` // "open" or "closed"
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FolderData represents the data stored for folder particles
|
|
||||||
type FolderData struct {
|
type FolderData struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Color *string `json:"color"`
|
Color *string `json:"color"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MediaData represents the data stored for media particles
|
|
||||||
type MediaData struct {
|
type MediaData struct {
|
||||||
ObjectID string `json:"object_id"` // reference to storage object
|
ObjectID string `json:"object_id"`
|
||||||
MimeType string `json:"mime_type"`
|
MimeType string `json:"mime_type"`
|
||||||
DurationMs int `json:"duration_ms"`
|
DurationMs int `json:"duration_ms"`
|
||||||
// Caption *string `json:"caption"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileData represents the data stored for file particles
|
|
||||||
type FileData struct {
|
type FileData struct {
|
||||||
ObjectID string `json:"object_id"` // reference to storage object
|
ObjectID string `json:"object_id"`
|
||||||
Filename string `json:"filename"`
|
Filename string `json:"filename"`
|
||||||
MimeType string `json:"mime_type"`
|
MimeType string `json:"mime_type"`
|
||||||
Size int64 `json:"size"` // in bytes
|
Size int64 `json:"size"` // bytes
|
||||||
}
|
}
|
||||||
|
|
||||||
// TextData represents the data stored for text particles
|
|
||||||
type TextData struct {
|
type TextData struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// QuestData represents the data stored for quest particles
|
|
||||||
type QuestData struct {
|
type QuestData struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Done bool `json:"done"`
|
Done bool `json:"done"`
|
||||||
Status *string `json:"status"`
|
Status *string `json:"status"`
|
||||||
AssignedTo *string `json:"assigned_to,omitempty"` // email
|
AssignedTo *string `json:"assigned_to,omitempty"` // email
|
||||||
DueDate *string `json:"due_date,omitempty"` // ISO date string
|
DueDate *string `json:"due_date,omitempty"` // ISO date
|
||||||
}
|
}
|
||||||
|
|
||||||
// PaperData represents the data stored for paper particles
|
|
||||||
type PaperData struct {
|
type PaperData struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Content string `json:"content"` // markdown
|
Content string `json:"content"` // markdown
|
||||||
}
|
}
|
||||||
|
|
||||||
// AckInfo represents an acknowledgment record
|
|
||||||
type AckInfo struct {
|
type AckInfo struct {
|
||||||
Email string
|
Email string
|
||||||
AckedAt time.Time
|
AckedAt time.Time
|
||||||
|
|||||||
@@ -5,6 +5,5 @@ import "context"
|
|||||||
//go:generate go tool mockgen -source ./networkmembershipchecker.go -destination ./mocks/networkmembershipchecker.go
|
//go:generate go tool mockgen -source ./networkmembershipchecker.go -destination ./mocks/networkmembershipchecker.go
|
||||||
|
|
||||||
type NetworkMembershipChecker interface {
|
type NetworkMembershipChecker interface {
|
||||||
// IsMember returns true if the humanId is a member of the network.
|
|
||||||
IsMember(ctx context.Context, networkID, humanId string) (bool, error)
|
IsMember(ctx context.Context, networkID, humanId string) (bool, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ type repository interface {
|
|||||||
getMembers(ctx context.Context, particleID string) ([]string, error)
|
getMembers(ctx context.Context, particleID string) ([]string, error)
|
||||||
getMembersMap(ctx context.Context, particleIDs []string) (map[string][]string, error)
|
getMembersMap(ctx context.Context, particleIDs []string) (map[string][]string, error)
|
||||||
|
|
||||||
// getAncestorChain returns the particle and all its ancestors (for access checks)
|
// getAncestorChain returns the particle followed by its ancestors, in order.
|
||||||
getAncestorChain(ctx context.Context, particleID string) ([]*Particle, error)
|
getAncestorChain(ctx context.Context, particleID string) ([]*Particle, error)
|
||||||
isMemberOf(ctx context.Context, particleID, email string) (bool, error)
|
isMemberOf(ctx context.Context, particleID, email string) (bool, error)
|
||||||
|
|
||||||
|
|||||||
@@ -13,45 +13,46 @@ import (
|
|||||||
|
|
||||||
const defaultPageSize = 50
|
const defaultPageSize = 50
|
||||||
|
|
||||||
// NOTE: this service is deprecated as we use firestore for particle data
|
// Deprecated: particle data now lives in Firestore. The Postgres-backed
|
||||||
|
// service is retained only for legacy paths.
|
||||||
type Service interface {
|
type Service interface {
|
||||||
// Create creates a new particle. Caller must be a network member (verified by handler).
|
// Create returns ErrInvalidType, ErrInvalidData, ErrMembersRequired,
|
||||||
// Returns ErrInvalidType, ErrInvalidData, ErrMembersRequired, ErrInvalidParent, ErrAccessDenied, or ErrCapacityExceeded.
|
// ErrInvalidParent, ErrAccessDenied, or ErrCapacityExceeded. Network
|
||||||
|
// membership is verified by the handler.
|
||||||
Create(ctx context.Context, input CreateInput, requesterEmail string) (*Particle, error)
|
Create(ctx context.Context, input CreateInput, requesterEmail string) (*Particle, error)
|
||||||
// GetByID returns ErrNotFound or ErrAccessDenied.
|
// GetByID returns ErrNotFound or ErrAccessDenied.
|
||||||
GetByID(ctx context.Context, id, requesterEmail string) (*Particle, error)
|
GetByID(ctx context.Context, id, requesterEmail string) (*Particle, error)
|
||||||
// Update updates the particle's data. Returns ErrNotFound, ErrAccessDenied, or ErrInvalidData.
|
// Update returns ErrNotFound, ErrAccessDenied, or ErrInvalidData.
|
||||||
Update(ctx context.Context, id string, data json.RawMessage, requesterEmail string) (*Particle, error)
|
Update(ctx context.Context, id string, data json.RawMessage, requesterEmail string) (*Particle, error)
|
||||||
// Delete returns ErrNotFound or ErrAccessDenied.
|
// Delete returns ErrNotFound or ErrAccessDenied.
|
||||||
Delete(ctx context.Context, id, requesterEmail string) error
|
Delete(ctx context.Context, id, requesterEmail string) error
|
||||||
|
|
||||||
// List returns particles in a network. Use parentID=nil for root particles.
|
// List uses parentID=nil for root particles. Returns ErrNotFound or
|
||||||
// Returns ErrNotFound or ErrAccessDenied if parentID is specified and inaccessible.
|
// ErrAccessDenied when parentID is given but inaccessible.
|
||||||
List(ctx context.Context, networkID string, parentID *string, requesterEmail string, filter ListFilter, cursor *Cursor, limit int) (*ParticleList, error)
|
List(ctx context.Context, networkID string, parentID *string, requesterEmail string, filter ListFilter, cursor *Cursor, limit int) (*ParticleList, error)
|
||||||
|
|
||||||
// OpenStream opens a closed stream. Returns ErrNotFound, ErrAccessDenied, ErrNotAStream, ErrStreamAlreadyOpen, or ErrCapacityExceeded.
|
// OpenStream returns ErrNotFound, ErrAccessDenied, ErrNotAStream,
|
||||||
|
// ErrStreamAlreadyOpen, or ErrCapacityExceeded.
|
||||||
OpenStream(ctx context.Context, id, requesterEmail string) error
|
OpenStream(ctx context.Context, id, requesterEmail string) error
|
||||||
// CloseStream closes an open stream. Returns ErrNotFound, ErrAccessDenied, ErrNotAStream, or ErrStreamAlreadyClosed.
|
// CloseStream returns ErrNotFound, ErrAccessDenied, ErrNotAStream, or
|
||||||
|
// ErrStreamAlreadyClosed.
|
||||||
CloseStream(ctx context.Context, id, requesterEmail string) error
|
CloseStream(ctx context.Context, id, requesterEmail string) error
|
||||||
|
|
||||||
// SetVisibility changes the particle's visibility mode. Returns ErrNotFound, ErrAccessDenied, or ErrAccessExpansion.
|
// SetVisibility returns ErrNotFound, ErrAccessDenied, or ErrAccessExpansion.
|
||||||
SetVisibility(ctx context.Context, id string, mode VisibilityMode, requesterEmail string) error
|
SetVisibility(ctx context.Context, id string, mode VisibilityMode, requesterEmail string) error
|
||||||
// AddMembers adds members to a custom visibility particle. Returns ErrNotFound or ErrAccessDenied.
|
// AddMembers / RemoveMembers operate on custom-visibility streams only.
|
||||||
|
// Both return ErrNotFound or ErrAccessDenied.
|
||||||
AddMembers(ctx context.Context, id string, emails []string, requesterEmail string) error
|
AddMembers(ctx context.Context, id string, emails []string, requesterEmail string) error
|
||||||
// RemoveMembers removes members from a custom visibility particle. Returns ErrNotFound or ErrAccessDenied.
|
|
||||||
RemoveMembers(ctx context.Context, id string, emails []string, requesterEmail string) error
|
RemoveMembers(ctx context.Context, id string, emails []string, requesterEmail string) error
|
||||||
|
|
||||||
// Seen tracking (private)
|
// Seen tracking is private per human; Ack is public and permanent.
|
||||||
MarkSeen(ctx context.Context, id, requesterEmail string) error
|
MarkSeen(ctx context.Context, id, requesterEmail string) error
|
||||||
MarkSeenBatch(ctx context.Context, ids []string, requesterEmail string) error
|
MarkSeenBatch(ctx context.Context, ids []string, requesterEmail string) error
|
||||||
|
|
||||||
// Ack tracking (public, permanent)
|
|
||||||
Ack(ctx context.Context, id, requesterEmail string) error
|
Ack(ctx context.Context, id, requesterEmail string) error
|
||||||
|
|
||||||
// Unseen counts for stream list view
|
|
||||||
GetUnseenCounts(ctx context.Context, networkID string, streamIDs []string, requesterEmail string) (map[string]int, error)
|
GetUnseenCounts(ctx context.Context, networkID string, streamIDs []string, requesterEmail string) (map[string]int, error)
|
||||||
|
|
||||||
// Bulk lookups for handler enrichment
|
// Bulk lookups for batch hydration.
|
||||||
GetSeenMap(ctx context.Context, particleIDs []string, requesterEmail string) (map[string]bool, error)
|
GetSeenMap(ctx context.Context, particleIDs []string, requesterEmail string) (map[string]bool, error)
|
||||||
GetAcksMap(ctx context.Context, particleIDs []string) (map[string][]AckInfo, error)
|
GetAcksMap(ctx context.Context, particleIDs []string) (map[string][]AckInfo, error)
|
||||||
GetMembersMap(ctx context.Context, particleIDs []string) (map[string][]string, error)
|
GetMembersMap(ctx context.Context, particleIDs []string) (map[string][]string, error)
|
||||||
@@ -69,10 +70,9 @@ func NewService(pool *pgxpool.Pool, networkReader NetworkMembershipChecker) Serv
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkAccess verifies that the email has access to the particle based on visibility.
|
// Walks the ancestor chain when visibility is inherited, stopping at the
|
||||||
// Assumes the caller is already verified as a network member (handler responsibility).
|
// first network_all or custom node. Assumes network membership is already
|
||||||
// Walks up the ancestor chain only when visibility is inherited, stopping at the first
|
// verified by the handler.
|
||||||
// network_all or custom node.
|
|
||||||
func (s *serviceImpl) checkAccess(ctx context.Context, particleID, email string) (bool, error) {
|
func (s *serviceImpl) checkAccess(ctx context.Context, particleID, email string) (bool, error) {
|
||||||
ancestors, err := s.repo.getAncestorChain(ctx, particleID)
|
ancestors, err := s.repo.getAncestorChain(ctx, particleID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -83,13 +83,12 @@ func (s *serviceImpl) checkAccess(ctx context.Context, particleID, email string)
|
|||||||
return false, errNotFound
|
return false, errNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build lookup map by ID
|
|
||||||
byID := make(map[string]*Particle, len(ancestors))
|
byID := make(map[string]*Particle, len(ancestors))
|
||||||
for _, p := range ancestors {
|
for _, p := range ancestors {
|
||||||
byID[p.ID] = p
|
byID[p.ID] = p
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start from the target particle (first in chain) and walk up on inherited
|
// ancestors[0] is the target; walk up only on inherited.
|
||||||
current := ancestors[0]
|
current := ancestors[0]
|
||||||
for {
|
for {
|
||||||
switch current.Visibility {
|
switch current.Visibility {
|
||||||
@@ -99,7 +98,7 @@ func (s *serviceImpl) checkAccess(ctx context.Context, particleID, email string)
|
|||||||
return s.repo.isMemberOf(ctx, current.ID, email)
|
return s.repo.isMemberOf(ctx, current.ID, email)
|
||||||
case VisibilityInherited:
|
case VisibilityInherited:
|
||||||
if current.ParentID == nil {
|
if current.ParentID == nil {
|
||||||
// inherited at root is invalid state, deny access
|
// inherited-at-root is invalid; deny.
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
parent, ok := byID[*current.ParentID]
|
parent, ok := byID[*current.ParentID]
|
||||||
@@ -119,30 +118,24 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate particle type
|
|
||||||
if !isValidParticleType(input.Type) {
|
if !isValidParticleType(input.Type) {
|
||||||
return nil, ErrInvalidType
|
return nil, ErrInvalidType
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate data matches type requirements
|
|
||||||
if err := validateParticleData(input.Type, input.Data); err != nil {
|
if err := validateParticleData(input.Type, input.Data); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// MVP visibility rules:
|
// MVP visibility: children always inherit; roots cannot inherit and
|
||||||
// - Child particles (have parent) → always inherited
|
// default to network_all. Streams/folders are root-only.
|
||||||
// - Root particles (no parent) → cannot be inherited, default network_all
|
|
||||||
if input.ParentID != nil {
|
if input.ParentID != nil {
|
||||||
// Children always inherit from parent
|
|
||||||
input.Visibility = VisibilityInherited
|
input.Visibility = VisibilityInherited
|
||||||
input.Members = nil // no members on inherited particles
|
input.Members = nil
|
||||||
|
|
||||||
// Reject streams and folders as children (MVP: streams are root-level only)
|
|
||||||
if input.Type == TypeStream || input.Type == TypeFolder {
|
if input.Type == TypeStream || input.Type == TypeFolder {
|
||||||
return nil, ErrInvalidParent
|
return nil, ErrInvalidParent
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Root particles cannot be inherited
|
|
||||||
if input.Visibility == VisibilityInherited {
|
if input.Visibility == VisibilityInherited {
|
||||||
return nil, ErrInheritedAtRoot
|
return nil, ErrInheritedAtRoot
|
||||||
}
|
}
|
||||||
@@ -151,7 +144,6 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom visibility requires at least one member and must be a stream
|
|
||||||
var customMembers []string
|
var customMembers []string
|
||||||
if input.Visibility == VisibilityCustom {
|
if input.Visibility == VisibilityCustom {
|
||||||
if input.Type != TypeStream {
|
if input.Type != TypeStream {
|
||||||
@@ -161,7 +153,7 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
|
|||||||
return nil, ErrMembersRequired
|
return nil, ErrMembersRequired
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate every supplied member against the network checker before touching the DB.
|
// Validate every member upfront so DB writes are all-or-nothing.
|
||||||
customMembers = make([]string, 0, len(input.Members)+1)
|
customMembers = make([]string, 0, len(input.Members)+1)
|
||||||
customMembers = append(customMembers, requesterEmail)
|
customMembers = append(customMembers, requesterEmail)
|
||||||
seen := map[string]bool{requesterEmail: true}
|
seen := map[string]bool{requesterEmail: true}
|
||||||
@@ -186,8 +178,7 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Network membership is verified by handler - we only check particle visibility
|
// Network membership is verified by the handler; only particle visibility is checked here.
|
||||||
// If parent specified, check parent access (visibility-based)
|
|
||||||
if input.ParentID != nil {
|
if input.ParentID != nil {
|
||||||
hasAccess, err := s.checkAccess(ctx, *input.ParentID, requesterEmail)
|
hasAccess, err := s.checkAccess(ctx, *input.ParentID, requesterEmail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -201,7 +192,6 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the particle
|
|
||||||
p := &Particle{
|
p := &Particle{
|
||||||
Type: input.Type,
|
Type: input.Type,
|
||||||
NetworkID: input.NetworkID,
|
NetworkID: input.NetworkID,
|
||||||
@@ -215,9 +205,8 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
|
|||||||
p.Data = json.RawMessage("{}")
|
p.Data = json.RawMessage("{}")
|
||||||
}
|
}
|
||||||
|
|
||||||
// For streams, set initial status to open and check capacity
|
// New streams default to open.
|
||||||
if input.Type == TypeStream {
|
if input.Type == TypeStream {
|
||||||
// Set status to open in the data JSON
|
|
||||||
data, err := setStreamStatus(p.Data, string(StreamStatusOpen))
|
data, err := setStreamStatus(p.Data, string(StreamStatusOpen))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -225,13 +214,11 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
|
|||||||
p.Data = data
|
p.Data = data
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the particle
|
|
||||||
created, err := s.repo.create(ctx, p)
|
created, err := s.repo.create(ctx, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the pre-validated member list for custom visibility.
|
|
||||||
if len(customMembers) > 0 {
|
if len(customMembers) > 0 {
|
||||||
if err := s.repo.addMembers(ctx, created.ID, customMembers); err != nil {
|
if err := s.repo.addMembers(ctx, created.ID, customMembers); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -247,7 +234,6 @@ func (s *serviceImpl) GetByID(ctx context.Context, id, requesterEmail string) (*
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check access
|
|
||||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
@@ -275,7 +261,6 @@ func (s *serviceImpl) Update(ctx context.Context, id string, data json.RawMessag
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check access
|
|
||||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
@@ -287,7 +272,6 @@ func (s *serviceImpl) Update(ctx context.Context, id string, data json.RawMessag
|
|||||||
return nil, ErrAccessDenied
|
return nil, ErrAccessDenied
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the particle to validate data against its type
|
|
||||||
p, err := s.repo.getByID(ctx, id)
|
p, err := s.repo.getByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
@@ -296,7 +280,6 @@ func (s *serviceImpl) Update(ctx context.Context, id string, data json.RawMessag
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate data matches type requirements
|
|
||||||
if err := validateParticleData(p.Type, data); err != nil {
|
if err := validateParticleData(p.Type, data); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -318,7 +301,6 @@ func (s *serviceImpl) Delete(ctx context.Context, id, requesterEmail string) err
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check access
|
|
||||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
@@ -330,7 +312,6 @@ func (s *serviceImpl) Delete(ctx context.Context, id, requesterEmail string) err
|
|||||||
return ErrAccessDenied
|
return ErrAccessDenied
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the particle to check if it's an open stream
|
|
||||||
_, err = s.repo.getByID(ctx, id)
|
_, err = s.repo.getByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
@@ -352,8 +333,7 @@ func (s *serviceImpl) List(ctx context.Context, networkID string, parentID *stri
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Network membership is verified by handler - we only check particle visibility
|
// Network membership is verified by the handler; only particle visibility is checked here.
|
||||||
// If parentID specified, check access to parent (visibility-based)
|
|
||||||
if parentID != nil {
|
if parentID != nil {
|
||||||
hasAccess, err := s.checkAccess(ctx, *parentID, requesterEmail)
|
hasAccess, err := s.checkAccess(ctx, *parentID, requesterEmail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -367,8 +347,7 @@ func (s *serviceImpl) List(ctx context.Context, networkID string, parentID *stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch one extra to determine if there are more
|
// Fetch limit+1 to detect a next page; visibility filtering lives in the query.
|
||||||
// Access filtering is done in the query itself (network_all OR user is member)
|
|
||||||
if limit == 0 {
|
if limit == 0 {
|
||||||
limit = defaultPageSize
|
limit = defaultPageSize
|
||||||
}
|
}
|
||||||
@@ -415,7 +394,6 @@ func (s *serviceImpl) OpenStream(ctx context.Context, id, requesterEmail string)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check access
|
|
||||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
@@ -427,7 +405,6 @@ func (s *serviceImpl) OpenStream(ctx context.Context, id, requesterEmail string)
|
|||||||
return ErrAccessDenied
|
return ErrAccessDenied
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the particle
|
|
||||||
p, err := s.repo.getByID(ctx, id)
|
p, err := s.repo.getByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
@@ -444,7 +421,6 @@ func (s *serviceImpl) OpenStream(ctx context.Context, id, requesterEmail string)
|
|||||||
return ErrStreamAlreadyOpen
|
return ErrStreamAlreadyOpen
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update stream status in data
|
|
||||||
newData, err := setStreamStatus(p.Data, string(StreamStatusOpen))
|
newData, err := setStreamStatus(p.Data, string(StreamStatusOpen))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -467,7 +443,6 @@ func (s *serviceImpl) CloseStream(ctx context.Context, id, requesterEmail string
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check access
|
|
||||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
@@ -479,7 +454,6 @@ func (s *serviceImpl) CloseStream(ctx context.Context, id, requesterEmail string
|
|||||||
return ErrAccessDenied
|
return ErrAccessDenied
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the particle
|
|
||||||
p, err := s.repo.getByID(ctx, id)
|
p, err := s.repo.getByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
@@ -496,7 +470,6 @@ func (s *serviceImpl) CloseStream(ctx context.Context, id, requesterEmail string
|
|||||||
return ErrStreamAlreadyClosed
|
return ErrStreamAlreadyClosed
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update stream status in data
|
|
||||||
newData, err := setStreamStatus(p.Data, string(StreamStatusClosed))
|
newData, err := setStreamStatus(p.Data, string(StreamStatusClosed))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -519,7 +492,6 @@ func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode Visibil
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check access
|
|
||||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
@@ -531,7 +503,6 @@ func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode Visibil
|
|||||||
return ErrAccessDenied
|
return ErrAccessDenied
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the particle to check constraints
|
|
||||||
p, err := s.repo.getByID(ctx, id)
|
p, err := s.repo.getByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
@@ -540,12 +511,11 @@ func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode Visibil
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Root particles cannot be inherited
|
|
||||||
if mode == VisibilityInherited && p.ParentID == nil {
|
if mode == VisibilityInherited && p.ParentID == nil {
|
||||||
return ErrInheritedAtRoot
|
return ErrInheritedAtRoot
|
||||||
}
|
}
|
||||||
|
|
||||||
// If expanding to network_all, check that parent's effective visibility allows it
|
// Expanding to network_all is rejected if any ancestor restricts to custom.
|
||||||
if mode == VisibilityNetworkAll && p.ParentID != nil {
|
if mode == VisibilityNetworkAll && p.ParentID != nil {
|
||||||
parentVis, err := s.getEffectiveVisibility(ctx, *p.ParentID)
|
parentVis, err := s.getEffectiveVisibility(ctx, *p.ParentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -563,7 +533,7 @@ func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode Visibil
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// getEffectiveVisibility walks up the inherited chain to find the concrete visibility mode.
|
// Walks up the inherited chain to the concrete visibility node.
|
||||||
func (s *serviceImpl) getEffectiveVisibility(ctx context.Context, particleID string) (VisibilityMode, error) {
|
func (s *serviceImpl) getEffectiveVisibility(ctx context.Context, particleID string) (VisibilityMode, error) {
|
||||||
ancestors, err := s.repo.getAncestorChain(ctx, particleID)
|
ancestors, err := s.repo.getAncestorChain(ctx, particleID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -600,7 +570,6 @@ func (s *serviceImpl) AddMembers(ctx context.Context, id string, emails []string
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check access
|
|
||||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
@@ -612,7 +581,6 @@ func (s *serviceImpl) AddMembers(ctx context.Context, id string, emails []string
|
|||||||
return ErrAccessDenied
|
return ErrAccessDenied
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the particle to check type and parent access
|
|
||||||
p, err := s.repo.getByID(ctx, id)
|
p, err := s.repo.getByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
@@ -621,14 +589,11 @@ func (s *serviceImpl) AddMembers(ctx context.Context, id string, emails []string
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only streams can have members
|
|
||||||
if p.Type != TypeStream {
|
if p.Type != TypeStream {
|
||||||
return ErrNotAContainer
|
return ErrNotAContainer
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate and normalize emails, checking network membership upfront.
|
// Validate every email upfront so any failure aborts before DB writes.
|
||||||
// Strict: a normalize failure, checker error, or non-member aborts the
|
|
||||||
// whole operation before any rows are written.
|
|
||||||
normalizedEmails := make([]string, 0, len(emails))
|
normalizedEmails := make([]string, 0, len(emails))
|
||||||
seen := make(map[string]bool, len(emails))
|
seen := make(map[string]bool, len(emails))
|
||||||
for _, email := range emails {
|
for _, email := range emails {
|
||||||
@@ -664,7 +629,6 @@ func (s *serviceImpl) RemoveMembers(ctx context.Context, id string, emails []str
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check access
|
|
||||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
@@ -676,7 +640,6 @@ func (s *serviceImpl) RemoveMembers(ctx context.Context, id string, emails []str
|
|||||||
return ErrAccessDenied
|
return ErrAccessDenied
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the particle to check type
|
|
||||||
p, err := s.repo.getByID(ctx, id)
|
p, err := s.repo.getByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
@@ -685,7 +648,6 @@ func (s *serviceImpl) RemoveMembers(ctx context.Context, id string, emails []str
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only streams can have members
|
|
||||||
if p.Type != TypeStream {
|
if p.Type != TypeStream {
|
||||||
return ErrNotAContainer
|
return ErrNotAContainer
|
||||||
}
|
}
|
||||||
@@ -712,7 +674,6 @@ func (s *serviceImpl) MarkSeen(ctx context.Context, id, requesterEmail string) e
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check access
|
|
||||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
@@ -733,17 +694,17 @@ func (s *serviceImpl) MarkSeenBatch(ctx context.Context, ids []string, requester
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check access for each particle and mark seen
|
// Silently skip particles that are missing or inaccessible.
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
continue // Skip non-existent particles
|
continue
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !hasAccess {
|
if !hasAccess {
|
||||||
continue // Skip inaccessible particles
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.repo.markSeen(ctx, id, requesterEmail); err != nil {
|
if err := s.repo.markSeen(ctx, id, requesterEmail); err != nil {
|
||||||
@@ -760,7 +721,6 @@ func (s *serviceImpl) Ack(ctx context.Context, id, requesterEmail string) error
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check access
|
|
||||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
@@ -772,7 +732,7 @@ func (s *serviceImpl) Ack(ctx context.Context, id, requesterEmail string) error
|
|||||||
return ErrAccessDenied
|
return ErrAccessDenied
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ack also marks as seen
|
// Ack implies seen.
|
||||||
if err := s.repo.markSeen(ctx, id, requesterEmail); err != nil {
|
if err := s.repo.markSeen(ctx, id, requesterEmail); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -815,7 +775,6 @@ func isValidParticleType(t ParticleType) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// getStreamStatus extracts the status from a stream particle's data
|
|
||||||
func getStreamStatus(data json.RawMessage) string {
|
func getStreamStatus(data json.RawMessage) string {
|
||||||
var d StreamData
|
var d StreamData
|
||||||
if err := json.Unmarshal(data, &d); err != nil {
|
if err := json.Unmarshal(data, &d); err != nil {
|
||||||
@@ -824,7 +783,6 @@ func getStreamStatus(data json.RawMessage) string {
|
|||||||
return d.Status
|
return d.Status
|
||||||
}
|
}
|
||||||
|
|
||||||
// setStreamStatus updates the status in a stream particle's data
|
|
||||||
func setStreamStatus(data json.RawMessage, status string) (json.RawMessage, error) {
|
func setStreamStatus(data json.RawMessage, status string) (json.RawMessage, error) {
|
||||||
var d StreamData
|
var d StreamData
|
||||||
if err := json.Unmarshal(data, &d); err != nil {
|
if err := json.Unmarshal(data, &d); err != nil {
|
||||||
@@ -834,10 +792,9 @@ func setStreamStatus(data json.RawMessage, status string) (json.RawMessage, erro
|
|||||||
return json.Marshal(d)
|
return json.Marshal(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateParticleData validates that the data field contains valid JSON
|
// Returns ErrInvalidData if data is not valid JSON or is missing required
|
||||||
// and has required fields for the given particle type.
|
// fields for pType. Empty/null data is allowed and treated as {}.
|
||||||
func validateParticleData(pType ParticleType, data json.RawMessage) error {
|
func validateParticleData(pType ParticleType, data json.RawMessage) error {
|
||||||
// Empty or null data is allowed - will default to {}
|
|
||||||
if len(data) == 0 || string(data) == "null" || string(data) == "{}" {
|
if len(data) == 0 || string(data) == "null" || string(data) == "{}" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,20 +10,19 @@ import (
|
|||||||
|
|
||||||
var ErrUnauthorized = errors.New("unauthorized")
|
var ErrUnauthorized = errors.New("unauthorized")
|
||||||
|
|
||||||
// Authorizer validates whether a user can access a given channel.
|
|
||||||
type Authorizer struct {
|
type Authorizer struct {
|
||||||
networkReader network.Reader
|
networkReader network.Reader
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthorizer creates a new channel authorizer.
|
|
||||||
func NewAuthorizer(networkReader network.Reader) *Authorizer {
|
func NewAuthorizer(networkReader network.Reader) *Authorizer {
|
||||||
return &Authorizer{networkReader: networkReader}
|
return &Authorizer{networkReader: networkReader}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authorize checks if the given humanID is allowed to subscribe to the channel.
|
// Authorize accepts channel IDs of the form:
|
||||||
// Channel formats:
|
//
|
||||||
// - network:{networkId}
|
// network:{networkId}
|
||||||
// - stream:{networkId}:{streamId}
|
// stream:{networkId}:{streamId}
|
||||||
|
// _presence:{humanId}
|
||||||
func (a *Authorizer) Authorize(ctx context.Context, channelID, humanID string) error {
|
func (a *Authorizer) Authorize(ctx context.Context, channelID, humanID string) error {
|
||||||
parts := strings.SplitN(channelID, ":", 2)
|
parts := strings.SplitN(channelID, ":", 2)
|
||||||
if len(parts) < 2 {
|
if len(parts) < 2 {
|
||||||
@@ -39,8 +38,7 @@ func (a *Authorizer) Authorize(ctx context.Context, channelID, humanID string) e
|
|||||||
case "stream":
|
case "stream":
|
||||||
return a.authorizeStream(ctx, rest, humanID)
|
return a.authorizeStream(ctx, rest, humanID)
|
||||||
case "_presence":
|
case "_presence":
|
||||||
// Always allowed — used for global online presence tracking.
|
// Only the owning human may subscribe to their presence channel.
|
||||||
// The channel ID is _presence:{humanId}, so verify the humanId matches.
|
|
||||||
if rest != humanID {
|
if rest != humanID {
|
||||||
return ErrUnauthorized
|
return ErrUnauthorized
|
||||||
}
|
}
|
||||||
@@ -61,8 +59,7 @@ func (a *Authorizer) authorizeNetwork(ctx context.Context, networkID, humanID st
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// authorizeStream expects rest to be "{networkId}:{streamId}".
|
// rest is "{networkId}:{streamId}"; stream-level visibility is enforced by network access.
|
||||||
// We only check network membership — stream visibility is handled by network access.
|
|
||||||
func (a *Authorizer) authorizeStream(ctx context.Context, rest, humanID string) error {
|
func (a *Authorizer) authorizeStream(ctx context.Context, rest, humanID string) error {
|
||||||
parts := strings.SplitN(rest, ":", 2)
|
parts := strings.SplitN(rest, ":", 2)
|
||||||
if len(parts) < 2 {
|
if len(parts) < 2 {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package pusher
|
package pusher
|
||||||
|
|
||||||
// Channel tracks the local connections subscribed to a channel on this pod.
|
// Channel tracks the local connections subscribed on this pod.
|
||||||
// All methods are only called from the Hub goroutine — no locks needed.
|
// State is only mutated by the Hub goroutine, so no locks are needed.
|
||||||
type Channel struct {
|
type Channel struct {
|
||||||
id string
|
id string
|
||||||
members map[*Conn]string // conn → humanID
|
members map[*Conn]string // conn → humanID
|
||||||
@@ -26,7 +26,7 @@ func (ch *Channel) isEmpty() bool {
|
|||||||
return len(ch.members) == 0
|
return len(ch.members) == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// localHumanIDs returns the deduplicated set of humanIDs connected on this pod.
|
// Deduplicated set; the same human may have multiple connections.
|
||||||
func (ch *Channel) localHumanIDs() []string {
|
func (ch *Channel) localHumanIDs() []string {
|
||||||
seen := make(map[string]bool, len(ch.members))
|
seen := make(map[string]bool, len(ch.members))
|
||||||
ids := make([]string, 0, len(ch.members))
|
ids := make([]string, 0, len(ch.members))
|
||||||
@@ -39,7 +39,6 @@ func (ch *Channel) localHumanIDs() []string {
|
|||||||
return ids
|
return ids
|
||||||
}
|
}
|
||||||
|
|
||||||
// hasHumanID returns true if the given humanID has at least one local connection.
|
|
||||||
func (ch *Channel) hasHumanID(humanID string) bool {
|
func (ch *Channel) hasHumanID(humanID string) bool {
|
||||||
for _, hid := range ch.members {
|
for _, hid := range ch.members {
|
||||||
if hid == humanID {
|
if hid == humanID {
|
||||||
@@ -49,7 +48,6 @@ func (ch *Channel) hasHumanID(humanID string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// broadcast sends a message to all local connections except the excluded one.
|
|
||||||
func (ch *Channel) broadcast(msg ServerMessage, exclude *Conn) {
|
func (ch *Channel) broadcast(msg ServerMessage, exclude *Conn) {
|
||||||
for conn := range ch.members {
|
for conn := range ch.members {
|
||||||
if conn != exclude {
|
if conn != exclude {
|
||||||
|
|||||||
@@ -11,13 +11,12 @@ import (
|
|||||||
|
|
||||||
const sendBufferSize = 256
|
const sendBufferSize = 256
|
||||||
|
|
||||||
// Conn wraps a WebSocket connection with identity and a send buffer.
|
|
||||||
type Conn struct {
|
type Conn struct {
|
||||||
id string
|
id string
|
||||||
humanID string
|
humanID string
|
||||||
ws *websocket.Conn
|
ws *websocket.Conn
|
||||||
send chan []byte
|
send chan []byte
|
||||||
once sync.Once // ensures close logic runs once
|
once sync.Once // guards Close
|
||||||
}
|
}
|
||||||
|
|
||||||
func newConn(id, humanID string, ws *websocket.Conn) *Conn {
|
func newConn(id, humanID string, ws *websocket.Conn) *Conn {
|
||||||
@@ -29,8 +28,8 @@ func newConn(id, humanID string, ws *websocket.Conn) *Conn {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadPump reads messages from the WebSocket and forwards them to the hub.
|
// ReadPump forwards inbound frames to the hub; blocks until the connection
|
||||||
// It blocks until the connection is closed or the context is cancelled.
|
// closes or ctx is cancelled.
|
||||||
func (c *Conn) ReadPump(ctx context.Context, hub *Hub) {
|
func (c *Conn) ReadPump(ctx context.Context, hub *Hub) {
|
||||||
defer hub.disconnect(c)
|
defer hub.disconnect(c)
|
||||||
|
|
||||||
@@ -45,7 +44,6 @@ func (c *Conn) ReadPump(ctx context.Context, hub *Hub) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Respond to keep-alive pings
|
|
||||||
if string(data) == "ping" {
|
if string(data) == "ping" {
|
||||||
if err := c.ws.Write(ctx, websocket.MessageText, []byte("pong")); err != nil {
|
if err := c.ws.Write(ctx, websocket.MessageText, []byte("pong")); err != nil {
|
||||||
slog.Warn("websocket pong write error", "connId", c.id, "error", err)
|
slog.Warn("websocket pong write error", "connId", c.id, "error", err)
|
||||||
@@ -84,7 +82,6 @@ func (c *Conn) ReadPump(ctx context.Context, hub *Hub) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WritePump drains the send buffer and writes messages to the WebSocket.
|
|
||||||
func (c *Conn) WritePump(ctx context.Context) {
|
func (c *Conn) WritePump(ctx context.Context) {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -102,8 +99,7 @@ func (c *Conn) WritePump(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send enqueues a ServerMessage to be written to the WebSocket.
|
// A full send buffer closes the connection (slow client policy).
|
||||||
// If the send buffer is full, the connection is closed (slow client).
|
|
||||||
func (c *Conn) Send(msg ServerMessage) {
|
func (c *Conn) Send(msg ServerMessage) {
|
||||||
data, err := json.Marshal(msg)
|
data, err := json.Marshal(msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -119,7 +115,6 @@ func (c *Conn) Send(msg ServerMessage) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close closes the WebSocket connection and the send channel.
|
|
||||||
func (c *Conn) Close() {
|
func (c *Conn) Close() {
|
||||||
c.once.Do(func() {
|
c.once.Do(func() {
|
||||||
c.ws.Close(websocket.StatusNormalClosure, "closing")
|
c.ws.Close(websocket.StatusNormalClosure, "closing")
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ type Hub struct {
|
|||||||
remoteEventCh chan *remoteEvent
|
remoteEventCh chan *remoteEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHub creates a new Hub.
|
|
||||||
func NewHub(bridge *RedisBridge, authorizer *Authorizer) *Hub {
|
func NewHub(bridge *RedisBridge, authorizer *Authorizer) *Hub {
|
||||||
return &Hub{
|
return &Hub{
|
||||||
channels: make(map[string]*Channel),
|
channels: make(map[string]*Channel),
|
||||||
@@ -84,7 +83,6 @@ func (h *Hub) Run(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) handleSubscribe(ctx context.Context, req *subscribeRequest) {
|
func (h *Hub) handleSubscribe(ctx context.Context, req *subscribeRequest) {
|
||||||
// Authorize channel access
|
|
||||||
if err := h.authorizer.Authorize(ctx, req.channelID, req.conn.humanID); err != nil {
|
if err := h.authorizer.Authorize(ctx, req.channelID, req.conn.humanID); err != nil {
|
||||||
req.conn.Send(ServerMessage{
|
req.conn.Send(ServerMessage{
|
||||||
Type: TypeError,
|
Type: TypeError,
|
||||||
@@ -94,7 +92,6 @@ func (h *Hub) handleSubscribe(ctx context.Context, req *subscribeRequest) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get or create local channel
|
|
||||||
ch, ok := h.channels[req.channelID]
|
ch, ok := h.channels[req.channelID]
|
||||||
if !ok {
|
if !ok {
|
||||||
ch = newChannel(req.channelID)
|
ch = newChannel(req.channelID)
|
||||||
@@ -104,32 +101,27 @@ func (h *Hub) handleSubscribe(ctx context.Context, req *subscribeRequest) {
|
|||||||
// Capture before addMember so multi-tab joins don't emit a spurious join.
|
// Capture before addMember so multi-tab joins don't emit a spurious join.
|
||||||
wasPresentLocally := ch.hasHumanID(req.conn.humanID)
|
wasPresentLocally := ch.hasHumanID(req.conn.humanID)
|
||||||
|
|
||||||
// Add to local channel
|
|
||||||
ch.addMember(req.conn, req.conn.humanID)
|
ch.addMember(req.conn, req.conn.humanID)
|
||||||
|
|
||||||
// Track in reverse index
|
|
||||||
if h.connChannels[req.conn] == nil {
|
if h.connChannels[req.conn] == nil {
|
||||||
h.connChannels[req.conn] = make(map[string]bool)
|
h.connChannels[req.conn] = make(map[string]bool)
|
||||||
}
|
}
|
||||||
h.connChannels[req.conn][req.channelID] = true
|
h.connChannels[req.conn][req.channelID] = true
|
||||||
|
|
||||||
// Register in Redis and get global presence
|
|
||||||
presence, err := h.bridge.Subscribe(ctx, req.channelID, req.conn.id, req.conn.humanID)
|
presence, err := h.bridge.Subscribe(ctx, req.channelID, req.conn.id, req.conn.humanID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("redis subscribe failed", "channelId", req.channelID, "error", err)
|
slog.Error("redis subscribe failed", "channelId", req.channelID, "error", err)
|
||||||
// Still send local presence as fallback
|
// Fall back to local-only presence.
|
||||||
presence = ch.localHumanIDs()
|
presence = ch.localHumanIDs()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send subscribed ack with presence snapshot
|
|
||||||
req.conn.Send(ServerMessage{
|
req.conn.Send(ServerMessage{
|
||||||
Type: TypeSubscribed,
|
Type: TypeSubscribed,
|
||||||
Channel: req.channelID,
|
Channel: req.channelID,
|
||||||
Presence: presence,
|
Presence: presence,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Notify other local members. The Redis self-filter drops our own echo,
|
// Redis self-filter drops our own echo, so same-pod peers need a direct nudge.
|
||||||
// so same-pod peers would otherwise never hear about this join.
|
|
||||||
if !wasPresentLocally {
|
if !wasPresentLocally {
|
||||||
ch.broadcast(ServerMessage{
|
ch.broadcast(ServerMessage{
|
||||||
Type: TypeJoin,
|
Type: TypeJoin,
|
||||||
@@ -147,18 +139,15 @@ func (h *Hub) handleUnsubscribe(ctx context.Context, req *unsubscribeRequest) {
|
|||||||
|
|
||||||
ch.removeMember(req.conn)
|
ch.removeMember(req.conn)
|
||||||
|
|
||||||
// Remove from reverse index
|
|
||||||
if chans, ok := h.connChannels[req.conn]; ok {
|
if chans, ok := h.connChannels[req.conn]; ok {
|
||||||
delete(chans, req.channelID)
|
delete(chans, req.channelID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update Redis
|
|
||||||
if err := h.bridge.Unsubscribe(ctx, req.channelID, req.conn.id, req.conn.humanID); err != nil {
|
if err := h.bridge.Unsubscribe(ctx, req.channelID, req.conn.id, req.conn.humanID); err != nil {
|
||||||
slog.Error("redis unsubscribe failed", "channelId", req.channelID, "error", err)
|
slog.Error("redis unsubscribe failed", "channelId", req.channelID, "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify other local members iff the humanID is fully gone from this pod
|
// Only emit leave once the humanID has no remaining tabs on this pod.
|
||||||
// (multi-tab: other conns keep them present, so no leave fires).
|
|
||||||
if !ch.hasHumanID(req.conn.humanID) {
|
if !ch.hasHumanID(req.conn.humanID) {
|
||||||
ch.broadcast(ServerMessage{
|
ch.broadcast(ServerMessage{
|
||||||
Type: TypeLeave,
|
Type: TypeLeave,
|
||||||
@@ -167,7 +156,6 @@ func (h *Hub) handleUnsubscribe(ctx context.Context, req *unsubscribeRequest) {
|
|||||||
}, req.conn)
|
}, req.conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up empty local channel
|
|
||||||
if ch.isEmpty() {
|
if ch.isEmpty() {
|
||||||
delete(h.channels, req.channelID)
|
delete(h.channels, req.channelID)
|
||||||
}
|
}
|
||||||
@@ -179,13 +167,12 @@ func (h *Hub) handleBroadcast(ctx context.Context, req *broadcastRequest) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check that the sender is actually in the channel
|
|
||||||
if _, isMember := ch.members[req.conn]; !isMember {
|
if _, isMember := ch.members[req.conn]; !isMember {
|
||||||
req.conn.sendError("not subscribed to channel: " + req.channelID)
|
req.conn.sendError("not subscribed to channel: " + req.channelID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deliver to local connections (except sender)
|
// Local fanout (excluding sender), then publish for other pods.
|
||||||
ch.broadcast(ServerMessage{
|
ch.broadcast(ServerMessage{
|
||||||
Type: TypeMessage,
|
Type: TypeMessage,
|
||||||
Channel: req.channelID,
|
Channel: req.channelID,
|
||||||
@@ -193,7 +180,6 @@ func (h *Hub) handleBroadcast(ctx context.Context, req *broadcastRequest) {
|
|||||||
Payload: req.payload,
|
Payload: req.payload,
|
||||||
}, req.conn)
|
}, req.conn)
|
||||||
|
|
||||||
// Publish to Redis for other pods
|
|
||||||
h.bridge.Broadcast(ctx, req.channelID, req.conn.humanID, req.payload)
|
h.bridge.Broadcast(ctx, req.channelID, req.conn.humanID, req.payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +220,8 @@ func (h *Hub) handleDisconnect(ctx context.Context, conn *Conn) {
|
|||||||
func (h *Hub) handleRemoteEvent(evt *remoteEvent) {
|
func (h *Hub) handleRemoteEvent(evt *remoteEvent) {
|
||||||
ch, ok := h.channels[evt.channelID]
|
ch, ok := h.channels[evt.channelID]
|
||||||
if !ok {
|
if !ok {
|
||||||
return // no local connections care about this channel
|
// No local subscribers — drop the event.
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch evt.event.Type {
|
switch evt.event.Type {
|
||||||
@@ -262,12 +249,10 @@ func (h *Hub) handleRemoteEvent(evt *remoteEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe enqueues a subscribe request for the given connection and channel.
|
|
||||||
func (h *Hub) Subscribe(conn *Conn, channelID string) {
|
func (h *Hub) Subscribe(conn *Conn, channelID string) {
|
||||||
h.subscribeCh <- &subscribeRequest{conn: conn, channelID: channelID}
|
h.subscribeCh <- &subscribeRequest{conn: conn, channelID: channelID}
|
||||||
}
|
}
|
||||||
|
|
||||||
// disconnect sends a connection to the disconnect channel.
|
|
||||||
func (h *Hub) disconnect(conn *Conn) {
|
func (h *Hub) disconnect(conn *Conn) {
|
||||||
h.disconnectCh <- conn
|
h.disconnectCh <- conn
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,22 +23,22 @@ const (
|
|||||||
pubsubPrefix = "pusher:events:"
|
pubsubPrefix = "pusher:events:"
|
||||||
)
|
)
|
||||||
|
|
||||||
// redisEvent is published/received via Redis Pub/Sub for cross-pod communication.
|
// Wire format for cross-pod Pub/Sub.
|
||||||
type redisEvent struct {
|
type redisEvent struct {
|
||||||
Type string `json:"type"` // "join", "leave", "message"
|
Type string `json:"type"` // "join", "leave", "message"
|
||||||
HumanID string `json:"humanId,omitempty"` // who triggered the event
|
HumanID string `json:"humanId,omitempty"` // who triggered the event
|
||||||
PodID string `json:"podId,omitempty"` // originating pod
|
PodID string `json:"podId,omitempty"` // originating pod
|
||||||
Payload json.RawMessage `json:"payload,omitempty"` // for message events
|
Payload json.RawMessage `json:"payload,omitempty"` // message events only
|
||||||
}
|
}
|
||||||
|
|
||||||
// RedisBridge handles cross-pod coordination via Redis Pub/Sub and presence tracking.
|
// RedisBridge handles cross-pod coordination via Redis Pub/Sub and presence
|
||||||
|
// tracking.
|
||||||
type RedisBridge struct {
|
type RedisBridge struct {
|
||||||
client *redis.Client
|
client *redis.Client
|
||||||
podID string
|
podID string
|
||||||
hub *Hub // set after hub is created
|
hub *Hub // wired post-construction; see SetHub
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRedisBridge creates a new Redis bridge for cross-pod coordination.
|
|
||||||
func NewRedisBridge(client *redis.Client, podID string) *RedisBridge {
|
func NewRedisBridge(client *redis.Client, podID string) *RedisBridge {
|
||||||
return &RedisBridge{
|
return &RedisBridge{
|
||||||
client: client,
|
client: client,
|
||||||
@@ -46,20 +46,20 @@ func NewRedisBridge(client *redis.Client, podID string) *RedisBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetHub sets the hub reference. Called during initialization.
|
// SetHub resolves the circular dependency between Hub and RedisBridge.
|
||||||
func (rb *RedisBridge) SetHub(hub *Hub) {
|
func (rb *RedisBridge) SetHub(hub *Hub) {
|
||||||
rb.hub = hub
|
rb.hub = hub
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Presence management (called by hub goroutine) ---
|
// --- Presence management ---
|
||||||
|
|
||||||
// Subscribe adds a connection to a channel in Redis.
|
// Subscribe records the connection in Redis and returns the channel's
|
||||||
// Returns the current presence set for the channel.
|
// current deduplicated presence set.
|
||||||
func (rb *RedisBridge) Subscribe(ctx context.Context, channelID, connID, humanID string) ([]string, error) {
|
func (rb *RedisBridge) Subscribe(ctx context.Context, channelID, connID, humanID string) ([]string, error) {
|
||||||
key := channelConnsKey(channelID)
|
key := channelConnsKey(channelID)
|
||||||
field := rb.connField(connID)
|
field := rb.connField(connID)
|
||||||
|
|
||||||
// Check if humanID was already present before adding
|
// Snapshot before the insert so multi-tab joins don't double-emit.
|
||||||
existingMembers, err := rb.client.HVals(ctx, key).Result()
|
existingMembers, err := rb.client.HVals(ctx, key).Result()
|
||||||
if err != nil && err != redis.Nil {
|
if err != nil && err != redis.Nil {
|
||||||
return nil, fmt.Errorf("failed to get channel members: %w", err)
|
return nil, fmt.Errorf("failed to get channel members: %w", err)
|
||||||
@@ -67,12 +67,10 @@ func (rb *RedisBridge) Subscribe(ctx context.Context, channelID, connID, humanID
|
|||||||
|
|
||||||
wasPresent := containsString(existingMembers, humanID)
|
wasPresent := containsString(existingMembers, humanID)
|
||||||
|
|
||||||
// Add this connection
|
|
||||||
if err := rb.client.HSet(ctx, key, field, humanID).Err(); err != nil {
|
if err := rb.client.HSet(ctx, key, field, humanID).Err(); err != nil {
|
||||||
return nil, fmt.Errorf("failed to add connection to channel: %w", err)
|
return nil, fmt.Errorf("failed to add connection to channel: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish join event if this is a new humanID in the channel
|
|
||||||
if !wasPresent {
|
if !wasPresent {
|
||||||
rb.publishEvent(ctx, channelID, redisEvent{
|
rb.publishEvent(ctx, channelID, redisEvent{
|
||||||
Type: TypeJoin,
|
Type: TypeJoin,
|
||||||
@@ -81,7 +79,6 @@ func (rb *RedisBridge) Subscribe(ctx context.Context, channelID, connID, humanID
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return deduplicated presence set
|
|
||||||
allMembers, err := rb.client.HVals(ctx, key).Result()
|
allMembers, err := rb.client.HVals(ctx, key).Result()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get channel members: %w", err)
|
return nil, fmt.Errorf("failed to get channel members: %w", err)
|
||||||
@@ -89,7 +86,6 @@ func (rb *RedisBridge) Subscribe(ctx context.Context, channelID, connID, humanID
|
|||||||
return deduplicateStrings(allMembers), nil
|
return deduplicateStrings(allMembers), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unsubscribe removes a connection from a channel in Redis.
|
|
||||||
func (rb *RedisBridge) Unsubscribe(ctx context.Context, channelID, connID, humanID string) error {
|
func (rb *RedisBridge) Unsubscribe(ctx context.Context, channelID, connID, humanID string) error {
|
||||||
key := channelConnsKey(channelID)
|
key := channelConnsKey(channelID)
|
||||||
field := rb.connField(connID)
|
field := rb.connField(connID)
|
||||||
@@ -98,7 +94,7 @@ func (rb *RedisBridge) Unsubscribe(ctx context.Context, channelID, connID, human
|
|||||||
return fmt.Errorf("failed to remove connection from channel: %w", err)
|
return fmt.Errorf("failed to remove connection from channel: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this humanID is still present via other connections
|
// Only emit leave once this humanID has no tabs left in the channel.
|
||||||
remainingMembers, err := rb.client.HVals(ctx, key).Result()
|
remainingMembers, err := rb.client.HVals(ctx, key).Result()
|
||||||
if err != nil && err != redis.Nil {
|
if err != nil && err != redis.Nil {
|
||||||
return fmt.Errorf("failed to get remaining members: %w", err)
|
return fmt.Errorf("failed to get remaining members: %w", err)
|
||||||
@@ -112,7 +108,6 @@ func (rb *RedisBridge) Unsubscribe(ctx context.Context, channelID, connID, human
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up empty channel hash
|
|
||||||
if len(remainingMembers) == 0 {
|
if len(remainingMembers) == 0 {
|
||||||
rb.client.Del(ctx, key)
|
rb.client.Del(ctx, key)
|
||||||
}
|
}
|
||||||
@@ -120,7 +115,6 @@ func (rb *RedisBridge) Unsubscribe(ctx context.Context, channelID, connID, human
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast publishes a message event to all pods.
|
|
||||||
func (rb *RedisBridge) Broadcast(ctx context.Context, channelID, humanID string, payload json.RawMessage) {
|
func (rb *RedisBridge) Broadcast(ctx context.Context, channelID, humanID string, payload json.RawMessage) {
|
||||||
rb.publishEvent(ctx, channelID, redisEvent{
|
rb.publishEvent(ctx, channelID, redisEvent{
|
||||||
Type: TypeMessage,
|
Type: TypeMessage,
|
||||||
@@ -130,7 +124,6 @@ func (rb *RedisBridge) Broadcast(ctx context.Context, channelID, humanID string,
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPresence returns the deduplicated humanIDs for the given channels.
|
|
||||||
func (rb *RedisBridge) GetPresence(ctx context.Context, channelIDs []string) (map[string][]string, error) {
|
func (rb *RedisBridge) GetPresence(ctx context.Context, channelIDs []string) (map[string][]string, error) {
|
||||||
result := make(map[string][]string, len(channelIDs))
|
result := make(map[string][]string, len(channelIDs))
|
||||||
for _, chID := range channelIDs {
|
for _, chID := range channelIDs {
|
||||||
@@ -143,8 +136,7 @@ func (rb *RedisBridge) GetPresence(ctx context.Context, channelIDs []string) (ma
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllConnectedHumanIDs scans all channel connection hashes in Redis and returns
|
// Returns every humanID with at least one active connection cluster-wide.
|
||||||
// the deduplicated set of all humanIDs that have at least one active connection.
|
|
||||||
func (rb *RedisBridge) GetAllConnectedHumanIDs(ctx context.Context) ([]string, error) {
|
func (rb *RedisBridge) GetAllConnectedHumanIDs(ctx context.Context) ([]string, error) {
|
||||||
allHumanIDs := make(map[string]bool)
|
allHumanIDs := make(map[string]bool)
|
||||||
var cursor uint64
|
var cursor uint64
|
||||||
@@ -178,10 +170,9 @@ func (rb *RedisBridge) GetAllConnectedHumanIDs(ctx context.Context) ([]string, e
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Pub/Sub listener (runs in its own goroutine) ---
|
// --- Pub/Sub listener ---
|
||||||
|
|
||||||
// Listen subscribes to Redis Pub/Sub and forwards events to the local hub.
|
// Listen forwards Redis Pub/Sub events to the local hub; blocks until ctx is cancelled.
|
||||||
// Blocks until the context is cancelled.
|
|
||||||
func (rb *RedisBridge) Listen(ctx context.Context) {
|
func (rb *RedisBridge) Listen(ctx context.Context) {
|
||||||
pubsub := rb.client.PSubscribe(ctx, pubsubPrefix+"*")
|
pubsub := rb.client.PSubscribe(ctx, pubsubPrefix+"*")
|
||||||
defer pubsub.Close()
|
defer pubsub.Close()
|
||||||
@@ -201,7 +192,7 @@ func (rb *RedisBridge) Listen(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (rb *RedisBridge) handlePubSubMessage(msg *redis.Message) {
|
func (rb *RedisBridge) handlePubSubMessage(msg *redis.Message) {
|
||||||
// Extract channel ID from topic: "pusher:events:{channelID}"
|
// Topic: "pusher:events:{channelID}".
|
||||||
channelID := strings.TrimPrefix(msg.Channel, pubsubPrefix)
|
channelID := strings.TrimPrefix(msg.Channel, pubsubPrefix)
|
||||||
if channelID == "" {
|
if channelID == "" {
|
||||||
return
|
return
|
||||||
@@ -213,7 +204,7 @@ func (rb *RedisBridge) handlePubSubMessage(msg *redis.Message) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip events originating from this pod — the local hub already handled them
|
// Same-pod events were already handled by the local hub.
|
||||||
if event.PodID == rb.podID {
|
if event.PodID == rb.podID {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -222,20 +213,18 @@ func (rb *RedisBridge) handlePubSubMessage(msg *redis.Message) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forward to local hub for delivery to local WebSocket connections
|
|
||||||
rb.hub.remoteEventCh <- &remoteEvent{
|
rb.hub.remoteEventCh <- &remoteEvent{
|
||||||
channelID: channelID,
|
channelID: channelID,
|
||||||
event: event,
|
event: event,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Heartbeat + cleanup (runs in its own goroutine) ---
|
// --- Heartbeat + cleanup ---
|
||||||
|
|
||||||
// Heartbeat maintains this pod's liveness key and cleans up stale pods.
|
// Heartbeat refreshes this pod's liveness key and reaps stale pods on a tick.
|
||||||
func (rb *RedisBridge) Heartbeat(ctx context.Context) {
|
func (rb *RedisBridge) Heartbeat(ctx context.Context) {
|
||||||
podKey := podKeyPrefix + rb.podID
|
podKey := podKeyPrefix + rb.podID
|
||||||
|
|
||||||
// Initial heartbeat
|
|
||||||
rb.client.Set(ctx, podKey, "alive", podHeartbeatTTL)
|
rb.client.Set(ctx, podKey, "alive", podHeartbeatTTL)
|
||||||
|
|
||||||
heartbeatTicker := time.NewTicker(podHeartbeatInterval)
|
heartbeatTicker := time.NewTicker(podHeartbeatInterval)
|
||||||
@@ -246,7 +235,7 @@ func (rb *RedisBridge) Heartbeat(ctx context.Context) {
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
// On shutdown, remove our pod key and clean up our connections
|
// On shutdown, drop our pod key and reclaim our connection slots.
|
||||||
rb.client.Del(context.Background(), podKey)
|
rb.client.Del(context.Background(), podKey)
|
||||||
rb.cleanupPod(context.Background(), rb.podID)
|
rb.cleanupPod(context.Background(), rb.podID)
|
||||||
return
|
return
|
||||||
@@ -259,7 +248,8 @@ func (rb *RedisBridge) Heartbeat(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (rb *RedisBridge) cleanupStalePods(ctx context.Context) {
|
func (rb *RedisBridge) cleanupStalePods(ctx context.Context) {
|
||||||
// Scan all channel conn hashes for pod IDs, then check if those pods are still alive
|
// Collect every pod referenced in channel-conn hashes, then drop those
|
||||||
|
// whose liveness key has expired.
|
||||||
var cursor uint64
|
var cursor uint64
|
||||||
knownPods := make(map[string]bool)
|
knownPods := make(map[string]bool)
|
||||||
alivePods := make(map[string]bool)
|
alivePods := make(map[string]bool)
|
||||||
@@ -290,7 +280,6 @@ func (rb *RedisBridge) cleanupStalePods(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check which pods are still alive
|
|
||||||
for podID := range knownPods {
|
for podID := range knownPods {
|
||||||
exists, err := rb.client.Exists(ctx, podKeyPrefix+podID).Result()
|
exists, err := rb.client.Exists(ctx, podKeyPrefix+podID).Result()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -301,7 +290,6 @@ func (rb *RedisBridge) cleanupStalePods(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up dead pods
|
|
||||||
for podID := range knownPods {
|
for podID := range knownPods {
|
||||||
if !alivePods[podID] {
|
if !alivePods[podID] {
|
||||||
slog.Info("cleaning up stale pod", "podId", podID)
|
slog.Info("cleaning up stale pod", "podId", podID)
|
||||||
@@ -328,7 +316,6 @@ func (rb *RedisBridge) cleanupPod(ctx context.Context, podID string) {
|
|||||||
for field, humanID := range fields {
|
for field, humanID := range fields {
|
||||||
if extractPodID(field) == podID {
|
if extractPodID(field) == podID {
|
||||||
rb.client.HDel(ctx, key, field)
|
rb.client.HDel(ctx, key, field)
|
||||||
// Check if this humanID is now gone from the channel
|
|
||||||
remaining, _ := rb.client.HVals(ctx, key).Result()
|
remaining, _ := rb.client.HVals(ctx, key).Result()
|
||||||
if !containsString(remaining, humanID) {
|
if !containsString(remaining, humanID) {
|
||||||
rb.publishEvent(ctx, channelID, redisEvent{
|
rb.publishEvent(ctx, channelID, redisEvent{
|
||||||
@@ -369,15 +356,15 @@ func channelConnsKey(channelID string) string {
|
|||||||
return channelConnsPrefix + channelID + channelConnsSuffix
|
return channelConnsPrefix + channelID + channelConnsSuffix
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "pusher:ch:{channelID}:conns" → channelID
|
||||||
func extractChannelID(redisKey string) string {
|
func extractChannelID(redisKey string) string {
|
||||||
// "pusher:ch:{channelID}:conns" → channelID
|
|
||||||
s := strings.TrimPrefix(redisKey, channelConnsPrefix)
|
s := strings.TrimPrefix(redisKey, channelConnsPrefix)
|
||||||
s = strings.TrimSuffix(s, channelConnsSuffix)
|
s = strings.TrimSuffix(s, channelConnsSuffix)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "{podID}:{connID}" → podID
|
||||||
func extractPodID(field string) string {
|
func extractPodID(field string) string {
|
||||||
// "{podID}:{connID}" → podID
|
|
||||||
parts := strings.SplitN(field, ":", 2)
|
parts := strings.SplitN(field, ":", 2)
|
||||||
if len(parts) == 2 {
|
if len(parts) == 2 {
|
||||||
return parts[0]
|
return parts[0]
|
||||||
|
|||||||
@@ -15,14 +15,12 @@ import (
|
|||||||
type Server struct {
|
type Server struct {
|
||||||
pbpusher.UnimplementedPusherServiceServer
|
pbpusher.UnimplementedPusherServiceServer
|
||||||
|
|
||||||
ctx context.Context // server-scoped context for graceful shutdown
|
ctx context.Context // server-scoped; cancelling closes all WebSockets gracefully
|
||||||
hub *Hub
|
hub *Hub
|
||||||
bridge *RedisBridge
|
bridge *RedisBridge
|
||||||
authSvc auth.SessionReader
|
authSvc auth.SessionReader
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer creates a new pusher server. The ctx controls the lifetime of all
|
|
||||||
// WebSocket connections — when cancelled, all connections are closed gracefully.
|
|
||||||
func NewServer(ctx context.Context, hub *Hub, bridge *RedisBridge, authSvc auth.SessionReader) *Server {
|
func NewServer(ctx context.Context, hub *Hub, bridge *RedisBridge, authSvc auth.SessionReader) *Server {
|
||||||
return &Server{
|
return &Server{
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
@@ -32,9 +30,8 @@ func NewServer(ctx context.Context, hub *Hub, bridge *RedisBridge, authSvc auth.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleWebSocket handles the WebSocket upgrade and connection lifecycle.
|
|
||||||
func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||||
// Authenticate via query param (WebSocket upgrade can't use custom headers)
|
// Token rides in the query string — WebSocket upgrades can't carry custom headers.
|
||||||
token := r.URL.Query().Get("token")
|
token := r.URL.Query().Get("token")
|
||||||
if token == "" {
|
if token == "" {
|
||||||
http.Error(w, "token required", http.StatusUnauthorized)
|
http.Error(w, "token required", http.StatusUnauthorized)
|
||||||
@@ -47,9 +44,8 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accept WebSocket upgrade
|
|
||||||
ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||||
// Allow all origins for now — CORS is handled at the gateway level
|
// CORS is enforced at the gateway.
|
||||||
InsecureSkipVerify: true,
|
InsecureSkipVerify: true,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -62,25 +58,21 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
slog.Info("websocket connected", "connId", connID, "humanId", session.HumanId)
|
slog.Info("websocket connected", "connId", connID, "humanId", session.HumanId)
|
||||||
|
|
||||||
// Use server context, NOT r.Context(). After WebSocket upgrade, the HTTP
|
// Use the server context, not r.Context(): after upgrade the HTTP request
|
||||||
// request context can be cancelled by load balancers or Go's HTTP server,
|
// context can be cancelled by load balancers and nhooyr/websocket would
|
||||||
// and nhooyr/websocket permanently closes the conn on any context error.
|
// then permanently close the conn.
|
||||||
ctx, cancel := context.WithCancel(s.ctx)
|
ctx, cancel := context.WithCancel(s.ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// Auto-subscribe to presence channel so this user appears online
|
// Auto-subscribe to the presence channel so this user appears online.
|
||||||
s.hub.Subscribe(conn, "_presence:"+session.HumanId)
|
s.hub.Subscribe(conn, "_presence:"+session.HumanId)
|
||||||
|
|
||||||
// WritePump runs in a separate goroutine
|
|
||||||
go conn.WritePump(ctx)
|
go conn.WritePump(ctx)
|
||||||
|
|
||||||
// ReadPump blocks until the connection closes
|
|
||||||
conn.ReadPump(ctx, s.hub)
|
conn.ReadPump(ctx, s.hub)
|
||||||
|
|
||||||
slog.Info("websocket disconnected", "connId", connID, "humanId", session.HumanId)
|
slog.Info("websocket disconnected", "connId", connID, "humanId", session.HumanId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetOnlineHumanIds returns all currently connected human IDs.
|
|
||||||
func (s *Server) GetOnlineHumanIds(ctx context.Context, _ *pbpusher.GetOnlineHumanIdsRequest) (*pbpusher.GetOnlineHumanIdsResponse, error) {
|
func (s *Server) GetOnlineHumanIds(ctx context.Context, _ *pbpusher.GetOnlineHumanIdsRequest) (*pbpusher.GetOnlineHumanIdsResponse, error) {
|
||||||
humanIDs, err := s.bridge.GetAllConnectedHumanIDs(ctx)
|
humanIDs, err := s.bridge.GetAllConnectedHumanIDs(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -89,7 +81,6 @@ func (s *Server) GetOnlineHumanIds(ctx context.Context, _ *pbpusher.GetOnlineHum
|
|||||||
return &pbpusher.GetOnlineHumanIdsResponse{HumanIds: humanIDs}, nil
|
return &pbpusher.GetOnlineHumanIdsResponse{HumanIds: humanIDs}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsOnline checks whether specific humans are currently online.
|
|
||||||
func (s *Server) IsOnline(ctx context.Context, req *pbpusher.IsOnlineRequest) (*pbpusher.IsOnlineResponse, error) {
|
func (s *Server) IsOnline(ctx context.Context, req *pbpusher.IsOnlineRequest) (*pbpusher.IsOnlineResponse, error) {
|
||||||
allOnline, err := s.bridge.GetAllConnectedHumanIDs(ctx)
|
allOnline, err := s.bridge.GetAllConnectedHumanIDs(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -106,7 +97,6 @@ func (s *Server) IsOnline(ctx context.Context, req *pbpusher.IsOnlineRequest) (*
|
|||||||
return &pbpusher.IsOnlineResponse{Online: result}, nil
|
return &pbpusher.IsOnlineResponse{Online: result}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetChannelPresence returns presence (human IDs) for specific channels.
|
|
||||||
func (s *Server) GetChannelPresence(ctx context.Context, req *pbpusher.GetChannelPresenceRequest) (*pbpusher.GetChannelPresenceResponse, error) {
|
func (s *Server) GetChannelPresence(ctx context.Context, req *pbpusher.GetChannelPresenceRequest) (*pbpusher.GetChannelPresenceResponse, error) {
|
||||||
presence, err := s.bridge.GetPresence(ctx, req.ChannelIds)
|
presence, err := s.bridge.GetPresence(ctx, req.ChannelIds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -18,14 +18,12 @@ const (
|
|||||||
TypeError = "error"
|
TypeError = "error"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ClientMessage is a message sent from a WebSocket client to the server.
|
|
||||||
type ClientMessage struct {
|
type ClientMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Channel string `json:"channel,omitempty"`
|
Channel string `json:"channel,omitempty"`
|
||||||
Payload json.RawMessage `json:"payload,omitempty"`
|
Payload json.RawMessage `json:"payload,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServerMessage is a message sent from the server to a WebSocket client.
|
|
||||||
type ServerMessage struct {
|
type ServerMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Channel string `json:"channel,omitempty"`
|
Channel string `json:"channel,omitempty"`
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func ConnectAndTestRedis(db int) *redis.Client {
|
|||||||
redisAddr := fmt.Sprintf("%s:%s", redisHost, "6379")
|
redisAddr := fmt.Sprintf("%s:%s", redisHost, "6379")
|
||||||
rdb := redis.NewClient(&redis.Options{
|
rdb := redis.NewClient(&redis.Options{
|
||||||
Addr: redisAddr,
|
Addr: redisAddr,
|
||||||
Password: "", // no password set
|
Password: "",
|
||||||
DB: db,
|
DB: db,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,12 @@ import (
|
|||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
// enum of environment variables
|
// EnvVar enumerates the env vars referenced via this package.
|
||||||
type EnvVar string
|
type EnvVar string
|
||||||
|
|
||||||
const ()
|
const ()
|
||||||
|
|
||||||
// MustGetEnv returns the value of the environment variable with the given key.
|
// MustGetEnv panics if the variable is unset.
|
||||||
// panics if the variable is not set.
|
|
||||||
func MustGetEnv[T string | EnvVar](key T) string {
|
func MustGetEnv[T string | EnvVar](key T) string {
|
||||||
keyString := string(key)
|
keyString := string(key)
|
||||||
value := os.Getenv(keyString)
|
value := os.Getenv(keyString)
|
||||||
@@ -24,8 +23,7 @@ func MustGetEnv[T string | EnvVar](key T) string {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetEnv returns the value of the environment variable with the given key.
|
// GetEnv returns "" if the variable is unset (and logs a warning).
|
||||||
// returns an empty string if the variable is not set.
|
|
||||||
func GetEnv(key string) string {
|
func GetEnv(key string) string {
|
||||||
value := os.Getenv(key)
|
value := os.Getenv(key)
|
||||||
if value == "" {
|
if value == "" {
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ func CreateOptionalBool(input bool) *bool {
|
|||||||
return &input
|
return &input
|
||||||
}
|
}
|
||||||
|
|
||||||
// OptionalString converts a non-nil *string to the respective string or returns "".
|
|
||||||
func OptionalString(input *string) string {
|
func OptionalString(input *string) string {
|
||||||
if input == nil {
|
if input == nil {
|
||||||
return ""
|
return ""
|
||||||
@@ -30,7 +29,6 @@ func OptionalString(input *string) string {
|
|||||||
return *input
|
return *input
|
||||||
}
|
}
|
||||||
|
|
||||||
// OptionalInt converts a non-nil *int to the respective int, otherwise returns 0.
|
|
||||||
func OptionalInt(input *int) int {
|
func OptionalInt(input *int) int {
|
||||||
if input == nil {
|
if input == nil {
|
||||||
return 0
|
return 0
|
||||||
@@ -39,8 +37,7 @@ func OptionalInt(input *int) int {
|
|||||||
return *input
|
return *input
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateOptionalInt when given a zero value int (0), it returns a nil *int.
|
// Zero values become nil; the inverse of OptionalInt.
|
||||||
// Otherwise, it gives a proper *int with valid value.
|
|
||||||
func CreateOptionalInt(input int) *int {
|
func CreateOptionalInt(input int) *int {
|
||||||
if input == 0 {
|
if input == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -49,8 +46,7 @@ func CreateOptionalInt(input int) *int {
|
|||||||
return &input
|
return &input
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateOptionalString when given an empty string, it returns a nil *string.
|
// Empty string becomes nil; the inverse of OptionalString.
|
||||||
// Otherwise, it gives a proper *string with valid value.
|
|
||||||
func CreateOptionalString(input string) *string {
|
func CreateOptionalString(input string) *string {
|
||||||
if input == "" {
|
if input == "" {
|
||||||
return nil
|
return nil
|
||||||
@@ -59,8 +55,7 @@ func CreateOptionalString(input string) *string {
|
|||||||
return &input
|
return &input
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetNumberFromString converts a string to a number.
|
// Returns an error if input contains non-digit characters or parses to <= 0.
|
||||||
// Returns error if the query is not a number.
|
|
||||||
func GetNumberFromString(input string) (int, error) {
|
func GetNumberFromString(input string) (int, error) {
|
||||||
for _, c := range input {
|
for _, c := range input {
|
||||||
if c < '0' || c > '9' {
|
if c < '0' || c > '9' {
|
||||||
@@ -80,7 +75,6 @@ type Number interface {
|
|||||||
int | int8 | int16 | int32 | int64 | uint | uint8 | uint16 | uint32 | uint64 | float32 | float64
|
int | int8 | int16 | int32 | int64 | uint | uint8 | uint16 | uint32 | uint64 | float32 | float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// OptionalNumber converts a non-nil *NUMBER to the respective number value or returns 0.
|
|
||||||
func OptionalNumber[T Number](input *T) T {
|
func OptionalNumber[T Number](input *T) T {
|
||||||
if input == nil {
|
if input == nil {
|
||||||
return 0
|
return 0
|
||||||
@@ -89,7 +83,7 @@ func OptionalNumber[T Number](input *T) T {
|
|||||||
return *input
|
return *input
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateOptionalNumber when given an zero value NUMBER (0), it returns a nil *NUMBER, otherwise, it gives a proper *NUMBER with valid value.
|
// Zero values become nil; the inverse of OptionalNumber.
|
||||||
func CreateOptionalNumber[T Number](input T) *T {
|
func CreateOptionalNumber[T Number](input T) *T {
|
||||||
if input == 0 {
|
if input == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ const (
|
|||||||
charsetNumbers = "0123456789"
|
charsetNumbers = "0123456789"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RandomString generates a random string of length n based on self defined charset
|
|
||||||
func RandomString(length int) string {
|
func RandomString(length int) string {
|
||||||
sb := strings.Builder{}
|
sb := strings.Builder{}
|
||||||
sb.Grow(length)
|
sb.Grow(length)
|
||||||
@@ -20,7 +19,6 @@ func RandomString(length int) string {
|
|||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// RandomStringNumbers
|
|
||||||
func RandomStringNumbers(length int) string {
|
func RandomStringNumbers(length int) string {
|
||||||
sb := strings.Builder{}
|
sb := strings.Builder{}
|
||||||
sb.Grow(length)
|
sb.Grow(length)
|
||||||
|
|||||||
@@ -34,11 +34,10 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Service interface {
|
type Service interface {
|
||||||
// returns AlreadyInWaitlistError if already in the waitlist
|
// AddToWaitlist returns AlreadyInWaitlistError if the email is already present.
|
||||||
// any other error is a failure
|
|
||||||
AddToWaitlist(ctx context.Context, email string, metadata map[string]string) error
|
AddToWaitlist(ctx context.Context, email string, metadata map[string]string) error
|
||||||
GetWaitlist(ctx context.Context, filter GetWaitlistFilter) ([]*WaitlistEntry, error)
|
GetWaitlist(ctx context.Context, filter GetWaitlistFilter) ([]*WaitlistEntry, error)
|
||||||
// returns error if not found
|
// GetWaitlistEntryByEmail returns EntryNotFoundError if missing.
|
||||||
GetWaitlistEntryByEmail(ctx context.Context, email string) (*WaitlistEntry, error)
|
GetWaitlistEntryByEmail(ctx context.Context, email string) (*WaitlistEntry, error)
|
||||||
MarkWaitlistEntryInvited(ctx context.Context, email string) error
|
MarkWaitlistEntryInvited(ctx context.Context, email string) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,3 +43,10 @@ spec:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: shared-secrets
|
name: shared-secrets
|
||||||
key: DEEPGRAM_SECRET
|
key: DEEPGRAM_SECRET
|
||||||
|
- name: "PUSHER_GRPC_ADDR"
|
||||||
|
value: "pusher:50051"
|
||||||
|
- name: "EXPO_ACCESS_TOKEN"
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: shared-secrets
|
||||||
|
key: EXPO_ACCESS_TOKEN
|
||||||
|
|||||||
@@ -40,3 +40,10 @@ spec:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: shared-secrets
|
name: shared-secrets
|
||||||
key: DEEPGRAM_SECRET
|
key: DEEPGRAM_SECRET
|
||||||
|
- name: "PUSHER_GRPC_ADDR"
|
||||||
|
value: "pusher:50051"
|
||||||
|
- name: "EXPO_ACCESS_TOKEN"
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: shared-secrets
|
||||||
|
key: EXPO_ACCESS_TOKEN
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS push_tokens;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE push_tokens (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
human_id TEXT NOT NULL REFERENCES humans(id) ON DELETE CASCADE,
|
||||||
|
platform TEXT NOT NULL CHECK (platform IN ('ios', 'android')),
|
||||||
|
app_version TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX push_tokens_human_id_idx ON push_tokens (human_id);
|
||||||
@@ -50,6 +50,13 @@ const config: ExpoConfig = {
|
|||||||
"Flowy uses your microphone to record voice messages.",
|
"Flowy uses your microphone to record voice messages.",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
"expo-notifications",
|
||||||
|
{
|
||||||
|
icon: "./assets/icon.png",
|
||||||
|
color: "#000000",
|
||||||
|
},
|
||||||
|
],
|
||||||
],
|
],
|
||||||
experiments: {
|
experiments: {
|
||||||
typedRoutes: false,
|
typedRoutes: false,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flowy-mobile",
|
"name": "flowy-mobile",
|
||||||
"version": "0.2.2",
|
"version": "0.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -22,8 +22,10 @@
|
|||||||
"expo-audio": "~1.0.13",
|
"expo-audio": "~1.0.13",
|
||||||
"expo-camera": "~17.0.10",
|
"expo-camera": "~17.0.10",
|
||||||
"expo-constants": "~18.0.13",
|
"expo-constants": "~18.0.13",
|
||||||
|
"expo-device": "~8.0.10",
|
||||||
"expo-file-system": "~19.0.16",
|
"expo-file-system": "~19.0.16",
|
||||||
"expo-haptics": "~15.0.7",
|
"expo-haptics": "~15.0.7",
|
||||||
|
"expo-notifications": "~0.32.17",
|
||||||
"expo-secure-store": "~15.0.8",
|
"expo-secure-store": "~15.0.8",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
"expo-video": "~3.0.10",
|
"expo-video": "~3.0.10",
|
||||||
|
|||||||
+12
-1
@@ -9,12 +9,20 @@ import {
|
|||||||
} from "react-native-safe-area-context";
|
} from "react-native-safe-area-context";
|
||||||
import { Toaster } from "sonner-native";
|
import { Toaster } from "sonner-native";
|
||||||
import { createQueryClient } from "@/lib/query-client";
|
import { createQueryClient } from "@/lib/query-client";
|
||||||
|
import {
|
||||||
|
flushPendingNavigation,
|
||||||
|
navigationRef,
|
||||||
|
} from "@/lib/notification-routing";
|
||||||
|
import { configureNotifications } from "@/lib/push-notifications";
|
||||||
import { PusherProvider } from "@/lib/pusher-provider";
|
import { PusherProvider } from "@/lib/pusher-provider";
|
||||||
import { RootNavigator } from "@/navigation/RootNavigator";
|
import { RootNavigator } from "@/navigation/RootNavigator";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
|
||||||
const queryClient = createQueryClient();
|
const queryClient = createQueryClient();
|
||||||
|
|
||||||
|
// One-time setup: foreground handler + push-token rotation listener. Idempotent.
|
||||||
|
configureNotifications();
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const restoreSession = useAuthStore((s) => s.restoreSession);
|
const restoreSession = useAuthStore((s) => s.restoreSession);
|
||||||
|
|
||||||
@@ -27,7 +35,10 @@ export default function App() {
|
|||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<PusherProvider>
|
<PusherProvider>
|
||||||
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||||
<NavigationContainer>
|
<NavigationContainer
|
||||||
|
ref={navigationRef}
|
||||||
|
onReady={flushPendingNavigation}
|
||||||
|
>
|
||||||
<RootNavigator />
|
<RootNavigator />
|
||||||
</NavigationContainer>
|
</NavigationContainer>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
|||||||
@@ -139,6 +139,20 @@ class ApiClient {
|
|||||||
await this.requestVoid("PATCH", "/humans/me/settings", data);
|
await this.requestVoid("PATCH", "/humans/me/settings", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Push notification tokens ---
|
||||||
|
|
||||||
|
async registerPushToken(data: {
|
||||||
|
token: string;
|
||||||
|
platform: "ios" | "android";
|
||||||
|
app_version: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
await this.requestVoid("POST", "/humans/me/push-tokens", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async unregisterPushToken(token: string): Promise<void> {
|
||||||
|
await this.requestVoid("DELETE", "/humans/me/push-tokens", { token });
|
||||||
|
}
|
||||||
|
|
||||||
// --- Depot ---
|
// --- Depot ---
|
||||||
|
|
||||||
async prepareUpload(data: PrepareUploadRequest) {
|
async prepareUpload(data: PrepareUploadRequest) {
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { createNavigationContainerRef } from "@react-navigation/native";
|
||||||
|
import type { Notification } from "expo-notifications";
|
||||||
|
import { logError } from "@/lib/errors";
|
||||||
|
|
||||||
|
// Shared ref so non-component code (notification handlers, deep links) can
|
||||||
|
// drive navigation without prop-drilling. Typed via the global
|
||||||
|
// ReactNavigation.RootParamList augmentation in navigation/types.ts.
|
||||||
|
export const navigationRef = createNavigationContainerRef();
|
||||||
|
|
||||||
|
// Shape the worker (go/internal/human/pushnotify/notifier.go::buildMessages)
|
||||||
|
// puts in `Notifications.notification.request.content.data`.
|
||||||
|
type ParticleCreatedData = {
|
||||||
|
kind: "particle_created";
|
||||||
|
network_id: string;
|
||||||
|
stream_id: string;
|
||||||
|
particle_id: string;
|
||||||
|
sender_human_id: string;
|
||||||
|
particle_kind: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isParticleCreatedData(data: unknown): data is ParticleCreatedData {
|
||||||
|
return (
|
||||||
|
typeof data === "object" &&
|
||||||
|
data !== null &&
|
||||||
|
(data as { kind?: unknown }).kind === "particle_created" &&
|
||||||
|
typeof (data as { network_id?: unknown }).network_id === "string" &&
|
||||||
|
typeof (data as { stream_id?: unknown }).stream_id === "string"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a tap arrives before the navigator has mounted (cold start), stash it and
|
||||||
|
// replay as soon as the container reports ready.
|
||||||
|
let pendingNavigation: ParticleCreatedData | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes a single notification tap to the appropriate screen. Safe to call
|
||||||
|
* before the navigation container is ready — it queues the route and replays
|
||||||
|
* it once `navigationRef.isReady()` flips true.
|
||||||
|
*/
|
||||||
|
export function routeNotificationTap(notification: Notification): void {
|
||||||
|
try {
|
||||||
|
const data = notification.request.content.data;
|
||||||
|
if (!isParticleCreatedData(data)) return;
|
||||||
|
|
||||||
|
if (!navigationRef.isReady()) {
|
||||||
|
pendingNavigation = data;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigateToStream(data);
|
||||||
|
} catch (err) {
|
||||||
|
logError(err, { scope: "push.route" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called once by App.tsx when the NavigationContainer mounts. Drains any
|
||||||
|
* cold-start tap that arrived before navigation was ready.
|
||||||
|
*/
|
||||||
|
export function flushPendingNavigation(): void {
|
||||||
|
if (!pendingNavigation) return;
|
||||||
|
const data = pendingNavigation;
|
||||||
|
pendingNavigation = null;
|
||||||
|
if (navigationRef.isReady()) {
|
||||||
|
navigateToStream(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToStream(data: ParticleCreatedData): void {
|
||||||
|
navigationRef.navigate("StreamView", {
|
||||||
|
networkId: data.network_id,
|
||||||
|
streamId: data.stream_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import Constants from "expo-constants";
|
||||||
|
import * as Device from "expo-device";
|
||||||
|
import * as Notifications from "expo-notifications";
|
||||||
|
import * as SecureStore from "expo-secure-store";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import { apiClient } from "@/api/client";
|
||||||
|
import { logError } from "@/lib/errors";
|
||||||
|
import { routeNotificationTap } from "@/lib/notification-routing";
|
||||||
|
|
||||||
|
const STORED_TOKEN_KEY = "expo_push_token";
|
||||||
|
|
||||||
|
let configured = false;
|
||||||
|
let tokenListenerSubscription: Notifications.Subscription | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the foreground notification handler so banners show while the app is
|
||||||
|
* open, and subscribes to Expo's token-rotation listener so the backend stays
|
||||||
|
* in sync without the user needing to re-launch. Safe to call multiple times.
|
||||||
|
*/
|
||||||
|
export function configureNotifications(): void {
|
||||||
|
if (configured) return;
|
||||||
|
configured = true;
|
||||||
|
|
||||||
|
Notifications.setNotificationHandler({
|
||||||
|
handleNotification: async () => ({
|
||||||
|
shouldShowBanner: true,
|
||||||
|
shouldShowList: true,
|
||||||
|
shouldPlaySound: true,
|
||||||
|
shouldSetBadge: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
tokenListenerSubscription = Notifications.addPushTokenListener((event) => {
|
||||||
|
// Token rotated server-side by Expo or APNs. Sync immediately so we don't
|
||||||
|
// keep pushing to a dead token.
|
||||||
|
void syncPushToken(event.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Warm-state taps (app in background or foreground). Cold-start taps are
|
||||||
|
// drained separately via getLastNotificationResponseAsync; see
|
||||||
|
// flushPendingNavigation in notification-routing.ts.
|
||||||
|
Notifications.addNotificationResponseReceivedListener((response) => {
|
||||||
|
routeNotificationTap(response.notification);
|
||||||
|
});
|
||||||
|
|
||||||
|
void Notifications.getLastNotificationResponseAsync().then((response) => {
|
||||||
|
if (response) routeNotificationTap(response.notification);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acquires (or returns the cached) Expo push token for this device. Returns
|
||||||
|
* null on simulators, when permission is denied, or when any step fails — the
|
||||||
|
* caller should treat that as "no push, no further action".
|
||||||
|
*/
|
||||||
|
async function acquirePushToken(): Promise<string | null> {
|
||||||
|
if (!Device.isDevice) return null;
|
||||||
|
|
||||||
|
const existing = await Notifications.getPermissionsAsync();
|
||||||
|
let status = existing.status;
|
||||||
|
if (status !== "granted") {
|
||||||
|
const requested = await Notifications.requestPermissionsAsync();
|
||||||
|
status = requested.status;
|
||||||
|
}
|
||||||
|
if (status !== "granted") return null;
|
||||||
|
|
||||||
|
const projectId =
|
||||||
|
Constants.expoConfig?.extra?.eas?.projectId ??
|
||||||
|
Constants.easConfig?.projectId;
|
||||||
|
if (!projectId) {
|
||||||
|
logError(new Error("EAS projectId missing — cannot fetch push token"), {
|
||||||
|
scope: "push.acquire",
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenResult = await Notifications.getExpoPushTokenAsync({ projectId });
|
||||||
|
return tokenResult.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStoredToken(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
return await SecureStore.getItemAsync(STORED_TOKEN_KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setStoredToken(token: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await SecureStore.setItemAsync(STORED_TOKEN_KEY, token);
|
||||||
|
} catch (err) {
|
||||||
|
logError(err, { scope: "push.store" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearStoredToken(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await SecureStore.deleteItemAsync(STORED_TOKEN_KEY);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares the freshly-fetched token to whatever we last sent to Orion and
|
||||||
|
* only POSTs on a delta. Never throws — push registration is best-effort and
|
||||||
|
* must never block the auth path.
|
||||||
|
*/
|
||||||
|
export async function syncPushToken(token?: string | null): Promise<void> {
|
||||||
|
try {
|
||||||
|
const next = token ?? (await acquirePushToken());
|
||||||
|
if (!next) return;
|
||||||
|
|
||||||
|
const stored = await getStoredToken();
|
||||||
|
if (stored === next) return;
|
||||||
|
|
||||||
|
const platform = Platform.OS === "ios" ? "ios" : "android";
|
||||||
|
const appVersion = Constants.expoConfig?.version ?? "";
|
||||||
|
|
||||||
|
await apiClient.registerPushToken({
|
||||||
|
token: next,
|
||||||
|
platform,
|
||||||
|
app_version: appVersion,
|
||||||
|
});
|
||||||
|
await setStoredToken(next);
|
||||||
|
} catch (err) {
|
||||||
|
logError(err, { scope: "push.sync" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort unregister at sign-out. Wipes the stored token even if the
|
||||||
|
* server call fails so the next signed-in user re-registers cleanly.
|
||||||
|
*/
|
||||||
|
export async function unregisterPushToken(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const stored = await getStoredToken();
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
await apiClient.unregisterPushToken(stored);
|
||||||
|
} catch (err) {
|
||||||
|
logError(err, { scope: "push.unregister" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await clearStoredToken();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test-only: tears down the module-level token listener. Not normally needed
|
||||||
|
* in the app lifecycle — Notifications subscriptions live as long as the JS
|
||||||
|
* runtime does.
|
||||||
|
*/
|
||||||
|
export function _resetPushNotificationsModule(): void {
|
||||||
|
tokenListenerSubscription?.remove();
|
||||||
|
tokenListenerSubscription = null;
|
||||||
|
configured = false;
|
||||||
|
}
|
||||||
@@ -7,6 +7,10 @@ import { apiClient } from "@/api/client";
|
|||||||
import type { Human } from "@/api/types";
|
import type { Human } from "@/api/types";
|
||||||
import { firebaseAuth } from "@/firebase";
|
import { firebaseAuth } from "@/firebase";
|
||||||
import { logError, ApiError } from "@/lib/errors";
|
import { logError, ApiError } from "@/lib/errors";
|
||||||
|
import {
|
||||||
|
syncPushToken,
|
||||||
|
unregisterPushToken,
|
||||||
|
} from "@/lib/push-notifications";
|
||||||
import { hydrateSession, useSessionStore } from "./session-store";
|
import { hydrateSession, useSessionStore } from "./session-store";
|
||||||
|
|
||||||
async function signInToFirebase(): Promise<void> {
|
async function signInToFirebase(): Promise<void> {
|
||||||
@@ -60,6 +64,7 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
const user = await apiClient.me();
|
const user = await apiClient.me();
|
||||||
await signInToFirebase();
|
await signInToFirebase();
|
||||||
set({ status: "authenticated", user });
|
set({ status: "authenticated", user });
|
||||||
|
void syncPushToken();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Expected on expired/invalid tokens — fall back to the login screen.
|
// Expected on expired/invalid tokens — fall back to the login screen.
|
||||||
logError(err, { scope: "auth.restore" });
|
logError(err, { scope: "auth.restore" });
|
||||||
@@ -89,6 +94,7 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
await useSessionStore.getState().setToken(token);
|
await useSessionStore.getState().setToken(token);
|
||||||
await signInToFirebase();
|
await signInToFirebase();
|
||||||
set({ status: "authenticated", user: human });
|
set({ status: "authenticated", user: human });
|
||||||
|
void syncPushToken();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message = e instanceof ApiError ? e.message : "Failed to sign in";
|
const message = e instanceof ApiError ? e.message : "Failed to sign in";
|
||||||
set({ error: message });
|
set({ error: message });
|
||||||
@@ -100,6 +106,9 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
|
|
||||||
signOut: async () => {
|
signOut: async () => {
|
||||||
set({ isSigningOut: true });
|
set({ isSigningOut: true });
|
||||||
|
// Unregister the push token first — once the session token is cleared the
|
||||||
|
// backend call would 401. Best-effort: failures must not block sign-out.
|
||||||
|
await unregisterPushToken();
|
||||||
try {
|
try {
|
||||||
await apiClient.signOut();
|
await apiClient.signOut();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
+313
-1
@@ -1526,6 +1526,11 @@
|
|||||||
protobufjs "^7.2.5"
|
protobufjs "^7.2.5"
|
||||||
yargs "^17.7.2"
|
yargs "^17.7.2"
|
||||||
|
|
||||||
|
"@ide/backoff@^1.0.0":
|
||||||
|
version "1.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@ide/backoff/-/backoff-1.0.0.tgz#466842c25bd4a4833e0642fab41ccff064010176"
|
||||||
|
integrity sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==
|
||||||
|
|
||||||
"@isaacs/fs-minipass@^4.0.0":
|
"@isaacs/fs-minipass@^4.0.0":
|
||||||
version "4.0.1"
|
version "4.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz#2d59ae3ab4b38fb4270bfa23d30f8e2e86c7fe32"
|
resolved "https://registry.yarnpkg.com/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz#2d59ae3ab4b38fb4270bfa23d30f8e2e86c7fe32"
|
||||||
@@ -2213,11 +2218,29 @@ asap@~2.0.6:
|
|||||||
resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46"
|
resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46"
|
||||||
integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==
|
integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==
|
||||||
|
|
||||||
|
assert@^2.0.0:
|
||||||
|
version "2.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/assert/-/assert-2.1.0.tgz#6d92a238d05dc02e7427c881fb8be81c8448b2dd"
|
||||||
|
integrity sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==
|
||||||
|
dependencies:
|
||||||
|
call-bind "^1.0.2"
|
||||||
|
is-nan "^1.3.2"
|
||||||
|
object-is "^1.1.5"
|
||||||
|
object.assign "^4.1.4"
|
||||||
|
util "^0.12.5"
|
||||||
|
|
||||||
async-limiter@~1.0.0:
|
async-limiter@~1.0.0:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd"
|
resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd"
|
||||||
integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==
|
integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==
|
||||||
|
|
||||||
|
available-typed-arrays@^1.0.7:
|
||||||
|
version "1.0.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846"
|
||||||
|
integrity sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==
|
||||||
|
dependencies:
|
||||||
|
possible-typed-array-names "^1.0.0"
|
||||||
|
|
||||||
babel-jest@^29.7.0:
|
babel-jest@^29.7.0:
|
||||||
version "29.7.0"
|
version "29.7.0"
|
||||||
resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.7.0.tgz#f4369919225b684c56085998ac63dbd05be020d5"
|
resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.7.0.tgz#f4369919225b684c56085998ac63dbd05be020d5"
|
||||||
@@ -2359,6 +2382,11 @@ babel-preset-jest@^29.6.3:
|
|||||||
babel-plugin-jest-hoist "^29.6.3"
|
babel-plugin-jest-hoist "^29.6.3"
|
||||||
babel-preset-current-node-syntax "^1.0.0"
|
babel-preset-current-node-syntax "^1.0.0"
|
||||||
|
|
||||||
|
badgin@^1.1.5:
|
||||||
|
version "1.2.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/badgin/-/badgin-1.2.3.tgz#994b5f519827d7d5422224825b2c8faea2bc43ad"
|
||||||
|
integrity sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==
|
||||||
|
|
||||||
balanced-match@^1.0.0:
|
balanced-match@^1.0.0:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
|
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
|
||||||
@@ -2487,6 +2515,32 @@ [email protected]:
|
|||||||
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
|
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
|
||||||
integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==
|
integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==
|
||||||
|
|
||||||
|
call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
|
||||||
|
version "1.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6"
|
||||||
|
integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==
|
||||||
|
dependencies:
|
||||||
|
es-errors "^1.3.0"
|
||||||
|
function-bind "^1.1.2"
|
||||||
|
|
||||||
|
call-bind@^1.0.0, call-bind@^1.0.2, call-bind@^1.0.7, call-bind@^1.0.8:
|
||||||
|
version "1.0.9"
|
||||||
|
resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.9.tgz#39a644700c80bc7d0ca9102fc6d1d43b2fd7eee7"
|
||||||
|
integrity sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==
|
||||||
|
dependencies:
|
||||||
|
call-bind-apply-helpers "^1.0.2"
|
||||||
|
es-define-property "^1.0.1"
|
||||||
|
get-intrinsic "^1.3.0"
|
||||||
|
set-function-length "^1.2.2"
|
||||||
|
|
||||||
|
call-bound@^1.0.2, call-bound@^1.0.3, call-bound@^1.0.4:
|
||||||
|
version "1.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a"
|
||||||
|
integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==
|
||||||
|
dependencies:
|
||||||
|
call-bind-apply-helpers "^1.0.2"
|
||||||
|
get-intrinsic "^1.3.0"
|
||||||
|
|
||||||
camelcase-css@^2.0.1:
|
camelcase-css@^2.0.1:
|
||||||
version "2.0.1"
|
version "2.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/camelcase-css/-/camelcase-css-2.0.1.tgz#ee978f6947914cc30c6b44741b6ed1df7f043fd5"
|
resolved "https://registry.yarnpkg.com/camelcase-css/-/camelcase-css-2.0.1.tgz#ee978f6947914cc30c6b44741b6ed1df7f043fd5"
|
||||||
@@ -2808,11 +2862,29 @@ defaults@^1.0.3:
|
|||||||
dependencies:
|
dependencies:
|
||||||
clone "^1.0.2"
|
clone "^1.0.2"
|
||||||
|
|
||||||
|
define-data-property@^1.0.1, define-data-property@^1.1.4:
|
||||||
|
version "1.1.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e"
|
||||||
|
integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==
|
||||||
|
dependencies:
|
||||||
|
es-define-property "^1.0.0"
|
||||||
|
es-errors "^1.3.0"
|
||||||
|
gopd "^1.0.1"
|
||||||
|
|
||||||
define-lazy-prop@^2.0.0:
|
define-lazy-prop@^2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f"
|
resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f"
|
||||||
integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==
|
integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==
|
||||||
|
|
||||||
|
define-properties@^1.1.3, define-properties@^1.2.1:
|
||||||
|
version "1.2.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.1.tgz#10781cc616eb951a80a034bafcaa7377f6af2b6c"
|
||||||
|
integrity sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==
|
||||||
|
dependencies:
|
||||||
|
define-data-property "^1.0.1"
|
||||||
|
has-property-descriptors "^1.0.0"
|
||||||
|
object-keys "^1.1.1"
|
||||||
|
|
||||||
[email protected], depd@~2.0.0:
|
[email protected], depd@~2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
|
resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
|
||||||
@@ -2890,6 +2962,15 @@ dotenv@~16.4.5:
|
|||||||
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.7.tgz#0e20c5b82950140aa99be360a8a5f52335f53c26"
|
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.7.tgz#0e20c5b82950140aa99be360a8a5f52335f53c26"
|
||||||
integrity sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==
|
integrity sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==
|
||||||
|
|
||||||
|
dunder-proto@^1.0.1:
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a"
|
||||||
|
integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==
|
||||||
|
dependencies:
|
||||||
|
call-bind-apply-helpers "^1.0.1"
|
||||||
|
es-errors "^1.3.0"
|
||||||
|
gopd "^1.2.0"
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
version "1.1.1"
|
version "1.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
|
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
|
||||||
@@ -2932,11 +3013,23 @@ error-stack-parser@^2.0.6:
|
|||||||
dependencies:
|
dependencies:
|
||||||
stackframe "^1.3.4"
|
stackframe "^1.3.4"
|
||||||
|
|
||||||
|
es-define-property@^1.0.0, es-define-property@^1.0.1:
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa"
|
||||||
|
integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==
|
||||||
|
|
||||||
es-errors@^1.3.0:
|
es-errors@^1.3.0:
|
||||||
version "1.3.0"
|
version "1.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f"
|
resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f"
|
||||||
integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==
|
integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==
|
||||||
|
|
||||||
|
es-object-atoms@^1.0.0, es-object-atoms@^1.1.1:
|
||||||
|
version "1.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1"
|
||||||
|
integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==
|
||||||
|
dependencies:
|
||||||
|
es-errors "^1.3.0"
|
||||||
|
|
||||||
escalade@^3.1.1, escalade@^3.2.0:
|
escalade@^3.1.1, escalade@^3.2.0:
|
||||||
version "3.2.0"
|
version "3.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5"
|
resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5"
|
||||||
@@ -2977,6 +3070,11 @@ event-target-shim@^5.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789"
|
resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789"
|
||||||
integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==
|
integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==
|
||||||
|
|
||||||
|
expo-application@~7.0.8:
|
||||||
|
version "7.0.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/expo-application/-/expo-application-7.0.8.tgz#320af0d6c39b331456d3bc833b25763c702d23db"
|
||||||
|
integrity sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==
|
||||||
|
|
||||||
expo-asset@~12.0.13:
|
expo-asset@~12.0.13:
|
||||||
version "12.0.13"
|
version "12.0.13"
|
||||||
resolved "https://registry.yarnpkg.com/expo-asset/-/expo-asset-12.0.13.tgz#1974ed7abee2ad987a519dbdcbf7f0c647dddf5b"
|
resolved "https://registry.yarnpkg.com/expo-asset/-/expo-asset-12.0.13.tgz#1974ed7abee2ad987a519dbdcbf7f0c647dddf5b"
|
||||||
@@ -3013,6 +3111,13 @@ expo-constants@~18.0.13:
|
|||||||
"@expo/config" "~12.0.13"
|
"@expo/config" "~12.0.13"
|
||||||
"@expo/env" "~2.0.8"
|
"@expo/env" "~2.0.8"
|
||||||
|
|
||||||
|
expo-device@~8.0.10:
|
||||||
|
version "8.0.10"
|
||||||
|
resolved "https://registry.yarnpkg.com/expo-device/-/expo-device-8.0.10.tgz#88be854d6de5568392ed814b44dad0e19d1d50f8"
|
||||||
|
integrity sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA==
|
||||||
|
dependencies:
|
||||||
|
ua-parser-js "^0.7.33"
|
||||||
|
|
||||||
expo-file-system@~19.0.16, expo-file-system@~19.0.22:
|
expo-file-system@~19.0.16, expo-file-system@~19.0.22:
|
||||||
version "19.0.22"
|
version "19.0.22"
|
||||||
resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-19.0.22.tgz#8e8f892b2e89a78102b2b90fc1af5bb6bad4f21b"
|
resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-19.0.22.tgz#8e8f892b2e89a78102b2b90fc1af5bb6bad4f21b"
|
||||||
@@ -3053,6 +3158,19 @@ [email protected]:
|
|||||||
dependencies:
|
dependencies:
|
||||||
invariant "^2.2.4"
|
invariant "^2.2.4"
|
||||||
|
|
||||||
|
expo-notifications@~0.32.17:
|
||||||
|
version "0.32.17"
|
||||||
|
resolved "https://registry.yarnpkg.com/expo-notifications/-/expo-notifications-0.32.17.tgz#7c9786f167da39d504edc450a84bcb5489c1a54e"
|
||||||
|
integrity sha512-lwwzn7tImuzTzn9PAglZlS2VfZEvsfFGJTK9Eb8I4cqkGh2DI23YJFJH+WPEIu4QhDvk5JeBjklenJ8IZbmA4A==
|
||||||
|
dependencies:
|
||||||
|
"@expo/image-utils" "^0.8.8"
|
||||||
|
"@ide/backoff" "^1.0.0"
|
||||||
|
abort-controller "^3.0.0"
|
||||||
|
assert "^2.0.0"
|
||||||
|
badgin "^1.1.5"
|
||||||
|
expo-application "~7.0.8"
|
||||||
|
expo-constants "~18.0.13"
|
||||||
|
|
||||||
expo-secure-store@~15.0.8:
|
expo-secure-store@~15.0.8:
|
||||||
version "15.0.8"
|
version "15.0.8"
|
||||||
resolved "https://registry.yarnpkg.com/expo-secure-store/-/expo-secure-store-15.0.8.tgz#678065599bb76061b5a85b15b9426bf7a11089ae"
|
resolved "https://registry.yarnpkg.com/expo-secure-store/-/expo-secure-store-15.0.8.tgz#678065599bb76061b5a85b15b9426bf7a11089ae"
|
||||||
@@ -3236,6 +3354,13 @@ fontfaceobserver@^2.1.0:
|
|||||||
resolved "https://registry.yarnpkg.com/fontfaceobserver/-/fontfaceobserver-2.3.0.tgz#5fb392116e75d5024b7ec8e4f2ce92106d1488c8"
|
resolved "https://registry.yarnpkg.com/fontfaceobserver/-/fontfaceobserver-2.3.0.tgz#5fb392116e75d5024b7ec8e4f2ce92106d1488c8"
|
||||||
integrity sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==
|
integrity sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==
|
||||||
|
|
||||||
|
for-each@^0.3.5:
|
||||||
|
version "0.3.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.5.tgz#d650688027826920feeb0af747ee7b9421a41d47"
|
||||||
|
integrity sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==
|
||||||
|
dependencies:
|
||||||
|
is-callable "^1.2.7"
|
||||||
|
|
||||||
freeport-async@^2.0.0:
|
freeport-async@^2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/freeport-async/-/freeport-async-2.0.0.tgz#6adf2ec0c629d11abff92836acd04b399135bab4"
|
resolved "https://registry.yarnpkg.com/freeport-async/-/freeport-async-2.0.0.tgz#6adf2ec0c629d11abff92836acd04b399135bab4"
|
||||||
@@ -3261,6 +3386,11 @@ function-bind@^1.1.2:
|
|||||||
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c"
|
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c"
|
||||||
integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
|
integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
|
||||||
|
|
||||||
|
generator-function@^2.0.0:
|
||||||
|
version "2.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/generator-function/-/generator-function-2.0.1.tgz#0e75dd410d1243687a0ba2e951b94eedb8f737a2"
|
||||||
|
integrity sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==
|
||||||
|
|
||||||
gensync@^1.0.0-beta.2:
|
gensync@^1.0.0-beta.2:
|
||||||
version "1.0.0-beta.2"
|
version "1.0.0-beta.2"
|
||||||
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
|
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
|
||||||
@@ -3271,11 +3401,35 @@ get-caller-file@^2.0.5:
|
|||||||
resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
|
resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
|
||||||
integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
|
integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
|
||||||
|
|
||||||
|
get-intrinsic@^1.2.4, get-intrinsic@^1.3.0:
|
||||||
|
version "1.3.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01"
|
||||||
|
integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==
|
||||||
|
dependencies:
|
||||||
|
call-bind-apply-helpers "^1.0.2"
|
||||||
|
es-define-property "^1.0.1"
|
||||||
|
es-errors "^1.3.0"
|
||||||
|
es-object-atoms "^1.1.1"
|
||||||
|
function-bind "^1.1.2"
|
||||||
|
get-proto "^1.0.1"
|
||||||
|
gopd "^1.2.0"
|
||||||
|
has-symbols "^1.1.0"
|
||||||
|
hasown "^2.0.2"
|
||||||
|
math-intrinsics "^1.1.0"
|
||||||
|
|
||||||
get-package-type@^0.1.0:
|
get-package-type@^0.1.0:
|
||||||
version "0.1.0"
|
version "0.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a"
|
resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a"
|
||||||
integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==
|
integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==
|
||||||
|
|
||||||
|
get-proto@^1.0.1:
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1"
|
||||||
|
integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==
|
||||||
|
dependencies:
|
||||||
|
dunder-proto "^1.0.1"
|
||||||
|
es-object-atoms "^1.0.0"
|
||||||
|
|
||||||
getenv@^2.0.0:
|
getenv@^2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/getenv/-/getenv-2.0.0.tgz#b1698c7b0f29588f4577d06c42c73a5b475c69e0"
|
resolved "https://registry.yarnpkg.com/getenv/-/getenv-2.0.0.tgz#b1698c7b0f29588f4577d06c42c73a5b475c69e0"
|
||||||
@@ -3316,6 +3470,11 @@ glob@^7.1.1, glob@^7.1.3, glob@^7.1.4:
|
|||||||
once "^1.3.0"
|
once "^1.3.0"
|
||||||
path-is-absolute "^1.0.0"
|
path-is-absolute "^1.0.0"
|
||||||
|
|
||||||
|
gopd@^1.0.1, gopd@^1.2.0:
|
||||||
|
version "1.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1"
|
||||||
|
integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==
|
||||||
|
|
||||||
graceful-fs@^4.2.4, graceful-fs@^4.2.9:
|
graceful-fs@^4.2.4, graceful-fs@^4.2.9:
|
||||||
version "4.2.11"
|
version "4.2.11"
|
||||||
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
|
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
|
||||||
@@ -3331,6 +3490,25 @@ has-flag@^4.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
|
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
|
||||||
integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
|
integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
|
||||||
|
|
||||||
|
has-property-descriptors@^1.0.0, has-property-descriptors@^1.0.2:
|
||||||
|
version "1.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854"
|
||||||
|
integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==
|
||||||
|
dependencies:
|
||||||
|
es-define-property "^1.0.0"
|
||||||
|
|
||||||
|
has-symbols@^1.0.3, has-symbols@^1.1.0:
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338"
|
||||||
|
integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==
|
||||||
|
|
||||||
|
has-tostringtag@^1.0.2:
|
||||||
|
version "1.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc"
|
||||||
|
integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==
|
||||||
|
dependencies:
|
||||||
|
has-symbols "^1.0.3"
|
||||||
|
|
||||||
hasown@^2.0.2:
|
hasown@^2.0.2:
|
||||||
version "2.0.3"
|
version "2.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.3.tgz#5e5c2b15b60370a4c7930c383dfb76bf17bc403c"
|
resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.3.tgz#5e5c2b15b60370a4c7930c383dfb76bf17bc403c"
|
||||||
@@ -3447,7 +3625,7 @@ inflight@^1.0.4:
|
|||||||
once "^1.3.0"
|
once "^1.3.0"
|
||||||
wrappy "1"
|
wrappy "1"
|
||||||
|
|
||||||
inherits@2, inherits@~2.0.3, inherits@~2.0.4:
|
inherits@2, inherits@^2.0.3, inherits@~2.0.3, inherits@~2.0.4:
|
||||||
version "2.0.4"
|
version "2.0.4"
|
||||||
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
|
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
|
||||||
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
||||||
@@ -3464,6 +3642,14 @@ invariant@^2.2.4:
|
|||||||
dependencies:
|
dependencies:
|
||||||
loose-envify "^1.0.0"
|
loose-envify "^1.0.0"
|
||||||
|
|
||||||
|
is-arguments@^1.0.4:
|
||||||
|
version "1.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.2.0.tgz#ad58c6aecf563b78ef2bf04df540da8f5d7d8e1b"
|
||||||
|
integrity sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==
|
||||||
|
dependencies:
|
||||||
|
call-bound "^1.0.2"
|
||||||
|
has-tostringtag "^1.0.2"
|
||||||
|
|
||||||
is-arrayish@^0.3.1:
|
is-arrayish@^0.3.1:
|
||||||
version "0.3.4"
|
version "0.3.4"
|
||||||
resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.4.tgz#1ee5553818511915685d33bb13d31bf854e5059d"
|
resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.4.tgz#1ee5553818511915685d33bb13d31bf854e5059d"
|
||||||
@@ -3476,6 +3662,11 @@ is-binary-path@~2.1.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
binary-extensions "^2.0.0"
|
binary-extensions "^2.0.0"
|
||||||
|
|
||||||
|
is-callable@^1.2.7:
|
||||||
|
version "1.2.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055"
|
||||||
|
integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==
|
||||||
|
|
||||||
is-core-module@^2.16.1:
|
is-core-module@^2.16.1:
|
||||||
version "2.16.1"
|
version "2.16.1"
|
||||||
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4"
|
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4"
|
||||||
@@ -3498,6 +3689,17 @@ is-fullwidth-code-point@^3.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
|
resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
|
||||||
integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
|
integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
|
||||||
|
|
||||||
|
is-generator-function@^1.0.7:
|
||||||
|
version "1.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.1.2.tgz#ae3b61e3d5ea4e4839b90bad22b02335051a17d5"
|
||||||
|
integrity sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==
|
||||||
|
dependencies:
|
||||||
|
call-bound "^1.0.4"
|
||||||
|
generator-function "^2.0.0"
|
||||||
|
get-proto "^1.0.1"
|
||||||
|
has-tostringtag "^1.0.2"
|
||||||
|
safe-regex-test "^1.1.0"
|
||||||
|
|
||||||
is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1:
|
is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1:
|
||||||
version "4.0.3"
|
version "4.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
|
resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
|
||||||
@@ -3505,6 +3707,14 @@ is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-extglob "^2.1.1"
|
is-extglob "^2.1.1"
|
||||||
|
|
||||||
|
is-nan@^1.3.2:
|
||||||
|
version "1.3.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/is-nan/-/is-nan-1.3.2.tgz#043a54adea31748b55b6cd4e09aadafa69bd9e1d"
|
||||||
|
integrity sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==
|
||||||
|
dependencies:
|
||||||
|
call-bind "^1.0.0"
|
||||||
|
define-properties "^1.1.3"
|
||||||
|
|
||||||
is-number@^7.0.0:
|
is-number@^7.0.0:
|
||||||
version "7.0.0"
|
version "7.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
|
resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
|
||||||
@@ -3515,6 +3725,23 @@ is-plain-obj@^2.1.0:
|
|||||||
resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287"
|
resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287"
|
||||||
integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==
|
integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==
|
||||||
|
|
||||||
|
is-regex@^1.2.1:
|
||||||
|
version "1.2.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.2.1.tgz#76d70a3ed10ef9be48eb577887d74205bf0cad22"
|
||||||
|
integrity sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==
|
||||||
|
dependencies:
|
||||||
|
call-bound "^1.0.2"
|
||||||
|
gopd "^1.2.0"
|
||||||
|
has-tostringtag "^1.0.2"
|
||||||
|
hasown "^2.0.2"
|
||||||
|
|
||||||
|
is-typed-array@^1.1.3:
|
||||||
|
version "1.1.15"
|
||||||
|
resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.15.tgz#4bfb4a45b61cee83a5a46fba778e4e8d59c0ce0b"
|
||||||
|
integrity sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==
|
||||||
|
dependencies:
|
||||||
|
which-typed-array "^1.1.16"
|
||||||
|
|
||||||
is-wsl@^2.1.1, is-wsl@^2.2.0:
|
is-wsl@^2.1.1, is-wsl@^2.2.0:
|
||||||
version "2.2.0"
|
version "2.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271"
|
resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271"
|
||||||
@@ -3942,6 +4169,11 @@ marky@^1.2.2:
|
|||||||
resolved "https://registry.yarnpkg.com/marky/-/marky-1.3.0.tgz#422b63b0baf65022f02eda61a238eccdbbc14997"
|
resolved "https://registry.yarnpkg.com/marky/-/marky-1.3.0.tgz#422b63b0baf65022f02eda61a238eccdbbc14997"
|
||||||
integrity sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==
|
integrity sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==
|
||||||
|
|
||||||
|
math-intrinsics@^1.1.0:
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
|
||||||
|
integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
version "2.0.14"
|
version "2.0.14"
|
||||||
resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50"
|
resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50"
|
||||||
@@ -4558,6 +4790,31 @@ object-hash@^3.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-3.0.0.tgz#73f97f753e7baffc0e2cc9d6e079079744ac82e9"
|
resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-3.0.0.tgz#73f97f753e7baffc0e2cc9d6e079079744ac82e9"
|
||||||
integrity sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==
|
integrity sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==
|
||||||
|
|
||||||
|
object-is@^1.1.5:
|
||||||
|
version "1.1.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.6.tgz#1a6a53aed2dd8f7e6775ff870bea58545956ab07"
|
||||||
|
integrity sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==
|
||||||
|
dependencies:
|
||||||
|
call-bind "^1.0.7"
|
||||||
|
define-properties "^1.2.1"
|
||||||
|
|
||||||
|
object-keys@^1.1.1:
|
||||||
|
version "1.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e"
|
||||||
|
integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==
|
||||||
|
|
||||||
|
object.assign@^4.1.4:
|
||||||
|
version "4.1.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.7.tgz#8c14ca1a424c6a561b0bb2a22f66f5049a945d3d"
|
||||||
|
integrity sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==
|
||||||
|
dependencies:
|
||||||
|
call-bind "^1.0.8"
|
||||||
|
call-bound "^1.0.3"
|
||||||
|
define-properties "^1.2.1"
|
||||||
|
es-object-atoms "^1.0.0"
|
||||||
|
has-symbols "^1.1.0"
|
||||||
|
object-keys "^1.1.1"
|
||||||
|
|
||||||
on-finished@~2.3.0:
|
on-finished@~2.3.0:
|
||||||
version "2.3.0"
|
version "2.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
|
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
|
||||||
@@ -4725,6 +4982,11 @@ pngjs@^3.3.0:
|
|||||||
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-3.4.0.tgz#99ca7d725965fb655814eaf65f38f12bbdbf555f"
|
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-3.4.0.tgz#99ca7d725965fb655814eaf65f38f12bbdbf555f"
|
||||||
integrity sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==
|
integrity sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==
|
||||||
|
|
||||||
|
possible-typed-array-names@^1.0.0:
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz#93e3582bc0e5426586d9d07b79ee40fc841de4ae"
|
||||||
|
integrity sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==
|
||||||
|
|
||||||
postcss-import@^15.1.0:
|
postcss-import@^15.1.0:
|
||||||
version "15.1.0"
|
version "15.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-15.1.0.tgz#41c64ed8cc0e23735a9698b3249ffdbf704adc70"
|
resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-15.1.0.tgz#41c64ed8cc0e23735a9698b3249ffdbf704adc70"
|
||||||
@@ -5180,6 +5442,15 @@ [email protected], safe-buffer@>=5.1.0:
|
|||||||
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
|
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
|
||||||
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
|
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
|
||||||
|
|
||||||
|
safe-regex-test@^1.1.0:
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz#7f87dfb67a3150782eaaf18583ff5d1711ac10c1"
|
||||||
|
integrity sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==
|
||||||
|
dependencies:
|
||||||
|
call-bound "^1.0.2"
|
||||||
|
es-errors "^1.3.0"
|
||||||
|
is-regex "^1.2.1"
|
||||||
|
|
||||||
sax@>=0.6.0:
|
sax@>=0.6.0:
|
||||||
version "1.6.0"
|
version "1.6.0"
|
||||||
resolved "https://registry.yarnpkg.com/sax/-/sax-1.6.0.tgz#da59637629307b97e7c4cb28e080a7bc38560d5b"
|
resolved "https://registry.yarnpkg.com/sax/-/sax-1.6.0.tgz#da59637629307b97e7c4cb28e080a7bc38560d5b"
|
||||||
@@ -5239,6 +5510,18 @@ serve-static@^1.16.2:
|
|||||||
parseurl "~1.3.3"
|
parseurl "~1.3.3"
|
||||||
send "~0.19.1"
|
send "~0.19.1"
|
||||||
|
|
||||||
|
set-function-length@^1.2.2:
|
||||||
|
version "1.2.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449"
|
||||||
|
integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==
|
||||||
|
dependencies:
|
||||||
|
define-data-property "^1.1.4"
|
||||||
|
es-errors "^1.3.0"
|
||||||
|
function-bind "^1.1.2"
|
||||||
|
get-intrinsic "^1.2.4"
|
||||||
|
gopd "^1.0.1"
|
||||||
|
has-property-descriptors "^1.0.2"
|
||||||
|
|
||||||
setprototypeof@~1.2.0:
|
setprototypeof@~1.2.0:
|
||||||
version "1.2.0"
|
version "1.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424"
|
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424"
|
||||||
@@ -5604,6 +5887,11 @@ typescript@~5.9.0:
|
|||||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f"
|
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f"
|
||||||
integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
|
integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
|
||||||
|
|
||||||
|
ua-parser-js@^0.7.33:
|
||||||
|
version "0.7.41"
|
||||||
|
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.41.tgz#9f6dee58c389e8afababa62a4a2dc22edb69a452"
|
||||||
|
integrity sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==
|
||||||
|
|
||||||
undici-types@~7.19.0:
|
undici-types@~7.19.0:
|
||||||
version "7.19.2"
|
version "7.19.2"
|
||||||
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.19.2.tgz#1b67fc26d0f157a0cba3a58a5b5c1e2276b8ba2a"
|
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.19.2.tgz#1b67fc26d0f157a0cba3a58a5b5c1e2276b8ba2a"
|
||||||
@@ -5665,6 +5953,17 @@ util-deprecate@^1.0.2:
|
|||||||
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
|
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
|
||||||
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
|
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
|
||||||
|
|
||||||
|
util@^0.12.5:
|
||||||
|
version "0.12.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/util/-/util-0.12.5.tgz#5f17a6059b73db61a875668781a1c2b136bd6fbc"
|
||||||
|
integrity sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==
|
||||||
|
dependencies:
|
||||||
|
inherits "^2.0.3"
|
||||||
|
is-arguments "^1.0.4"
|
||||||
|
is-generator-function "^1.0.7"
|
||||||
|
is-typed-array "^1.1.3"
|
||||||
|
which-typed-array "^1.1.2"
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
|
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
|
||||||
@@ -5747,6 +6046,19 @@ [email protected]:
|
|||||||
punycode "^2.1.1"
|
punycode "^2.1.1"
|
||||||
webidl-conversions "^5.0.0"
|
webidl-conversions "^5.0.0"
|
||||||
|
|
||||||
|
which-typed-array@^1.1.16, which-typed-array@^1.1.2:
|
||||||
|
version "1.1.20"
|
||||||
|
resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.20.tgz#3fdb7adfafe0ea69157b1509f3a1cd892bd1d122"
|
||||||
|
integrity sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==
|
||||||
|
dependencies:
|
||||||
|
available-typed-arrays "^1.0.7"
|
||||||
|
call-bind "^1.0.8"
|
||||||
|
call-bound "^1.0.4"
|
||||||
|
for-each "^0.3.5"
|
||||||
|
get-proto "^1.0.1"
|
||||||
|
gopd "^1.2.0"
|
||||||
|
has-tostringtag "^1.0.2"
|
||||||
|
|
||||||
which@^2.0.1:
|
which@^2.0.1:
|
||||||
version "2.0.2"
|
version "2.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"
|
resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"
|
||||||
|
|||||||
Reference in New Issue
Block a user