From bf0147d5420e7fb8d7c6323709ddcca9eafa0980 Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Tue, 24 Mar 2026 19:48:02 -0700 Subject: [PATCH] refactor: reference human id instead of email (#73) * refactor: update api and client to reference humanIds * fix: prevent deletion of network member This may cause various side effects if there is data in other services which reference this member --- go/cmd/orion/main.go | 7 + go/internal/auth/service.go | 40 ++- go/internal/handler/handler.go | 316 ++++++++++++++---- go/internal/human/service.go | 10 + go/internal/middleware/auth.go | 21 +- go/internal/network/models.go | 10 +- go/internal/network/repository.go | 170 +++++----- go/internal/network/service.go | 141 ++++---- go/internal/network/service_test.go | 99 +++++- go/internal/particle/service.go | 45 +-- go/internal/particle/service_test.go | 52 --- go/migrations/000002_networks.up.sql | 6 +- .../000008_network_invitations.down.sql | 1 + .../000008_network_invitations.up.sql | 6 + js/src/api/types.ts | 15 +- js/src/features/compose/compose-overlay.tsx | 18 +- .../compose/configure-stream-step.tsx | 24 +- js/src/features/layout.tsx | 2 +- .../features/particles/autoplay-overlay.tsx | 9 +- .../particles/fallback-particle-view.tsx | 2 +- .../features/particles/particle-list-view.tsx | 37 +- js/src/features/particles/stream-view.tsx | 33 +- js/src/hooks/use-create-particle.ts | 8 +- js/src/lib/firestore-particles.ts | 10 +- 24 files changed, 666 insertions(+), 416 deletions(-) create mode 100644 go/migrations/000008_network_invitations.down.sql create mode 100644 go/migrations/000008_network_invitations.up.sql diff --git a/go/cmd/orion/main.go b/go/cmd/orion/main.go index 5d30d9a..ca12f9f 100644 --- a/go/cmd/orion/main.go +++ b/go/cmd/orion/main.go @@ -111,6 +111,13 @@ func main() { mux.Handle("GET /networks", withAuth(h.ListNetworks)) mux.Handle("GET /networks/{id}", withAuth(h.GetNetwork)) mux.Handle("POST /networks/{id}/members", withAuth(h.AddMembersToNetwork)) + // mux.Handle("DELETE /networks/{id}/members/{humanId}", withAuth(h.RemoveMemberFromNetwork)) + + // Network Invitations + mux.Handle("GET /networks/{id}/invitations", withAuth(h.ListInvitationsForNetwork)) + mux.Handle("DELETE /networks/{id}/invitations", withAuth(h.RevokeInvitation)) + mux.Handle("GET /invitations", withAuth(h.ListMyInvitations)) + mux.Handle("POST /invitations/accept", withAuth(h.AcceptInvitation)) // Particles mux.Handle("GET /particles/{id}/download", withAuth(h.DownloadParticleMedia)) diff --git a/go/internal/auth/service.go b/go/internal/auth/service.go index 3a1a72e..0d6a85b 100644 --- a/go/internal/auth/service.go +++ b/go/internal/auth/service.go @@ -2,6 +2,7 @@ package auth import ( "context" + "encoding/json" "errors" "fmt" "log/slog" @@ -37,14 +38,20 @@ func newSessionToken() (sessionToken, error) { return typeid.New[sessionToken]() } +type Session struct { + Email string `json:"email"` + HumanId string `json:"human_id"` +} + type AuthService interface { // RequestSignInCode generates a code and emails it to the provided email. // To retrieve a session, client must verify with VerifySignInCode. RequestSignInCode(ctx context.Context, email string) error - // VerifySignInCode returns ErrInvalidCode if incorrect code - VerifySignInCode(ctx context.Context, email, code string) (sessionToken string, err error) + // VerifySignInCode returns ErrInvalidCode if incorrect code, otherwise creates a session. + // humanId is stored in the session alongside the email. + VerifySignInCode(ctx context.Context, email, code, humanId string) (sessionToken string, err error) // GetSession returns ErrSessionNotFound if no valid session - GetSession(ctx context.Context, sessionToken string) (email string, err error) + GetSession(ctx context.Context, sessionToken string) (*Session, error) // ExtendSession returns ErrSessionNotFound if no valid session ExtendSession(ctx context.Context, sessionToken string) error SignOut(ctx context.Context, sessionToken string) error @@ -111,7 +118,7 @@ func (a *authServiceImpl) RequestSignInCode(ctx context.Context, email string) e return nil } -func (a *authServiceImpl) VerifySignInCode(ctx context.Context, email, code string) (string, error) { +func (a *authServiceImpl) VerifySignInCode(ctx context.Context, email, code, humanId string) (string, error) { formattedEmail, err := utils.NormalizeEmail(email) if err != nil { return "", fmt.Errorf("invalid email: %w", err) @@ -134,7 +141,7 @@ func (a *authServiceImpl) VerifySignInCode(ctx context.Context, email, code stri slog.Error("error deleting code from redis", "error", err) } - token, err := a.createSession(ctx, formattedEmail) + token, err := a.createSession(ctx, formattedEmail, humanId) if err != nil { return "", err } @@ -142,16 +149,19 @@ func (a *authServiceImpl) VerifySignInCode(ctx context.Context, email, code stri return token, nil } -func (a *authServiceImpl) GetSession(ctx context.Context, token string) (string, error) { - email, err := a.redisClient.Get(ctx, token).Result() +func (a *authServiceImpl) GetSession(ctx context.Context, token string) (*Session, error) { + sessionInfo, err := a.redisClient.Get(ctx, token).Result() if err != nil { if errors.Is(err, redis.Nil) { - return "", ErrSessionNotFound + return nil, ErrSessionNotFound } - return "", fmt.Errorf("error getting session: %w", err) + return nil, fmt.Errorf("error getting session: %w", err) } - return email, nil + var session Session + err = json.Unmarshal([]byte(sessionInfo), &session) + + return &session, nil } func (a *authServiceImpl) ExtendSession(ctx context.Context, token string) error { @@ -180,7 +190,7 @@ func (a *authServiceImpl) SignOut(ctx context.Context, token string) error { return nil } -func (a *authServiceImpl) createSession(ctx context.Context, email string) (string, error) { +func (a *authServiceImpl) createSession(ctx context.Context, email, humanId string) (string, error) { formattedEmail, err := utils.NormalizeEmail(email) if err != nil { return "", fmt.Errorf("invalid email: %w", err) @@ -191,7 +201,13 @@ func (a *authServiceImpl) createSession(ctx context.Context, email string) (stri return "", fmt.Errorf("error generating session token: %w", err) } - if err := a.redisClient.Set(ctx, token.String(), formattedEmail, sessionExpiry).Err(); err != nil { + session := Session{Email: formattedEmail, HumanId: humanId} + data, err := json.Marshal(session) + if err != nil { + return "", fmt.Errorf("error marshaling session: %w", err) + } + + if err := a.redisClient.Set(ctx, token.String(), data, sessionExpiry).Err(); err != nil { return "", fmt.Errorf("error storing session: %w", err) } diff --git a/go/internal/handler/handler.go b/go/internal/handler/handler.go index 65f6dcb..cbb15a0 100644 --- a/go/internal/handler/handler.go +++ b/go/internal/handler/handler.go @@ -6,7 +6,6 @@ import ( "errors" "log/slog" "net/http" - "strings" "time" "github.com/flowy-live/llink/internal/auth" @@ -39,12 +38,10 @@ func NewHandler(authSvc auth.AuthService, humanSvc human.Service, networkSvc net // Response DTOs type Human struct { - // Id will be nil if this human is not registered - Id *string `json:"id"` + Id string `json:"id"` Email string `json:"email"` EmailPrefix string `json:"email_prefix"` - // CreatedAt will be nil if this human is not registered - CreatedAt *time.Time `json:"created_at"` + CreatedAt time.Time `json:"created_at"` } type Network struct { @@ -89,6 +86,20 @@ type MembersRequest struct { Emails []string `json:"emails"` } +type Invitation struct { + NetworkId string `json:"network_id"` + Email string `json:"email"` + CreatedAt time.Time `json:"created_at"` +} + +type AcceptInvitationRequest struct { + NetworkId string `json:"network_id"` +} + +type RevokeInvitationRequest struct { + Email string `json:"email"` +} + // Depot DTOs type PrepareUploadRequest struct { @@ -162,7 +173,19 @@ func (h *Handler) SignIn(w http.ResponseWriter, r *http.Request) { return } - token, err := h.authSvc.VerifySignInCode(r.Context(), req.Email, req.Code) + // Look up human first so we can store humanId in the session + hum, err := h.humanSvc.GetByEmail(r.Context(), req.Email) + if err != nil { + if errors.Is(err, human.ErrNotFound) { + http.Error(w, "human not found", http.StatusNotFound) + return + } + slog.Error("failed to get human for sign-in", "error", err, "email", req.Email) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + token, err := h.authSvc.VerifySignInCode(r.Context(), req.Email, req.Code, hum.ID) if err != nil { if errors.Is(err, auth.ErrInvalidCode) { http.Error(w, "invalid code", http.StatusUnauthorized) @@ -173,14 +196,6 @@ func (h *Handler) SignIn(w http.ResponseWriter, r *http.Request) { return } - // Get the human - hum, err := h.humanSvc.GetByEmail(r.Context(), req.Email) - if err != nil { - slog.Error("failed to get human after sign-in", "error", err, "email", req.Email) - http.Error(w, "internal server error", http.StatusInternalServerError) - return - } - resp := SignInResponse{ Human: humanToDTO(hum), Token: token, @@ -237,7 +252,7 @@ func (h *Handler) GetCurrentHuman(w http.ResponseWriter, r *http.Request) { // CreateNetwork creates a new network func (h *Handler) CreateNetwork(w http.ResponseWriter, r *http.Request) { - email, ok := middleware.EmailFromContext(r.Context()) + humanId, ok := middleware.HumanIdFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return @@ -249,13 +264,13 @@ func (h *Handler) CreateNetwork(w http.ResponseWriter, r *http.Request) { return } - net, err := h.networkSvc.Create(r.Context(), req.Name, email) + net, err := h.networkSvc.Create(r.Context(), req.Name, humanId) if err != nil { if errors.Is(err, network.ErrInvalidName) { http.Error(w, "name cannot be empty", http.StatusBadRequest) return } - slog.Error("failed to create network", "error", err, "email", email, "name", req.Name) + slog.Error("failed to create network", "error", err, "humanId", humanId, "name", req.Name) http.Error(w, "internal server error", http.StatusInternalServerError) return } @@ -274,15 +289,15 @@ func (h *Handler) CreateNetwork(w http.ResponseWriter, r *http.Request) { // ListNetworks retrieves networks for the authenticated human func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) { - email, ok := middleware.EmailFromContext(r.Context()) + humanId, ok := middleware.HumanIdFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } - networks, err := h.networkSvc.ListForEmail(r.Context(), email) + networks, err := h.networkSvc.ListForHuman(r.Context(), humanId) if err != nil { - slog.Error("failed to list networks", "error", err, "email", email) + slog.Error("failed to list networks", "error", err, "humanId", humanId) http.Error(w, "internal server error", http.StatusInternalServerError) return } @@ -303,7 +318,7 @@ func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) { // GetNetwork retrieves a specific network func (h *Handler) GetNetwork(w http.ResponseWriter, r *http.Request) { - email, ok := middleware.EmailFromContext(r.Context()) + humanId, ok := middleware.HumanIdFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return @@ -315,10 +330,9 @@ func (h *Handler) GetNetwork(w http.ResponseWriter, r *http.Request) { return } - // Check membership - isMember, err := h.networkSvc.IsMember(r.Context(), networkID, email) + isMember, err := h.networkSvc.IsMember(r.Context(), networkID, humanId) if err != nil { - slog.Error("failed to check network membership", "error", err, "network_id", networkID, "email", email) + slog.Error("failed to check network membership", "error", err, "network_id", networkID, "humanId", humanId) http.Error(w, "internal server error", http.StatusInternalServerError) return } @@ -349,9 +363,10 @@ func (h *Handler) GetNetwork(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(resp) } -// AddMembersToNetwork adds members to a network +// AddMembersToNetwork adds members to a network. Registered users are added as members, +// unregistered users receive email invitations. func (h *Handler) AddMembersToNetwork(w http.ResponseWriter, r *http.Request) { - email, ok := middleware.EmailFromContext(r.Context()) + humanId, ok := middleware.HumanIdFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return @@ -363,10 +378,9 @@ func (h *Handler) AddMembersToNetwork(w http.ResponseWriter, r *http.Request) { return } - // Check membership - isMember, err := h.networkSvc.IsMember(r.Context(), networkID, email) + isMember, err := h.networkSvc.IsMember(r.Context(), networkID, humanId) if err != nil { - slog.Error("failed to check network membership", "error", err, "network_id", networkID, "email", email) + slog.Error("failed to check network membership", "error", err, "network_id", networkID, "humanId", humanId) http.Error(w, "internal server error", http.StatusInternalServerError) return } @@ -386,10 +400,42 @@ func (h *Handler) AddMembersToNetwork(w http.ResponseWriter, r *http.Request) { return } - if err := h.networkSvc.AddMembers(r.Context(), networkID, req.EmailAddresses); err != nil { - slog.Error("failed to add members to network", "error", err, "network_id", networkID) - http.Error(w, "internal server error", http.StatusInternalServerError) - return + // Resolve emails: registered users become members, unregistered get invitations + var memberHumanIds []string + var inviteEmails []string + for _, email := range req.EmailAddresses { + normalized, err := utils.NormalizeEmail(email) + if err != nil { + http.Error(w, "invalid email: "+email, http.StatusBadRequest) + return + } + hum, err := h.humanSvc.GetByEmail(r.Context(), normalized) + if err != nil { + if errors.Is(err, human.ErrNotFound) { + inviteEmails = append(inviteEmails, normalized) + continue + } + slog.Error("failed to look up human by email", "error", err, "email", normalized) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + memberHumanIds = append(memberHumanIds, hum.ID) + } + + if len(memberHumanIds) > 0 { + if err := h.networkSvc.AddMembers(r.Context(), networkID, memberHumanIds); err != nil { + slog.Error("failed to add members to network", "error", err, "network_id", networkID) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + } + + if len(inviteEmails) > 0 { + if err := h.networkSvc.InviteByEmail(r.Context(), networkID, inviteEmails); err != nil { + slog.Error("failed to invite members to network", "error", err, "network_id", networkID) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } } // Return updated network @@ -413,23 +459,22 @@ func (h *Handler) AddMembersToNetwork(w http.ResponseWriter, r *http.Request) { // RemoveMemberFromNetwork removes a member from a network func (h *Handler) RemoveMemberFromNetwork(w http.ResponseWriter, r *http.Request) { - email, ok := middleware.EmailFromContext(r.Context()) + humanId, ok := middleware.HumanIdFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } networkID := r.PathValue("id") - memberEmail := r.PathValue("email") - if networkID == "" || memberEmail == "" { - http.Error(w, "network id and member email are required", http.StatusBadRequest) + memberHumanId := r.PathValue("humanId") + if networkID == "" || memberHumanId == "" { + http.Error(w, "network id and member humanId are required", http.StatusBadRequest) return } - // Check membership - isMember, err := h.networkSvc.IsMember(r.Context(), networkID, email) + isMember, err := h.networkSvc.IsMember(r.Context(), networkID, humanId) if err != nil { - slog.Error("failed to check network membership", "error", err, "network_id", networkID, "email", email) + slog.Error("failed to check network membership", "error", err, "network_id", networkID, "humanId", humanId) http.Error(w, "internal server error", http.StatusInternalServerError) return } @@ -438,8 +483,159 @@ func (h *Handler) RemoveMemberFromNetwork(w http.ResponseWriter, r *http.Request return } - if err := h.networkSvc.RemoveMember(r.Context(), networkID, memberEmail); err != nil { - slog.Error("failed to remove member from network", "error", err, "network_id", networkID, "member_email", memberEmail) + if err := h.networkSvc.RemoveMember(r.Context(), networkID, memberHumanId); err != nil { + slog.Error("failed to remove member from network", "error", err, "network_id", networkID, "memberHumanId", memberHumanId) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// ListInvitationsForNetwork returns pending invitations for a network +func (h *Handler) ListInvitationsForNetwork(w http.ResponseWriter, r *http.Request) { + humanId, ok := middleware.HumanIdFromContext(r.Context()) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + networkID := r.PathValue("id") + if networkID == "" { + http.Error(w, "network id is required", http.StatusBadRequest) + return + } + + isMember, err := h.networkSvc.IsMember(r.Context(), networkID, humanId) + if err != nil { + slog.Error("failed to check network membership", "error", err, "network_id", networkID, "humanId", humanId) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if !isMember { + http.Error(w, "access denied", http.StatusForbidden) + return + } + + invitations, err := h.networkSvc.ListInvitationsForNetwork(r.Context(), networkID) + if err != nil { + slog.Error("failed to list invitations", "error", err, "network_id", networkID) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + resp := make([]Invitation, 0, len(invitations)) + for _, inv := range invitations { + resp = append(resp, Invitation{ + NetworkId: inv.NetworkID, + Email: inv.Email, + CreatedAt: inv.CreatedAt, + }) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +// ListMyInvitations returns pending invitations for the authenticated user +func (h *Handler) ListMyInvitations(w http.ResponseWriter, r *http.Request) { + email, ok := middleware.EmailFromContext(r.Context()) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + invitations, err := h.networkSvc.ListInvitationsForEmail(r.Context(), email) + if err != nil { + slog.Error("failed to list invitations for email", "error", err, "email", email) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + resp := make([]Invitation, 0, len(invitations)) + for _, inv := range invitations { + resp = append(resp, Invitation{ + NetworkId: inv.NetworkID, + Email: inv.Email, + CreatedAt: inv.CreatedAt, + }) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +// AcceptInvitation accepts a pending network invitation for the authenticated user +func (h *Handler) AcceptInvitation(w http.ResponseWriter, r *http.Request) { + email, ok := middleware.EmailFromContext(r.Context()) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + humanId, ok := middleware.HumanIdFromContext(r.Context()) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + var req AcceptInvitationRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + if req.NetworkId == "" { + http.Error(w, "network_id is required", http.StatusBadRequest) + return + } + + if err := h.networkSvc.AcceptInvitation(r.Context(), req.NetworkId, email, humanId); err != nil { + slog.Error("failed to accept invitation", "error", err, "network_id", req.NetworkId, "email", email) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// RevokeInvitation revokes a pending invitation from a network +func (h *Handler) RevokeInvitation(w http.ResponseWriter, r *http.Request) { + humanId, ok := middleware.HumanIdFromContext(r.Context()) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + networkID := r.PathValue("id") + if networkID == "" { + http.Error(w, "network id is required", http.StatusBadRequest) + return + } + + isMember, err := h.networkSvc.IsMember(r.Context(), networkID, humanId) + if err != nil { + slog.Error("failed to check network membership", "error", err, "network_id", networkID, "humanId", humanId) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if !isMember { + http.Error(w, "access denied", http.StatusForbidden) + return + } + + var req RevokeInvitationRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + if req.Email == "" { + http.Error(w, "email is required", http.StatusBadRequest) + return + } + + if err := h.networkSvc.RevokeInvitation(r.Context(), networkID, req.Email); err != nil { + slog.Error("failed to revoke invitation", "error", err, "network_id", networkID, "email", req.Email) http.Error(w, "internal server error", http.StatusInternalServerError) return } @@ -476,7 +672,7 @@ func (h *Handler) DownloadParticleMedia(w http.ResponseWriter, r *http.Request) // PrepareUpload prepares an upload and returns a signed URL for direct upload to GCS func (h *Handler) PrepareUpload(w http.ResponseWriter, r *http.Request) { - email, ok := middleware.EmailFromContext(r.Context()) + humanId, ok := middleware.HumanIdFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return @@ -493,10 +689,9 @@ func (h *Handler) PrepareUpload(w http.ResponseWriter, r *http.Request) { return } - // Check network membership - isMember, err := h.networkSvc.IsMember(r.Context(), req.NetworkId, email) + isMember, err := h.networkSvc.IsMember(r.Context(), req.NetworkId, humanId) if err != nil { - slog.Error("failed to check network membership", "error", err, "network_id", req.NetworkId, "email", email) + slog.Error("failed to check network membership", "error", err, "network_id", req.NetworkId, "humanId", humanId) http.Error(w, "internal server error", http.StatusInternalServerError) return } @@ -582,40 +777,27 @@ func (h *Handler) ConfirmUpload(w http.ResponseWriter, r *http.Request) { func humanToDTO(h *human.Human) Human { return Human{ - Id: utils.CreateOptionalString(h.ID), + Id: h.ID, Email: h.Email, EmailPrefix: h.EmailPrefix, - CreatedAt: &h.CreatedAt, + CreatedAt: h.CreatedAt, } } -func emailPrefix(email string) string { - return strings.Split(email, "@")[0] -} - func (h *Handler) networkToDTO(ctx context.Context, n *network.Network) (Network, error) { - adminHuman, err := h.humanSvc.GetByEmail(ctx, n.AdminEmail) + adminHuman, err := h.humanSvc.GetByID(ctx, n.AdminHumanId) if err != nil { return Network{}, err } - // Get member humans - humans := make([]Human, 0, len(n.MemberEmails)) - for _, email := range n.MemberEmails { - hum, err := h.humanSvc.GetByEmail(ctx, email) + humans := make([]Human, 0, len(n.MemberHumanIds)) + for _, memberHumanId := range n.MemberHumanIds { + hum, err := h.humanSvc.GetByID(ctx, memberHumanId) if err != nil { - if err == human.ErrNotFound { - humans = append(humans, Human{ - Id: nil, - CreatedAt: nil, - Email: email, - EmailPrefix: emailPrefix(email), - }) - } + slog.Warn("failed to look up network member", "humanId", memberHumanId, "error", err) continue - } else { - humans = append(humans, humanToDTO(hum)) } + humans = append(humans, humanToDTO(hum)) } return Network{ diff --git a/go/internal/human/service.go b/go/internal/human/service.go index e894686..6ff94e1 100644 --- a/go/internal/human/service.go +++ b/go/internal/human/service.go @@ -14,6 +14,8 @@ type Service interface { GetOrCreateByEmail(ctx context.Context, email string) (*Human, error) // GetByEmail returns ErrNotFound if no human found GetByEmail(ctx context.Context, email string) (*Human, error) + // GetByID returns ErrNotFound if no human found + GetByID(ctx context.Context, id string) (*Human, error) } type serviceImpl struct { @@ -52,3 +54,11 @@ func (s *serviceImpl) GetByEmail(ctx context.Context, email string) (*Human, err } return h, err } + +func (s *serviceImpl) GetByID(ctx context.Context, id string) (*Human, error) { + h, err := s.repo.getByID(ctx, id) + if errors.Is(err, errNotFound) { + return nil, ErrNotFound + } + return h, err +} diff --git a/go/internal/middleware/auth.go b/go/internal/middleware/auth.go index 330c6bb..91e42dc 100644 --- a/go/internal/middleware/auth.go +++ b/go/internal/middleware/auth.go @@ -11,7 +11,10 @@ import ( type contextKey string -const emailContextKey contextKey = "email" +const ( + emailContextKey contextKey = "email" + humanIdContextKey contextKey = "humanId" +) // WithEmail adds the email to the context func WithEmail(ctx context.Context, email string) context.Context { @@ -24,6 +27,17 @@ func EmailFromContext(ctx context.Context) (string, bool) { return email, ok } +// WithHumanId adds the humanId to the context +func WithHumanId(ctx context.Context, humanId string) context.Context { + return context.WithValue(ctx, humanIdContextKey, humanId) +} + +// HumanIdFromContext extracts the id from the context +func HumanIdFromContext(ctx context.Context) (string, bool) { + humanId, ok := ctx.Value(humanIdContextKey).(string) + return humanId, ok +} + // Auth returns a middleware that validates the session token and adds the email to the context func Auth(authSvc auth.AuthService) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { @@ -34,7 +48,7 @@ func Auth(authSvc auth.AuthService) func(http.Handler) http.Handler { return } - email, err := authSvc.GetSession(r.Context(), token) + session, err := authSvc.GetSession(r.Context(), token) if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return @@ -45,7 +59,8 @@ func Auth(authSvc auth.AuthService) func(http.Handler) http.Handler { slog.Warn("failed to extend session", "error", err) } - ctx := WithEmail(r.Context(), email) + ctx := WithEmail(r.Context(), session.Email) + ctx = WithHumanId(ctx, session.HumanId) next.ServeHTTP(w, r.WithContext(ctx)) }) } diff --git a/go/internal/network/models.go b/go/internal/network/models.go index 3523ddc..6eedbd7 100644 --- a/go/internal/network/models.go +++ b/go/internal/network/models.go @@ -5,9 +5,15 @@ import "time" type Network struct { ID string Name string - AdminEmail string - MemberEmails []string + AdminHumanId string + MemberHumanIds []string OpenStreamCapacity int OpenStreamCount int CreatedAt time.Time } + +type Invitation struct { + NetworkID string + Email string + CreatedAt time.Time +} diff --git a/go/internal/network/repository.go b/go/internal/network/repository.go index a03d75e..e846309 100644 --- a/go/internal/network/repository.go +++ b/go/internal/network/repository.go @@ -26,18 +26,21 @@ func newNetworkID() (networkID, error) { var errCapacityExceeded = errors.New("capacity exceeded") type repository interface { - create(ctx context.Context, name, adminEmail string) (*Network, error) + create(ctx context.Context, name, adminHumanId string) (*Network, error) getByID(ctx context.Context, id string) (*Network, error) updateName(ctx context.Context, id, name string) error delete(ctx context.Context, id string) error - addMember(ctx context.Context, networkID, email string) error - removeMember(ctx context.Context, networkID, email string) error - getMemberEmails(ctx context.Context, networkID string) ([]string, error) - getNetworksForEmail(ctx context.Context, email string) ([]*Network, error) - isMember(ctx context.Context, networkID, email string) (bool, error) - setOpenStreamCapacity(ctx context.Context, id string, capacity int) error - incrementOpenStreamCount(ctx context.Context, id string) error - decrementOpenStreamCount(ctx context.Context, id string) error + addMember(ctx context.Context, networkID, humanId string) error + removeMember(ctx context.Context, networkID, humanId string) error + getMemberHumanIds(ctx context.Context, networkID string) ([]string, error) + getNetworksForHuman(ctx context.Context, humanId string) ([]*Network, error) + isMember(ctx context.Context, networkID, humanId string) (bool, error) + + // Invitations + createInvitation(ctx context.Context, networkID, email string) error + getInvitationsByEmail(ctx context.Context, email string) ([]*Invitation, error) + getInvitationsByNetwork(ctx context.Context, networkID string) ([]*Invitation, error) + deleteInvitation(ctx context.Context, networkID, email string) error } type repositoryImpl struct { @@ -48,7 +51,7 @@ func newRepository(pool *pgxpool.Pool) repository { return &repositoryImpl{pool: pool} } -func (r *repositoryImpl) create(ctx context.Context, name, adminEmail string) (*Network, error) { +func (r *repositoryImpl) create(ctx context.Context, name, adminHumanId string) (*Network, error) { id, err := newNetworkID() if err != nil { return nil, err @@ -56,24 +59,24 @@ func (r *repositoryImpl) create(ctx context.Context, name, adminEmail string) (* var n Network err = r.pool.QueryRow(ctx, - `INSERT INTO networks (id, name, admin_email) VALUES ($1, $2, $3) - RETURNING id, name, admin_email, open_stream_capacity, open_stream_count, created_at`, - id.String(), name, adminEmail, - ).Scan(&n.ID, &n.Name, &n.AdminEmail, &n.OpenStreamCapacity, &n.OpenStreamCount, &n.CreatedAt) + `INSERT INTO networks (id, name, admin_human_id) VALUES ($1, $2, $3) + RETURNING id, name, admin_human_id, open_stream_capacity, open_stream_count, created_at`, + id.String(), name, adminHumanId, + ).Scan(&n.ID, &n.Name, &n.AdminHumanId, &n.OpenStreamCapacity, &n.OpenStreamCount, &n.CreatedAt) if err != nil { return nil, err } - n.MemberEmails = []string{} + n.MemberHumanIds = []string{} return &n, nil } func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Network, error) { var n Network err := r.pool.QueryRow(ctx, - `SELECT id, name, admin_email, open_stream_capacity, open_stream_count, created_at FROM networks WHERE id = $1`, + `SELECT id, name, admin_human_id, open_stream_capacity, open_stream_count, created_at FROM networks WHERE id = $1`, id, - ).Scan(&n.ID, &n.Name, &n.AdminEmail, &n.OpenStreamCapacity, &n.OpenStreamCount, &n.CreatedAt) + ).Scan(&n.ID, &n.Name, &n.AdminHumanId, &n.OpenStreamCapacity, &n.OpenStreamCount, &n.CreatedAt) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, errNotFound @@ -81,7 +84,7 @@ func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Network, erro return nil, err } - n.MemberEmails, err = r.getMemberEmails(ctx, id) + n.MemberHumanIds, err = r.getMemberHumanIds(ctx, id) if err != nil { return nil, err } @@ -114,26 +117,26 @@ func (r *repositoryImpl) delete(ctx context.Context, id string) error { return nil } -func (r *repositoryImpl) addMember(ctx context.Context, networkID, email string) error { +func (r *repositoryImpl) addMember(ctx context.Context, networkID, humanId string) error { _, err := r.pool.Exec(ctx, - `INSERT INTO network_members (network_id, email) VALUES ($1, $2) - ON CONFLICT (network_id, email) DO NOTHING`, - networkID, email, + `INSERT INTO network_members (network_id, human_id) VALUES ($1, $2) + ON CONFLICT (network_id, human_id) DO NOTHING`, + networkID, humanId, ) return err } -func (r *repositoryImpl) removeMember(ctx context.Context, networkID, email string) error { +func (r *repositoryImpl) removeMember(ctx context.Context, networkID, humanId string) error { _, err := r.pool.Exec(ctx, - `DELETE FROM network_members WHERE network_id = $1 AND email = $2`, - networkID, email, + `DELETE FROM network_members WHERE network_id = $1 AND human_id = $2`, + networkID, humanId, ) return err } -func (r *repositoryImpl) getMemberEmails(ctx context.Context, networkID string) ([]string, error) { +func (r *repositoryImpl) getMemberHumanIds(ctx context.Context, networkID string) ([]string, error) { rows, err := r.pool.Query(ctx, - `SELECT email FROM network_members WHERE network_id = $1`, + `SELECT human_id FROM network_members WHERE network_id = $1`, networkID, ) if err != nil { @@ -141,24 +144,24 @@ func (r *repositoryImpl) getMemberEmails(ctx context.Context, networkID string) } defer rows.Close() - var emails []string + var humanIds []string for rows.Next() { - var email string - if err := rows.Scan(&email); err != nil { + var humanId string + if err := rows.Scan(&humanId); err != nil { return nil, err } - emails = append(emails, email) + humanIds = append(humanIds, humanId) } - return emails, rows.Err() + return humanIds, rows.Err() } -func (r *repositoryImpl) getNetworksForEmail(ctx context.Context, email string) ([]*Network, error) { +func (r *repositoryImpl) getNetworksForHuman(ctx context.Context, humanId string) ([]*Network, error) { rows, err := r.pool.Query(ctx, - `SELECT n.id, n.name, n.admin_email, n.open_stream_capacity, n.open_stream_count, n.created_at + `SELECT n.id, n.name, n.admin_human_id, n.open_stream_capacity, n.open_stream_count, n.created_at FROM networks n - WHERE n.admin_email = $1 - OR EXISTS (SELECT 1 FROM network_members nm WHERE nm.network_id = n.id AND nm.email = $1)`, - email, + WHERE n.admin_human_id = $1 + OR EXISTS (SELECT 1 FROM network_members nm WHERE nm.network_id = n.id AND nm.human_id = $1)`, + humanId, ) if err != nil { return nil, err @@ -168,7 +171,7 @@ func (r *repositoryImpl) getNetworksForEmail(ctx context.Context, email string) var networks []*Network for rows.Next() { var n Network - if err := rows.Scan(&n.ID, &n.Name, &n.AdminEmail, &n.OpenStreamCapacity, &n.OpenStreamCount, &n.CreatedAt); err != nil { + if err := rows.Scan(&n.ID, &n.Name, &n.AdminHumanId, &n.OpenStreamCapacity, &n.OpenStreamCount, &n.CreatedAt); err != nil { return nil, err } networks = append(networks, &n) @@ -178,7 +181,7 @@ func (r *repositoryImpl) getNetworksForEmail(ctx context.Context, email string) } for _, n := range networks { - n.MemberEmails, err = r.getMemberEmails(ctx, n.ID) + n.MemberHumanIds, err = r.getMemberHumanIds(ctx, n.ID) if err != nil { return nil, err } @@ -187,66 +190,75 @@ func (r *repositoryImpl) getNetworksForEmail(ctx context.Context, email string) return networks, nil } -func (r *repositoryImpl) isMember(ctx context.Context, networkID, email string) (bool, error) { +func (r *repositoryImpl) isMember(ctx context.Context, networkID, humanId string) (bool, error) { var isMember bool err := r.pool.QueryRow(ctx, ` SELECT EXISTS( SELECT 1 FROM networks n - LEFT JOIN network_members nm ON nm.network_id = n.id AND nm.email = $2 - WHERE n.id = $1 AND (n.admin_email = $2 OR nm.email IS NOT NULL) + LEFT JOIN network_members nm ON nm.network_id = n.id AND nm.human_id = $2 + WHERE n.id = $1 AND (n.admin_human_id = $2 OR nm.human_id IS NOT NULL) ) - `, networkID, email).Scan(&isMember) + `, networkID, humanId).Scan(&isMember) return isMember, err } -func (r *repositoryImpl) setOpenStreamCapacity(ctx context.Context, id string, capacity int) error { - result, err := r.pool.Exec(ctx, - `UPDATE networks SET open_stream_capacity = $1 WHERE id = $2`, - capacity, id, +// Invitation methods + +func (r *repositoryImpl) createInvitation(ctx context.Context, networkID, email string) error { + _, err := r.pool.Exec(ctx, + `INSERT INTO network_invitations (network_id, email) VALUES ($1, $2) + ON CONFLICT (network_id, email) DO NOTHING`, + networkID, email, ) - if err != nil { - return err - } - if result.RowsAffected() == 0 { - return errNotFound - } - return nil + return err } -func (r *repositoryImpl) incrementOpenStreamCount(ctx context.Context, id string) error { - result, err := r.pool.Exec(ctx, - `UPDATE networks SET open_stream_count = open_stream_count + 1 - WHERE id = $1 AND open_stream_count < open_stream_capacity`, - id, +func (r *repositoryImpl) getInvitationsByEmail(ctx context.Context, email string) ([]*Invitation, error) { + rows, err := r.pool.Query(ctx, + `SELECT network_id, email, created_at FROM network_invitations WHERE email = $1`, + email, ) if err != nil { - return err + return nil, err } - if result.RowsAffected() == 0 { - // Check if network exists vs capacity exceeded - var exists bool - err := r.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM networks WHERE id = $1)`, id).Scan(&exists) - if err != nil { - return err + defer rows.Close() + + var invitations []*Invitation + for rows.Next() { + var inv Invitation + if err := rows.Scan(&inv.NetworkID, &inv.Email, &inv.CreatedAt); err != nil { + return nil, err } - if !exists { - return errNotFound - } - return errCapacityExceeded + invitations = append(invitations, &inv) } - return nil + return invitations, rows.Err() } -func (r *repositoryImpl) decrementOpenStreamCount(ctx context.Context, id string) error { - result, err := r.pool.Exec(ctx, - `UPDATE networks SET open_stream_count = GREATEST(0, open_stream_count - 1) WHERE id = $1`, - id, +func (r *repositoryImpl) getInvitationsByNetwork(ctx context.Context, networkID string) ([]*Invitation, error) { + rows, err := r.pool.Query(ctx, + `SELECT network_id, email, created_at FROM network_invitations WHERE network_id = $1`, + networkID, ) if err != nil { - return err + return nil, err } - if result.RowsAffected() == 0 { - return errNotFound + defer rows.Close() + + var invitations []*Invitation + for rows.Next() { + var inv Invitation + if err := rows.Scan(&inv.NetworkID, &inv.Email, &inv.CreatedAt); err != nil { + return nil, err + } + invitations = append(invitations, &inv) } - return nil + return invitations, rows.Err() +} + +func (r *repositoryImpl) deleteInvitation(ctx context.Context, networkID, email string) error { + _, err := r.pool.Exec(ctx, + `DELETE FROM network_invitations WHERE network_id = $1 AND email = $2`, + networkID, email, + ) + return err } diff --git a/go/internal/network/service.go b/go/internal/network/service.go index f35a51e..37715d7 100644 --- a/go/internal/network/service.go +++ b/go/internal/network/service.go @@ -15,23 +15,23 @@ var ErrInvalidName = errors.New("name cannot be empty") var ErrCapacityExceeded = errors.New("active stream capacity exceeded") type Service interface { - // Create creates a network and adds adminEmail as the first member. Returns ErrInvalidName if name is empty. - Create(ctx context.Context, name, adminEmail string) (*Network, error) + // Create creates a network and adds adminHumanId as the first member. Returns ErrInvalidName if name is empty. + Create(ctx context.Context, name, adminHumanId string) (*Network, error) // GetByID returns ErrNotFound if network doesn't exist. GetByID(ctx context.Context, id string) (*Network, error) // SetName returns ErrNotFound or ErrInvalidName. SetName(ctx context.Context, id, name string) error - AddMembers(ctx context.Context, networkID string, emails []string) error - RemoveMember(ctx context.Context, networkID, email string) error - ListForEmail(ctx context.Context, email string) ([]*Network, error) - IsMember(ctx context.Context, networkID, email string) (bool, error) + AddMembers(ctx context.Context, networkID string, humanIds []string) error + RemoveMember(ctx context.Context, networkID, humanId string) error + ListForHuman(ctx context.Context, humanId string) ([]*Network, error) + IsMember(ctx context.Context, networkID, humanId string) (bool, error) - // SetOpenStreamCapacity sets the max open streams for a network. Returns ErrNotFound. - SetOpenStreamCapacity(ctx context.Context, networkID string, capacity int) error - // IncrementOpenStreamCount returns ErrNotFound or ErrCapacityExceeded. - IncrementOpenStreamCount(ctx context.Context, networkID string) error - // DecrementOpenStreamCount returns ErrNotFound. - DecrementOpenStreamCount(ctx context.Context, networkID string) error + // Invitations (email-based, for users who haven't registered yet) + InviteByEmail(ctx context.Context, networkID string, emails []string) error + ListInvitationsForEmail(ctx context.Context, email string) ([]*Invitation, error) + ListInvitationsForNetwork(ctx context.Context, networkID string) ([]*Invitation, error) + AcceptInvitation(ctx context.Context, networkID, email, humanId string) error + RevokeInvitation(ctx context.Context, networkID, email string) error } type serviceImpl struct { @@ -42,23 +42,18 @@ func NewService(pool *pgxpool.Pool) Service { return &serviceImpl{repo: newRepository(pool)} } -func (s *serviceImpl) Create(ctx context.Context, name, adminEmail string) (*Network, error) { +func (s *serviceImpl) Create(ctx context.Context, name, adminHumanId string) (*Network, error) { name = strings.TrimSpace(name) if name == "" { return nil, ErrInvalidName } - adminEmail, err := utils.NormalizeEmail(adminEmail) + network, err := s.repo.create(ctx, name, adminHumanId) if err != nil { return nil, err } - network, err := s.repo.create(ctx, name, adminEmail) - if err != nil { - return nil, err - } - - err = s.AddMembers(ctx, network.ID, []string{adminEmail}) + err = s.AddMembers(ctx, network.ID, []string{adminHumanId}) if err != nil { return nil, err } @@ -87,69 +82,85 @@ func (s *serviceImpl) SetName(ctx context.Context, id, name string) error { return err } -func (s *serviceImpl) AddMembers(ctx context.Context, networkID string, emails []string) error { - for _, email := range emails { - normalized, err := utils.NormalizeEmail(email) - if err != nil { - return fmt.Errorf("invalid email: %w", err) +func (s *serviceImpl) AddMembers(ctx context.Context, networkID string, humanIds []string) error { + for _, humanId := range humanIds { + if humanId == "" { + return fmt.Errorf("invalid humanId") } - if err := s.repo.addMember(ctx, networkID, normalized); err != nil { + if err := s.repo.addMember(ctx, networkID, humanId); err != nil { return err } } return nil } -func (s *serviceImpl) RemoveMember(ctx context.Context, networkID, email string) error { - email, err := utils.NormalizeEmail(email) - if err != nil { - return fmt.Errorf("invalid email: %w", err) +func (s *serviceImpl) RemoveMember(ctx context.Context, networkID, humanId string) error { + if humanId == "" { + return fmt.Errorf("invalid humanId") } - return s.repo.removeMember(ctx, networkID, email) + return s.repo.removeMember(ctx, networkID, humanId) } -func (s *serviceImpl) ListForEmail(ctx context.Context, email string) ([]*Network, error) { - email, err := utils.NormalizeEmail(email) +func (s *serviceImpl) ListForHuman(ctx context.Context, humanId string) ([]*Network, error) { + if humanId == "" { + return nil, fmt.Errorf("invalid humanId") + } + return s.repo.getNetworksForHuman(ctx, humanId) +} + +func (s *serviceImpl) IsMember(ctx context.Context, networkID, humanId string) (bool, error) { + if humanId == "" { + return false, fmt.Errorf("invalid humanId") + } + return s.repo.isMember(ctx, networkID, humanId) +} + +// Invitation methods + +func (s *serviceImpl) InviteByEmail(ctx context.Context, networkID string, emails []string) error { + for _, email := range emails { + normalized, err := utils.NormalizeEmail(email) + if err != nil { + return fmt.Errorf("invalid email %q: %w", email, err) + } + if err := s.repo.createInvitation(ctx, networkID, normalized); err != nil { + return err + } + } + return nil +} + +func (s *serviceImpl) ListInvitationsForEmail(ctx context.Context, email string) ([]*Invitation, error) { + normalized, err := utils.NormalizeEmail(email) if err != nil { return nil, fmt.Errorf("invalid email: %w", err) } - return s.repo.getNetworksForEmail(ctx, email) + return s.repo.getInvitationsByEmail(ctx, normalized) } -func (s *serviceImpl) IsMember(ctx context.Context, networkID, email string) (bool, error) { - email, err := utils.NormalizeEmail(email) +func (s *serviceImpl) ListInvitationsForNetwork(ctx context.Context, networkID string) ([]*Invitation, error) { + return s.repo.getInvitationsByNetwork(ctx, networkID) +} + +func (s *serviceImpl) AcceptInvitation(ctx context.Context, networkID, email, humanId string) error { + normalized, err := utils.NormalizeEmail(email) if err != nil { - return false, err + return fmt.Errorf("invalid email: %w", err) } - return s.repo.isMember(ctx, networkID, email) + if humanId == "" { + return fmt.Errorf("invalid humanId") + } + + if err := s.repo.deleteInvitation(ctx, networkID, normalized); err != nil { + return err + } + return s.repo.addMember(ctx, networkID, humanId) } -func (s *serviceImpl) SetOpenStreamCapacity(ctx context.Context, networkID string, capacity int) error { - if capacity < 0 { - capacity = 0 +func (s *serviceImpl) RevokeInvitation(ctx context.Context, networkID, email string) error { + normalized, err := utils.NormalizeEmail(email) + if err != nil { + return fmt.Errorf("invalid email: %w", err) } - err := s.repo.setOpenStreamCapacity(ctx, networkID, capacity) - if errors.Is(err, errNotFound) { - return ErrNotFound - } - return err -} - -func (s *serviceImpl) IncrementOpenStreamCount(ctx context.Context, networkID string) error { - err := s.repo.incrementOpenStreamCount(ctx, networkID) - if errors.Is(err, errNotFound) { - return ErrNotFound - } - if errors.Is(err, errCapacityExceeded) { - return ErrCapacityExceeded - } - return err -} - -func (s *serviceImpl) DecrementOpenStreamCount(ctx context.Context, networkID string) error { - err := s.repo.decrementOpenStreamCount(ctx, networkID) - if errors.Is(err, errNotFound) { - return ErrNotFound - } - return err + return s.repo.deleteInvitation(ctx, networkID, normalized) } diff --git a/go/internal/network/service_test.go b/go/internal/network/service_test.go index 04dd539..eeb6d3e 100644 --- a/go/internal/network/service_test.go +++ b/go/internal/network/service_test.go @@ -25,12 +25,17 @@ func TestNetworkService(t *testing.T) { ctx := context.Background() svc := network.NewService(dbPool) + adminHumanId := "human_admin123" + member1HumanId := "human_member1abc" + member2HumanId := "human_member2def" + strangerHumanId := "human_stranger789" + // Test Create - createdNetwork, err := svc.Create(ctx, "Test Network", "admin@example.com") + createdNetwork, err := svc.Create(ctx, "Test Network", adminHumanId) assert.NoError(t, err) assert.NotEmpty(t, createdNetwork.ID) assert.Equal(t, "Test Network", createdNetwork.Name) - assert.Equal(t, "admin@example.com", createdNetwork.AdminEmail) + assert.Equal(t, adminHumanId, createdNetwork.AdminHumanId) assert.NotZero(t, createdNetwork.CreatedAt) // Test GetByID @@ -38,7 +43,7 @@ func TestNetworkService(t *testing.T) { assert.NoError(t, err) assert.Equal(t, createdNetwork.ID, foundNetwork.ID) assert.Equal(t, createdNetwork.Name, foundNetwork.Name) - assert.Equal(t, createdNetwork.AdminEmail, foundNetwork.AdminEmail) + assert.Equal(t, createdNetwork.AdminHumanId, foundNetwork.AdminHumanId) // Test GetByID with non-existent id _, err = svc.GetByID(ctx, "network_nonexistent") @@ -60,45 +65,45 @@ func TestNetworkService(t *testing.T) { assert.ErrorIs(t, err, network.ErrNotFound) // Test AddMembers - err = svc.AddMembers(ctx, createdNetwork.ID, []string{"member1@example.com", "member2@example.com"}) + err = svc.AddMembers(ctx, createdNetwork.ID, []string{member1HumanId, member2HumanId}) assert.NoError(t, err) - // Test ListForEmail - should find network for admin - networks, err := svc.ListForEmail(ctx, "admin@example.com") + // Test ListForHuman - should find network for admin + networks, err := svc.ListForHuman(ctx, adminHumanId) assert.NoError(t, err) assert.Len(t, networks, 1) assert.Equal(t, createdNetwork.ID, networks[0].ID) - // Test ListForEmail - should find network for member - networks, err = svc.ListForEmail(ctx, "member1@example.com") + // Test ListForHuman - should find network for member + networks, err = svc.ListForHuman(ctx, member1HumanId) assert.NoError(t, err) assert.Len(t, networks, 1) assert.Equal(t, createdNetwork.ID, networks[0].ID) - // Test ListForEmail - should return empty for non-member - networks, err = svc.ListForEmail(ctx, "stranger@example.com") + // Test ListForHuman - should return empty for non-member + networks, err = svc.ListForHuman(ctx, strangerHumanId) assert.NoError(t, err) assert.Len(t, networks, 0) // Test RemoveMember - err = svc.RemoveMember(ctx, createdNetwork.ID, "member1@example.com") + err = svc.RemoveMember(ctx, createdNetwork.ID, member1HumanId) assert.NoError(t, err) // Verify member was removed - networks, err = svc.ListForEmail(ctx, "member1@example.com") + networks, err = svc.ListForHuman(ctx, member1HumanId) assert.NoError(t, err) assert.Len(t, networks, 0) // member2 should still have access - networks, err = svc.ListForEmail(ctx, "member2@example.com") + networks, err = svc.ListForHuman(ctx, member2HumanId) assert.NoError(t, err) assert.Len(t, networks, 1) - // Create another network and verify ListForEmail returns multiple - network2, err := svc.Create(ctx, "Second Network", "member2@example.com") + // Create another network and verify ListForHuman returns multiple + network2, err := svc.Create(ctx, "Second Network", member2HumanId) assert.NoError(t, err) - networks, err = svc.ListForEmail(ctx, "member2@example.com") + networks, err = svc.ListForHuman(ctx, member2HumanId) assert.NoError(t, err) assert.Len(t, networks, 2) @@ -107,3 +112,65 @@ func TestNetworkService(t *testing.T) { assert.Contains(t, networkIDs, createdNetwork.ID) assert.Contains(t, networkIDs, network2.ID) } + +func TestNetworkInvitations(t *testing.T) { + ctx := context.Background() + svc := network.NewService(dbPool) + + adminHumanId := "human_invtest_admin" + inviteeEmail := "invitee@example.com" + inviteeHumanId := "human_invitee123" + + // Create a network + net, err := svc.Create(ctx, "Invitation Test Network", adminHumanId) + assert.NoError(t, err) + + // Invite by email + err = svc.InviteByEmail(ctx, net.ID, []string{inviteeEmail}) + assert.NoError(t, err) + + // List invitations for email + invitations, err := svc.ListInvitationsForEmail(ctx, inviteeEmail) + assert.NoError(t, err) + assert.Len(t, invitations, 1) + assert.Equal(t, net.ID, invitations[0].NetworkID) + assert.Equal(t, inviteeEmail, invitations[0].Email) + + // List invitations for network + invitations, err = svc.ListInvitationsForNetwork(ctx, net.ID) + assert.NoError(t, err) + assert.Len(t, invitations, 1) + + // Duplicate invite is idempotent + err = svc.InviteByEmail(ctx, net.ID, []string{inviteeEmail}) + assert.NoError(t, err) + invitations, err = svc.ListInvitationsForNetwork(ctx, net.ID) + assert.NoError(t, err) + assert.Len(t, invitations, 1) + + // Accept invitation + err = svc.AcceptInvitation(ctx, net.ID, inviteeEmail, inviteeHumanId) + assert.NoError(t, err) + + // Invitation should be removed + invitations, err = svc.ListInvitationsForEmail(ctx, inviteeEmail) + assert.NoError(t, err) + assert.Len(t, invitations, 0) + + // Human should now be a member + isMember, err := svc.IsMember(ctx, net.ID, inviteeHumanId) + assert.NoError(t, err) + assert.True(t, isMember) + + // Test revoke invitation + revokeEmail := "revokee@example.com" + err = svc.InviteByEmail(ctx, net.ID, []string{revokeEmail}) + assert.NoError(t, err) + + err = svc.RevokeInvitation(ctx, net.ID, revokeEmail) + assert.NoError(t, err) + + invitations, err = svc.ListInvitationsForEmail(ctx, revokeEmail) + assert.NoError(t, err) + assert.Len(t, invitations, 0) +} diff --git a/go/internal/particle/service.go b/go/internal/particle/service.go index 684ce32..ce99953 100644 --- a/go/internal/particle/service.go +++ b/go/internal/particle/service.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "errors" - "log/slog" "time" "github.com/flowy-live/llink/internal/network" @@ -14,6 +13,7 @@ import ( const defaultPageSize = 50 +// NOTE: this service is deprecated as we use firestore for particle data type Service interface { // Create creates a new particle. Caller must be a network member (verified by handler). // Returns ErrInvalidType, ErrInvalidData, ErrMembersRequired, ErrInvalidParent, ErrAccessDenied, or ErrCapacityExceeded. @@ -198,26 +198,11 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm return nil, err } p.Data = data - - // Check and increment capacity - err = s.networkSvc.IncrementOpenStreamCount(ctx, input.NetworkID) - if err != nil { - if errors.Is(err, network.ErrCapacityExceeded) { - return nil, ErrCapacityExceeded - } - return nil, err - } } // Create the particle created, err := s.repo.create(ctx, p) if err != nil { - // If we incremented the stream count but creation failed, decrement it - if input.Type == TypeStream { - if decErr := s.networkSvc.DecrementOpenStreamCount(ctx, input.NetworkID); decErr != nil { - slog.Warn("failed to rollback stream count after particle creation failure", "error", decErr, "network_id", input.NetworkID) - } - } return nil, err } @@ -334,7 +319,7 @@ func (s *serviceImpl) Delete(ctx context.Context, id, requesterEmail string) err } // Get the particle to check if it's an open stream - p, err := s.repo.getByID(ctx, id) + _, err = s.repo.getByID(ctx, id) if err != nil { if errors.Is(err, errNotFound) { return ErrNotFound @@ -342,13 +327,6 @@ func (s *serviceImpl) Delete(ctx context.Context, id, requesterEmail string) err return err } - // If it's an open stream, decrement the count - if p.Type == TypeStream && getStreamStatus(p.Data) == string(StreamStatusOpen) { - if err := s.networkSvc.DecrementOpenStreamCount(ctx, p.NetworkID); err != nil { - return err - } - } - err = s.repo.delete(ctx, id) if errors.Is(err, errNotFound) { return ErrNotFound @@ -454,30 +432,14 @@ func (s *serviceImpl) OpenStream(ctx context.Context, id, requesterEmail string) return ErrStreamAlreadyOpen } - // Check and increment capacity - err = s.networkSvc.IncrementOpenStreamCount(ctx, p.NetworkID) - if err != nil { - if errors.Is(err, network.ErrCapacityExceeded) { - return ErrCapacityExceeded - } - return err - } - // Update stream status in data newData, err := setStreamStatus(p.Data, string(StreamStatusOpen)) if err != nil { - if decErr := s.networkSvc.DecrementOpenStreamCount(ctx, p.NetworkID); decErr != nil { - slog.Warn("failed to rollback stream count after status update failure", "error", decErr, "network_id", p.NetworkID, "particle_id", id) - } return err } err = s.repo.update(ctx, id, newData, time.Now()) if err != nil { - // Rollback the capacity increment - if decErr := s.networkSvc.DecrementOpenStreamCount(ctx, p.NetworkID); decErr != nil { - slog.Warn("failed to rollback stream count after particle update failure", "error", decErr, "network_id", p.NetworkID, "particle_id", id) - } if errors.Is(err, errNotFound) { return ErrNotFound } @@ -536,8 +498,7 @@ func (s *serviceImpl) CloseStream(ctx context.Context, id, requesterEmail string return err } - // Decrement capacity - return s.networkSvc.DecrementOpenStreamCount(ctx, p.NetworkID) + return nil } func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode VisibilityMode, requesterEmail string) error { diff --git a/go/internal/particle/service_test.go b/go/internal/particle/service_test.go index 7832a92..ec8a2a2 100644 --- a/go/internal/particle/service_test.go +++ b/go/internal/particle/service_test.go @@ -70,58 +70,6 @@ func TestParticleService_CreateAndGet(t *testing.T) { // Service assumes caller is already verified as network member } -func TestParticleService_StreamCapacity(t *testing.T) { - ctx := context.Background() - networkSvc := network.NewService(dbPool) - svc := particle.NewService(dbPool, networkSvc) - - // Create a network with capacity 2 - net, err := networkSvc.Create(ctx, "Capacity Test Network", "admin@example.com") - assert.NoError(t, err) - - err = networkSvc.SetOpenStreamCapacity(ctx, net.ID, 2) - assert.NoError(t, err) - - // Create first stream - should succeed - input := particle.CreateInput{ - Type: particle.TypeStream, - NetworkID: net.ID, - Data: json.RawMessage(`{"name":"Stream 1","status":"open"}`), - } - stream1, err := svc.Create(ctx, input, "admin@example.com") - assert.NoError(t, err) - - // Create second stream - should succeed - input.Data = json.RawMessage(`{"name":"Stream 2","status":"open"}`) - stream2, err := svc.Create(ctx, input, "admin@example.com") - assert.NoError(t, err) - - // Create third stream - should fail with capacity exceeded - input.Data = json.RawMessage(`{"name":"Stream 3","status":"open"}`) - _, err = svc.Create(ctx, input, "admin@example.com") - assert.Error(t, err) - assert.ErrorIs(t, err, particle.ErrCapacityExceeded) - - // Close a stream - err = svc.CloseStream(ctx, stream1.ID, "admin@example.com") - assert.NoError(t, err) - - // Now we can create another stream - stream3, err := svc.Create(ctx, input, "admin@example.com") - assert.NoError(t, err) - assert.NotEmpty(t, stream3.ID) - - // Verify stream2 is still open - found, err := svc.GetByID(ctx, stream2.ID, "admin@example.com") - assert.NoError(t, err) - assert.Equal(t, string(particle.StreamStatusOpen), getStreamStatus(found.Data)) - - // Verify stream1 is closed - found, err = svc.GetByID(ctx, stream1.ID, "admin@example.com") - assert.NoError(t, err) - assert.Equal(t, string(particle.StreamStatusClosed), getStreamStatus(found.Data)) -} - func TestParticleService_NestedParticles(t *testing.T) { ctx := context.Background() networkSvc := network.NewService(dbPool) diff --git a/go/migrations/000002_networks.up.sql b/go/migrations/000002_networks.up.sql index e481a3f..b25d710 100644 --- a/go/migrations/000002_networks.up.sql +++ b/go/migrations/000002_networks.up.sql @@ -1,13 +1,13 @@ CREATE TABLE networks ( id TEXT PRIMARY KEY, name VARCHAR(255) NOT NULL, - admin_email VARCHAR(255) NOT NULL, + admin_human_id VARCHAR(255) NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE TABLE network_members ( network_id TEXT NOT NULL REFERENCES networks(id) ON DELETE CASCADE, - email VARCHAR(255) NOT NULL, + human_id VARCHAR(255) NOT NULL, joined_at TIMESTAMPTZ DEFAULT NOW(), - PRIMARY KEY (network_id, email) + PRIMARY KEY (network_id, human_id) ); diff --git a/go/migrations/000008_network_invitations.down.sql b/go/migrations/000008_network_invitations.down.sql new file mode 100644 index 0000000..cf454a2 --- /dev/null +++ b/go/migrations/000008_network_invitations.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS network_invitations; diff --git a/go/migrations/000008_network_invitations.up.sql b/go/migrations/000008_network_invitations.up.sql new file mode 100644 index 0000000..7a68057 --- /dev/null +++ b/go/migrations/000008_network_invitations.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE network_invitations ( + network_id TEXT NOT NULL REFERENCES networks(id) ON DELETE CASCADE, + email VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (network_id, email) +); diff --git a/js/src/api/types.ts b/js/src/api/types.ts index 17ae94e..22ce879 100644 --- a/js/src/api/types.ts +++ b/js/src/api/types.ts @@ -1,8 +1,8 @@ import { z } from "zod"; export const HumanSchema = z.object({ - id: z.string().nullable(), - created_at: z.coerce.date().nullable(), + id: z.string(), + created_at: z.coerce.date(), email: z.string().email(), email_prefix: z.string(), }); @@ -101,7 +101,8 @@ export const QuestPropertiesSchema = z.object({ title: z.string(), description: z.string(), status: z.string().optional(), - assigned_to: z.string().email().optional(), + // humanId + assigned_to: z.string().optional(), }); export type QuestProperties = z.infer; @@ -126,15 +127,15 @@ export interface ParticlePropertiesMap { const ParticleBaseSchema = z.object({ id: z.string(), created_at: z.coerce.date(), - created_by_email: z.string().email(), + created_by_human_id: z.string(), updated_at: z.coerce.date().optional(), }); export const ParticleSchema = z.discriminatedUnion("type", [ ParticleBaseSchema.extend({ type: z.literal("stream"), properties: StreamPropertiesSchema, - // e.g. ["human:aron@acme.com", "human:john@acme.com"] - visible only to Aron and John - // e.g. ["network:123"] - visible to everyone in the network + // e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John + // e.g. ["network:xywx"] - visible to everyone in the network visible_to: z.array(z.string()), // Marks human_id to their `playback_position_at`: where they left off in a conversation playback_markers: z.record(z.string(), z.coerce.date()).optional(), @@ -144,7 +145,7 @@ export const ParticleSchema = z.discriminatedUnion("type", [ }), ParticleBaseSchema.extend({ type: z.literal("folder"), properties: FolderPropertiesSchema, - // e.g. ["human:aron@acme.com", "human:john@acme.com"] - visible only to Aron and John + // e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John // e.g. ["network:123"] - visible to everyone in the network visible_to: z.array(z.string()), }), diff --git a/js/src/features/compose/compose-overlay.tsx b/js/src/features/compose/compose-overlay.tsx index 333e0b7..869ca9d 100644 --- a/js/src/features/compose/compose-overlay.tsx +++ b/js/src/features/compose/compose-overlay.tsx @@ -42,7 +42,7 @@ export function ComposeOverlay({ const [reviewMimeType, setReviewMimeType] = useState(null); const recordingMode = useMediaSettingsStore((s) => s.recordingMode); - const userEmail = useAuthStore((s) => s.user?.email); + const userId = useAuthStore((s) => s.user?.id); const createParticle = useCreateParticle(); const createStream = useCreateStreamParticle(); @@ -113,7 +113,7 @@ export function ComposeOverlay({ const createChildParticle = useCallback( async (path: ParticlePath) => { - if (!userEmail) return; + if (!userId) return; let particleId = ''; if (textContent.trim()) { @@ -121,7 +121,7 @@ export function ComposeOverlay({ path, type: "text", properties: { content: textContent }, - createdByEmail: userEmail, + createdByHumanId: userId, }); } else if (reviewBlob && reviewMimeType) { const { object_id, size_bytes } = await uploadMedia( @@ -138,14 +138,14 @@ export function ComposeOverlay({ duration_ms: reviewDurationMs, size_bytes, }, - createdByEmail: userEmail, + createdByHumanId: userId, }); } onParticleCreated?.(particleId); }, [ - userEmail, + userId, textContent, reviewBlob, reviewMimeType, @@ -158,7 +158,7 @@ export function ComposeOverlay({ // Reply mode: create particle directly under targetPath const onSubmitReply = useEffectEvent(async () => { - if (!targetPath || !userEmail || stepRef.current === "submitting") return; + if (!targetPath || !userId || stepRef.current === "submitting") return; setStepSync("submitting"); await createChildParticle(targetPath); cancel(); @@ -167,7 +167,7 @@ export function ComposeOverlay({ // New stream mode: create stream + first child const handleStreamSubmit = useCallback( async (streamName: string, visibleTo: string[]) => { - if (!userEmail || stepRef.current === "submitting") return; + if (!userId || stepRef.current === "submitting") return; setStepSync("submitting"); const streamId = await createStream.mutateAsync({ @@ -176,7 +176,7 @@ export function ComposeOverlay({ name: streamName, status: "open", }, - createdByEmail: userEmail, + createdByHumanId: userId, visibleTo, }); @@ -185,7 +185,7 @@ export function ComposeOverlay({ cancel(); }, - [networkId, userEmail, createParticle, createChildParticle, cancel], + [networkId, userId, createParticle, createChildParticle, cancel], ); // --- Keyboard handling --- diff --git a/js/src/features/compose/configure-stream-step.tsx b/js/src/features/compose/configure-stream-step.tsx index a959a35..fb910b5 100644 --- a/js/src/features/compose/configure-stream-step.tsx +++ b/js/src/features/compose/configure-stream-step.tsx @@ -24,16 +24,16 @@ export function ConfigureStreamStep({ const [name, setName] = useState(() => generateRandomName()); const [everyone, setEveryone] = useState(true); - const userEmail = useAuthStore((s) => s.user?.email); - const [selectedEmails, setSelectedEmails] = useState>(new Set()); + const userId = useAuthStore((s) => s.user?.id); + const [selectedIds, setSelectedIds] = useState>(new Set()); - const members = (network?.humans ?? []).filter((h) => h.email !== userEmail); + const members = (network?.humans ?? []).filter((h) => h.id !== userId); - const toggleMember = useCallback((email: string) => { - setSelectedEmails((prev) => { + const toggleMember = useCallback((id: string) => { + setSelectedIds((prev) => { const next = new Set(prev); - if (next.has(email)) next.delete(email); - else next.add(email); + if (next.has(id)) next.delete(id); + else next.add(id); return next; }); }, []); @@ -41,8 +41,8 @@ export function ConfigureStreamStep({ const buildVisibleTo = useCallback((): string[] => { if (everyone && networkId) return [`network:${networkId}`]; - return Array.from(removeDuplicates([...selectedEmails, userEmail])).map((e) => `human:${e}`); - }, [everyone, networkId, selectedEmails, userEmail]); + return Array.from(removeDuplicates([...selectedIds, userId].filter(Boolean) as string[])).map((id) => `human:${id}`); + }, [everyone, networkId, selectedIds, userId]); const handleSubmit = useCallback(() => { if (!name.trim() || !networkId) return; @@ -117,16 +117,16 @@ export function ConfigureStreamStep({
{members.map((member, index) => { - const isSelected = selectedEmails.has(member.email); + const isSelected = selectedIds.has(member.id); const initials = member.email_prefix .slice(0, 2) .toUpperCase(); return (
toggleMember(member.email)} + onClick={() => toggleMember(member.id)} className={cn( "flex w-full cursor-pointer items-center gap-2.5 rounded px-3 py-1.5 text-left text-sm transition-colors", "text-white/70 hover:bg-white/5", diff --git a/js/src/features/layout.tsx b/js/src/features/layout.tsx index 958564e..ad3e6ec 100644 --- a/js/src/features/layout.tsx +++ b/js/src/features/layout.tsx @@ -77,7 +77,7 @@ function TopBar() { const { networkId, "*": rest } = useParams(); const segments = [networkId, ...rest?.split("/") ?? []].filter(Boolean); - const path = rest ? particlePath(networkId!, rest.split("/").filter(Boolean)) : undefined; + const path = rest && networkId ? particlePath(networkId, rest.split("/").filter(Boolean)) : undefined; const { data: particle } = useParticle(path); diff --git a/js/src/features/particles/autoplay-overlay.tsx b/js/src/features/particles/autoplay-overlay.tsx index c0189fc..0d459fc 100644 --- a/js/src/features/particles/autoplay-overlay.tsx +++ b/js/src/features/particles/autoplay-overlay.tsx @@ -5,6 +5,7 @@ import { useDownloadUrl } from "@/hooks/use-download-url"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Small } from "@/components/ui/typography"; import { getInitials } from "@/lib/utils"; +import { useNetwork } from "@/hooks/use-networks"; interface AutoplayOverlayProps { networkId: string; @@ -17,12 +18,14 @@ export function AutoplayOverlay({ networkId }: AutoplayOverlayProps) { const { data: url } = useDownloadUrl(activeParticle?.properties.object_id); const navigate = useNavigate(); + const network = useNetwork(networkId); + if (!activeParticle || !url) return null; const isVideo = activeParticle.properties.mime_type?.startsWith("video/"); - const senderEmail = activeParticle.created_by_email; - const senderInitials = getInitials(senderEmail); - const senderName = senderEmail.split("@")[0]; + const creator = network?.humans?.find((h) => h.id === activeParticle.created_by_human_id); + const senderInitials = creator ? getInitials(creator.email) : activeParticle.created_by_human_id.slice(0, 2).toUpperCase(); + const senderName = creator?.email_prefix ?? activeParticle.created_by_human_id; const handleClick = () => { stop(); diff --git a/js/src/features/particles/fallback-particle-view.tsx b/js/src/features/particles/fallback-particle-view.tsx index 0ef8654..46bcfcc 100644 --- a/js/src/features/particles/fallback-particle-view.tsx +++ b/js/src/features/particles/fallback-particle-view.tsx @@ -51,7 +51,7 @@ export function FallbackParticleView({ particle }: FallbackParticleViewProps) {

- From {particle.created_by_email} + From {particle.created_by_human_id}

diff --git a/js/src/features/particles/particle-list-view.tsx b/js/src/features/particles/particle-list-view.tsx index eb8a3a9..1e300d9 100644 --- a/js/src/features/particles/particle-list-view.tsx +++ b/js/src/features/particles/particle-list-view.tsx @@ -30,6 +30,7 @@ import type { Particle, StreamProperties } from "@/api/types"; import { useAutoplayStore } from "@/stores/autoplay-store"; import { where, Timestamp } from "firebase/firestore"; import { RECENCY_WINDOW_HOURS } from "@/lib/constants"; +import { useNetwork } from "@/hooks/use-networks"; function getParticleTypeIcon(particle: Particle): LucideIcon { switch (particle.type) { @@ -88,7 +89,7 @@ function StreamRow({ const { latestChild } = useLiveLatestChild(streamPath); const user = useAuthStore((s) => s.user); const userId = user?.id ?? ""; - const userEmail = user?.email ?? ""; + const network = useNetwork(networkId); // Autoplay: trigger only when latestChild *changes* to a new media particle, // not on initial data load. We track the "settled" id — the first non-null value @@ -106,7 +107,7 @@ function StreamRow({ if (latestChild.id === settledIdRef.current) return; settledIdRef.current = latestChild.id; - if (latestChild.created_by_email === userEmail) return; + if (latestChild.created_by_human_id === userId) return; if (latestChild.type === "text") { new Audio(beepSound).play().catch(() => {}); @@ -125,20 +126,22 @@ function StreamRow({ const initials = useMemo(() => { if (isDM) { const otherEntry = particle.visible_to.find( - (v) => v !== `human:${userEmail}`, + (v) => v !== `human:${userId}`, ); if (otherEntry) { - const otherEmail = otherEntry.replace("human:", ""); - return getInitials(otherEmail); + const otherId = otherEntry.replace("human:", ""); + const otherHuman = network?.humans?.find((h) => h.id === otherId); + if (otherHuman) return getInitials(otherHuman.email); } } if (latestChild) { - return getInitials(latestChild.created_by_email); + const creator = network?.humans?.find((h) => h.id === latestChild.created_by_human_id); + if (creator) return getInitials(creator.email); } return particle.properties.name.slice(0, 2).toUpperCase(); - }, [isDM, particle.visible_to, particle.properties.name, userEmail, latestChild]); + }, [isDM, particle.visible_to, particle.properties.name, userId, latestChild, network]); const isUnseen = useMemo(() => { if (!latestChild) return false; @@ -150,17 +153,17 @@ function StreamRow({ const senderPrefix = useMemo(() => { if (!latestChild) return null; - const isCurrentUser = latestChild.created_by_email === userEmail; + const isCurrentUser = latestChild.created_by_human_id === userId; if (isDM) { return isCurrentUser ? "You: " : null; } // Group stream if (isCurrentUser) return "You: "; - const emailPrefix = latestChild.created_by_email.split("@")[0]; - const capitalized = - emailPrefix.charAt(0).toUpperCase() + emailPrefix.slice(1); + const creator = network?.humans?.find((h) => h.id === latestChild.created_by_human_id); + const name = creator?.email_prefix ?? latestChild.created_by_human_id; + const capitalized = name.charAt(0).toUpperCase() + name.slice(1); return `${capitalized}: `; - }, [latestChild, userEmail, isDM]); + }, [latestChild, userId, isDM, network]); const subtitle = latestChild ? getMessagePreview(latestChild) @@ -237,19 +240,19 @@ function StreamRow({ // Generates the scopes for filtering particles to those that the user has access to function useVisibilityScopes( - userEmail?: string, + userId?: string, networkId?: string, ) { return useMemo(() => { let scopes: string[] = []; - if (userEmail) { - scopes.push(`human:${userEmail}`); + if (userId) { + scopes.push(`human:${userId}`); } if (networkId) { scopes.push(`network:${networkId}`); } return scopes; - }, [userEmail, networkId]); + }, [userId, networkId]); } interface ParticleListViewProps { @@ -262,7 +265,7 @@ interface ParticleListViewProps { export function ParticleListView({ path }: ParticleListViewProps) { const { networkId } = parseParticlePath(path); const user = useAuthStore((s) => s.user); - const visibilityScopes = useVisibilityScopes(user?.email, networkId); + const visibilityScopes = useVisibilityScopes(user?.id, networkId); const [recencyCutoff, setRecencyCutoff] = useState(() => { const d = new Date(); diff --git a/js/src/features/particles/stream-view.tsx b/js/src/features/particles/stream-view.tsx index 70cb5ee..fa891c1 100644 --- a/js/src/features/particles/stream-view.tsx +++ b/js/src/features/particles/stream-view.tsx @@ -202,7 +202,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {

@@ -267,7 +267,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
- + )} @@ -370,10 +370,11 @@ function NetworkBreadcrumbContent({ networkId }: { networkId: string }) { ); } -function ParticleBreadcrumbContent({ particle }: { particle: Particle }) { - const createdByEmail = particle.created_by_email; - const prefix = createdByEmail.split('@')[0]; - const initials = createdByEmail.slice(0, 2).toUpperCase(); +function ParticleBreadcrumbContent({ particle, networkId }: { particle: Particle; networkId: string }) { + const network = useNetwork(networkId); + const creator = network?.humans?.find((h) => h.id === particle.created_by_human_id); + const prefix = creator?.email_prefix ?? particle.created_by_human_id; + const initials = prefix.slice(0, 2).toUpperCase(); return ( - {seenUserEmails.map((email) => ( - + {seenHumans.map((human) => ( + - {email.split("@")[0].slice(0, 2)} + {human.email_prefix.slice(0, 2)} -

Seen by {email.split("@")[0]}

+

Seen by {human.email_prefix}

))} diff --git a/js/src/hooks/use-create-particle.ts b/js/src/hooks/use-create-particle.ts index 7561af8..a108450 100644 --- a/js/src/hooks/use-create-particle.ts +++ b/js/src/hooks/use-create-particle.ts @@ -8,7 +8,7 @@ interface CreateParticleParams { path: ParticlePath; type: T; properties: ParticlePropertiesMap[T]; - createdByEmail: string; + createdByHumanId: string; } export function useCreateParticle() { @@ -19,7 +19,7 @@ export function useCreateParticle() { collectionPath, params.type, params.properties, - params.createdByEmail, + params.createdByHumanId, ); const streamDocPath = toFirestoreDocPath(params.path); @@ -32,7 +32,7 @@ export function useCreateParticle() { type CreateStreamParticleParams = { networkId: string; properties: ParticlePropertiesMap["stream"]; - createdByEmail: string; + createdByHumanId: string; visibleTo?: string[]; }; @@ -45,7 +45,7 @@ export function useCreateStreamParticle() { networkCollectionPath, "stream", params.properties, - params.createdByEmail, + params.createdByHumanId, params.visibleTo, ); } diff --git a/js/src/lib/firestore-particles.ts b/js/src/lib/firestore-particles.ts index 12e7099..9414c4d 100644 --- a/js/src/lib/firestore-particles.ts +++ b/js/src/lib/firestore-particles.ts @@ -49,7 +49,7 @@ const particleConverter: FirestoreDataConverter = { type: raw.type, properties: raw.properties, created_at: (raw.created_at as Timestamp).toDate(), - created_by_email: raw.created_by_email, + created_by_human_id: raw.created_by_human_id, updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined, visible_to: raw.visible_to, playback_markers: raw.playback_markers @@ -68,7 +68,7 @@ const particleConverter: FirestoreDataConverter = { type: raw.type, properties: raw.properties, created_at: (raw.created_at as Timestamp).toDate(), - created_by_email: raw.created_by_email, + created_by_human_id: raw.created_by_human_id, updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined, visible_to: raw.visible_to, }); @@ -82,7 +82,7 @@ const particleConverter: FirestoreDataConverter = { type: raw.type, properties: raw.properties, created_at: (raw.created_at as Timestamp).toDate(), - created_by_email: raw.created_by_email, + created_by_human_id: raw.created_by_human_id, updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined, }); default: @@ -188,7 +188,7 @@ export async function createParticle( collectionPath: string, type: T, properties: ParticlePropertiesMap[T], - createdByEmail: string, + createdByHumanId: string, // Must be passed for container types visibleTo?: string[], ): Promise { @@ -203,7 +203,7 @@ export async function createParticle( type, properties, created_at: new Date(), - created_by_email: createdByEmail, + created_by_human_id: createdByHumanId, ...(visibleTo ? { visible_to: visibleTo } : {}), }); const ref = await addDoc(typedCollection(collectionPath), particle);