refactor: update api and client to reference humanIds

This commit is contained in:
talksik
2026-03-24 19:08:51 -07:00
parent 3bf3f16be7
commit 57a1cbc696
24 changed files with 666 additions and 416 deletions
+7
View File
@@ -111,6 +111,13 @@ func main() {
mux.Handle("GET /networks", withAuth(h.ListNetworks)) mux.Handle("GET /networks", withAuth(h.ListNetworks))
mux.Handle("GET /networks/{id}", withAuth(h.GetNetwork)) mux.Handle("GET /networks/{id}", withAuth(h.GetNetwork))
mux.Handle("POST /networks/{id}/members", withAuth(h.AddMembersToNetwork)) 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 // Particles
mux.Handle("GET /particles/{id}/download", withAuth(h.DownloadParticleMedia)) mux.Handle("GET /particles/{id}/download", withAuth(h.DownloadParticleMedia))
+28 -12
View File
@@ -2,6 +2,7 @@ package auth
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
@@ -37,14 +38,20 @@ func newSessionToken() (sessionToken, error) {
return typeid.New[sessionToken]() return typeid.New[sessionToken]()
} }
type Session struct {
Email string `json:"email"`
HumanId string `json:"human_id"`
}
type AuthService interface { type AuthService interface {
// RequestSignInCode generates a code and emails it to the provided email. // RequestSignInCode generates a code and emails it to the provided email.
// To retrieve a session, client must verify with 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 // VerifySignInCode returns ErrInvalidCode if incorrect code, otherwise creates a session.
VerifySignInCode(ctx context.Context, email, code string) (sessionToken string, err error) // 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 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 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
@@ -111,7 +118,7 @@ func (a *authServiceImpl) RequestSignInCode(ctx context.Context, email string) e
return nil 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) formattedEmail, err := utils.NormalizeEmail(email)
if err != nil { if err != nil {
return "", fmt.Errorf("invalid email: %w", err) 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) 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 { if err != nil {
return "", err return "", err
} }
@@ -142,16 +149,19 @@ func (a *authServiceImpl) VerifySignInCode(ctx context.Context, email, code stri
return token, nil return token, nil
} }
func (a *authServiceImpl) GetSession(ctx context.Context, token string) (string, error) { func (a *authServiceImpl) GetSession(ctx context.Context, token string) (*Session, error) {
email, err := a.redisClient.Get(ctx, token).Result() sessionInfo, err := a.redisClient.Get(ctx, token).Result()
if err != nil { if err != nil {
if errors.Is(err, redis.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 { 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 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) formattedEmail, err := utils.NormalizeEmail(email)
if err != nil { if err != nil {
return "", fmt.Errorf("invalid email: %w", err) 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) 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) return "", fmt.Errorf("error storing session: %w", err)
} }
+249 -67
View File
@@ -6,7 +6,6 @@ import (
"errors" "errors"
"log/slog" "log/slog"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/flowy-live/llink/internal/auth" "github.com/flowy-live/llink/internal/auth"
@@ -39,12 +38,10 @@ func NewHandler(authSvc auth.AuthService, humanSvc human.Service, networkSvc net
// Response DTOs // Response DTOs
type Human struct { 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"` Email string `json:"email"`
EmailPrefix string `json:"email_prefix"` 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 { type Network struct {
@@ -89,6 +86,20 @@ type MembersRequest struct {
Emails []string `json:"emails"` 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 // Depot DTOs
type PrepareUploadRequest struct { type PrepareUploadRequest struct {
@@ -162,7 +173,19 @@ func (h *Handler) SignIn(w http.ResponseWriter, r *http.Request) {
return 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 err != nil {
if errors.Is(err, auth.ErrInvalidCode) { if errors.Is(err, auth.ErrInvalidCode) {
http.Error(w, "invalid code", http.StatusUnauthorized) http.Error(w, "invalid code", http.StatusUnauthorized)
@@ -173,14 +196,6 @@ func (h *Handler) SignIn(w http.ResponseWriter, r *http.Request) {
return 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{ resp := SignInResponse{
Human: humanToDTO(hum), Human: humanToDTO(hum),
Token: token, Token: token,
@@ -237,7 +252,7 @@ func (h *Handler) GetCurrentHuman(w http.ResponseWriter, r *http.Request) {
// CreateNetwork creates a new network // 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) {
email, ok := middleware.EmailFromContext(r.Context()) humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok { if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized) http.Error(w, "unauthorized", http.StatusUnauthorized)
return return
@@ -249,13 +264,13 @@ func (h *Handler) CreateNetwork(w http.ResponseWriter, r *http.Request) {
return 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 err != nil {
if errors.Is(err, network.ErrInvalidName) { if errors.Is(err, network.ErrInvalidName) {
http.Error(w, "name cannot be empty", http.StatusBadRequest) http.Error(w, "name cannot be empty", http.StatusBadRequest)
return 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) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -274,15 +289,15 @@ func (h *Handler) CreateNetwork(w http.ResponseWriter, r *http.Request) {
// ListNetworks retrieves networks for the authenticated human // 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) {
email, ok := middleware.EmailFromContext(r.Context()) humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok { if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized) http.Error(w, "unauthorized", http.StatusUnauthorized)
return return
} }
networks, err := h.networkSvc.ListForEmail(r.Context(), email) networks, err := h.networkSvc.ListForHuman(r.Context(), humanId)
if err != nil { 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) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -303,7 +318,7 @@ func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) {
// GetNetwork retrieves a specific network // 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) {
email, ok := middleware.EmailFromContext(r.Context()) humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok { if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized) http.Error(w, "unauthorized", http.StatusUnauthorized)
return return
@@ -315,10 +330,9 @@ func (h *Handler) GetNetwork(w http.ResponseWriter, r *http.Request) {
return return
} }
// Check membership isMember, err := h.networkSvc.IsMember(r.Context(), networkID, humanId)
isMember, err := h.networkSvc.IsMember(r.Context(), networkID, email)
if err != nil { 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) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -349,9 +363,10 @@ 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 // 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) { func (h *Handler) AddMembersToNetwork(w http.ResponseWriter, r *http.Request) {
email, ok := middleware.EmailFromContext(r.Context()) humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok { if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized) http.Error(w, "unauthorized", http.StatusUnauthorized)
return return
@@ -363,10 +378,9 @@ func (h *Handler) AddMembersToNetwork(w http.ResponseWriter, r *http.Request) {
return return
} }
// Check membership isMember, err := h.networkSvc.IsMember(r.Context(), networkID, humanId)
isMember, err := h.networkSvc.IsMember(r.Context(), networkID, email)
if err != nil { 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) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -386,10 +400,42 @@ func (h *Handler) AddMembersToNetwork(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := h.networkSvc.AddMembers(r.Context(), networkID, req.EmailAddresses); err != nil { // Resolve emails: registered users become members, unregistered get invitations
slog.Error("failed to add members to network", "error", err, "network_id", networkID) var memberHumanIds []string
http.Error(w, "internal server error", http.StatusInternalServerError) var inviteEmails []string
return 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 // Return updated network
@@ -413,23 +459,22 @@ func (h *Handler) AddMembersToNetwork(w http.ResponseWriter, r *http.Request) {
// RemoveMemberFromNetwork removes a member from a network // RemoveMemberFromNetwork removes a member from a network
func (h *Handler) RemoveMemberFromNetwork(w http.ResponseWriter, r *http.Request) { func (h *Handler) RemoveMemberFromNetwork(w http.ResponseWriter, r *http.Request) {
email, ok := middleware.EmailFromContext(r.Context()) humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok { if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized) http.Error(w, "unauthorized", http.StatusUnauthorized)
return return
} }
networkID := r.PathValue("id") networkID := r.PathValue("id")
memberEmail := r.PathValue("email") memberHumanId := r.PathValue("humanId")
if networkID == "" || memberEmail == "" { if networkID == "" || memberHumanId == "" {
http.Error(w, "network id and member email are required", http.StatusBadRequest) http.Error(w, "network id and member humanId are required", http.StatusBadRequest)
return return
} }
// Check membership isMember, err := h.networkSvc.IsMember(r.Context(), networkID, humanId)
isMember, err := h.networkSvc.IsMember(r.Context(), networkID, email)
if err != nil { 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) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -438,8 +483,159 @@ func (h *Handler) RemoveMemberFromNetwork(w http.ResponseWriter, r *http.Request
return return
} }
if err := h.networkSvc.RemoveMember(r.Context(), networkID, memberEmail); err != nil { if err := h.networkSvc.RemoveMember(r.Context(), networkID, memberHumanId); err != nil {
slog.Error("failed to remove member from network", "error", err, "network_id", networkID, "member_email", memberEmail) 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) http.Error(w, "internal server error", http.StatusInternalServerError)
return 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 // PrepareUpload prepares an upload and 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) {
email, ok := middleware.EmailFromContext(r.Context()) humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok { if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized) http.Error(w, "unauthorized", http.StatusUnauthorized)
return return
@@ -493,10 +689,9 @@ func (h *Handler) PrepareUpload(w http.ResponseWriter, r *http.Request) {
return return
} }
// Check network membership isMember, err := h.networkSvc.IsMember(r.Context(), req.NetworkId, humanId)
isMember, err := h.networkSvc.IsMember(r.Context(), req.NetworkId, email)
if err != nil { 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) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -582,40 +777,27 @@ func (h *Handler) ConfirmUpload(w http.ResponseWriter, r *http.Request) {
func humanToDTO(h *human.Human) Human { func humanToDTO(h *human.Human) Human {
return Human{ return Human{
Id: utils.CreateOptionalString(h.ID), Id: h.ID,
Email: h.Email, Email: h.Email,
EmailPrefix: h.EmailPrefix, 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) { 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 { if err != nil {
return Network{}, err return Network{}, err
} }
// Get member humans humans := make([]Human, 0, len(n.MemberHumanIds))
humans := make([]Human, 0, len(n.MemberEmails)) for _, memberHumanId := range n.MemberHumanIds {
for _, email := range n.MemberEmails { hum, err := h.humanSvc.GetByID(ctx, memberHumanId)
hum, err := h.humanSvc.GetByEmail(ctx, email)
if err != nil { if err != nil {
if err == human.ErrNotFound { slog.Warn("failed to look up network member", "humanId", memberHumanId, "error", err)
humans = append(humans, Human{
Id: nil,
CreatedAt: nil,
Email: email,
EmailPrefix: emailPrefix(email),
})
}
continue continue
} else {
humans = append(humans, humanToDTO(hum))
} }
humans = append(humans, humanToDTO(hum))
} }
return Network{ return Network{
+10
View File
@@ -14,6 +14,8 @@ 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(ctx context.Context, id string) (*Human, error)
} }
type serviceImpl struct { type serviceImpl struct {
@@ -52,3 +54,11 @@ func (s *serviceImpl) GetByEmail(ctx context.Context, email string) (*Human, err
} }
return h, 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
}
+18 -3
View File
@@ -11,7 +11,10 @@ import (
type contextKey string type contextKey string
const emailContextKey contextKey = "email" const (
emailContextKey contextKey = "email"
humanIdContextKey contextKey = "humanId"
)
// WithEmail adds the email to the context // 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 {
@@ -24,6 +27,17 @@ func EmailFromContext(ctx context.Context) (string, bool) {
return email, ok 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 // 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 { func Auth(authSvc auth.AuthService) func(http.Handler) http.Handler {
return func(next 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 return
} }
email, err := authSvc.GetSession(r.Context(), token) session, err := authSvc.GetSession(r.Context(), token)
if err != nil { if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized) http.Error(w, "unauthorized", http.StatusUnauthorized)
return return
@@ -45,7 +59,8 @@ func Auth(authSvc auth.AuthService) func(http.Handler) http.Handler {
slog.Warn("failed to extend session", "error", err) 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)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
} }
+8 -2
View File
@@ -5,9 +5,15 @@ import "time"
type Network struct { type Network struct {
ID string ID string
Name string Name string
AdminEmail string AdminHumanId string
MemberEmails []string MemberHumanIds []string
OpenStreamCapacity int OpenStreamCapacity int
OpenStreamCount int OpenStreamCount int
CreatedAt time.Time CreatedAt time.Time
} }
type Invitation struct {
NetworkID string
Email string
CreatedAt time.Time
}
+91 -79
View File
@@ -26,18 +26,21 @@ func newNetworkID() (networkID, error) {
var errCapacityExceeded = errors.New("capacity exceeded") var errCapacityExceeded = errors.New("capacity exceeded")
type repository interface { 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) getByID(ctx context.Context, id string) (*Network, error)
updateName(ctx context.Context, id, name string) error updateName(ctx context.Context, id, name string) error
delete(ctx context.Context, id string) error delete(ctx context.Context, id string) error
addMember(ctx context.Context, networkID, email string) error addMember(ctx context.Context, networkID, humanId string) error
removeMember(ctx context.Context, networkID, email string) error removeMember(ctx context.Context, networkID, humanId string) error
getMemberEmails(ctx context.Context, networkID string) ([]string, error) getMemberHumanIds(ctx context.Context, networkID string) ([]string, error)
getNetworksForEmail(ctx context.Context, email string) ([]*Network, error) getNetworksForHuman(ctx context.Context, humanId string) ([]*Network, error)
isMember(ctx context.Context, networkID, email string) (bool, error) isMember(ctx context.Context, networkID, humanId string) (bool, error)
setOpenStreamCapacity(ctx context.Context, id string, capacity int) error
incrementOpenStreamCount(ctx context.Context, id string) error // Invitations
decrementOpenStreamCount(ctx context.Context, id string) error 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 { type repositoryImpl struct {
@@ -48,7 +51,7 @@ func newRepository(pool *pgxpool.Pool) repository {
return &repositoryImpl{pool: pool} 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() id, err := newNetworkID()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -56,24 +59,24 @@ func (r *repositoryImpl) create(ctx context.Context, name, adminEmail string) (*
var n Network var n Network
err = r.pool.QueryRow(ctx, err = r.pool.QueryRow(ctx,
`INSERT INTO networks (id, name, admin_email) VALUES ($1, $2, $3) `INSERT INTO networks (id, name, admin_human_id) VALUES ($1, $2, $3)
RETURNING id, name, admin_email, open_stream_capacity, open_stream_count, created_at`, RETURNING id, name, admin_human_id, open_stream_capacity, open_stream_count, created_at`,
id.String(), name, adminEmail, id.String(), name, adminHumanId,
).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 err != nil {
return nil, err return nil, err
} }
n.MemberEmails = []string{} n.MemberHumanIds = []string{}
return &n, nil return &n, nil
} }
func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Network, error) { func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Network, error) {
var n Network var n Network
err := r.pool.QueryRow(ctx, 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, 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 err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, errNotFound return nil, errNotFound
@@ -81,7 +84,7 @@ func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Network, erro
return nil, err return nil, err
} }
n.MemberEmails, err = r.getMemberEmails(ctx, id) n.MemberHumanIds, err = r.getMemberHumanIds(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -114,26 +117,26 @@ func (r *repositoryImpl) delete(ctx context.Context, id string) error {
return nil 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, _, err := r.pool.Exec(ctx,
`INSERT INTO network_members (network_id, email) VALUES ($1, $2) `INSERT INTO network_members (network_id, human_id) VALUES ($1, $2)
ON CONFLICT (network_id, email) DO NOTHING`, ON CONFLICT (network_id, human_id) DO NOTHING`,
networkID, email, networkID, humanId,
) )
return err 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, _, err := r.pool.Exec(ctx,
`DELETE FROM network_members WHERE network_id = $1 AND email = $2`, `DELETE FROM network_members WHERE network_id = $1 AND human_id = $2`,
networkID, email, networkID, humanId,
) )
return err 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, 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, networkID,
) )
if err != nil { if err != nil {
@@ -141,24 +144,24 @@ func (r *repositoryImpl) getMemberEmails(ctx context.Context, networkID string)
} }
defer rows.Close() defer rows.Close()
var emails []string var humanIds []string
for rows.Next() { for rows.Next() {
var email string var humanId string
if err := rows.Scan(&email); err != nil { if err := rows.Scan(&humanId); err != nil {
return nil, err 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, 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 FROM networks n
WHERE n.admin_email = $1 WHERE n.admin_human_id = $1
OR EXISTS (SELECT 1 FROM network_members nm WHERE nm.network_id = n.id AND nm.email = $1)`, OR EXISTS (SELECT 1 FROM network_members nm WHERE nm.network_id = n.id AND nm.human_id = $1)`,
email, humanId,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -168,7 +171,7 @@ func (r *repositoryImpl) getNetworksForEmail(ctx context.Context, email string)
var networks []*Network var networks []*Network
for rows.Next() { for rows.Next() {
var n Network 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 return nil, err
} }
networks = append(networks, &n) networks = append(networks, &n)
@@ -178,7 +181,7 @@ func (r *repositoryImpl) getNetworksForEmail(ctx context.Context, email string)
} }
for _, n := range networks { for _, n := range networks {
n.MemberEmails, err = r.getMemberEmails(ctx, n.ID) n.MemberHumanIds, err = r.getMemberHumanIds(ctx, n.ID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -187,66 +190,75 @@ func (r *repositoryImpl) getNetworksForEmail(ctx context.Context, email string)
return networks, nil 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 var isMember bool
err := r.pool.QueryRow(ctx, ` err := r.pool.QueryRow(ctx, `
SELECT EXISTS( SELECT EXISTS(
SELECT 1 FROM networks n SELECT 1 FROM networks n
LEFT JOIN network_members nm ON nm.network_id = n.id AND nm.email = $2 LEFT JOIN network_members nm ON nm.network_id = n.id AND nm.human_id = $2
WHERE n.id = $1 AND (n.admin_email = $2 OR nm.email IS NOT NULL) 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 return isMember, err
} }
func (r *repositoryImpl) setOpenStreamCapacity(ctx context.Context, id string, capacity int) error { // Invitation methods
result, err := r.pool.Exec(ctx,
`UPDATE networks SET open_stream_capacity = $1 WHERE id = $2`, func (r *repositoryImpl) createInvitation(ctx context.Context, networkID, email string) error {
capacity, id, _, 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
return err
}
if result.RowsAffected() == 0 {
return errNotFound
}
return nil
} }
func (r *repositoryImpl) incrementOpenStreamCount(ctx context.Context, id string) error { func (r *repositoryImpl) getInvitationsByEmail(ctx context.Context, email string) ([]*Invitation, error) {
result, err := r.pool.Exec(ctx, rows, err := r.pool.Query(ctx,
`UPDATE networks SET open_stream_count = open_stream_count + 1 `SELECT network_id, email, created_at FROM network_invitations WHERE email = $1`,
WHERE id = $1 AND open_stream_count < open_stream_capacity`, email,
id,
) )
if err != nil { if err != nil {
return err return nil, err
} }
if result.RowsAffected() == 0 { defer rows.Close()
// Check if network exists vs capacity exceeded
var exists bool var invitations []*Invitation
err := r.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM networks WHERE id = $1)`, id).Scan(&exists) for rows.Next() {
if err != nil { var inv Invitation
return err if err := rows.Scan(&inv.NetworkID, &inv.Email, &inv.CreatedAt); err != nil {
return nil, err
} }
if !exists { invitations = append(invitations, &inv)
return errNotFound
}
return errCapacityExceeded
} }
return nil return invitations, rows.Err()
} }
func (r *repositoryImpl) decrementOpenStreamCount(ctx context.Context, id string) error { func (r *repositoryImpl) getInvitationsByNetwork(ctx context.Context, networkID string) ([]*Invitation, error) {
result, err := r.pool.Exec(ctx, rows, err := r.pool.Query(ctx,
`UPDATE networks SET open_stream_count = GREATEST(0, open_stream_count - 1) WHERE id = $1`, `SELECT network_id, email, created_at FROM network_invitations WHERE network_id = $1`,
id, networkID,
) )
if err != nil { if err != nil {
return err return nil, err
} }
if result.RowsAffected() == 0 { defer rows.Close()
return errNotFound
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
} }
+76 -65
View File
@@ -15,23 +15,23 @@ var ErrInvalidName = errors.New("name cannot be empty")
var ErrCapacityExceeded = errors.New("active stream capacity exceeded") var ErrCapacityExceeded = errors.New("active stream capacity exceeded")
type Service interface { type Service interface {
// Create creates a network and adds adminEmail as the first member. Returns ErrInvalidName if name is empty. // Create creates a network and adds adminHumanId as the first member. Returns ErrInvalidName if name is empty.
Create(ctx context.Context, name, adminEmail string) (*Network, error) Create(ctx context.Context, name, adminHumanId string) (*Network, error)
// GetByID returns ErrNotFound if network doesn't exist. // GetByID returns ErrNotFound if network doesn't exist.
GetByID(ctx context.Context, id string) (*Network, error) GetByID(ctx context.Context, id 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
AddMembers(ctx context.Context, networkID string, emails []string) error AddMembers(ctx context.Context, networkID string, humanIds []string) error
RemoveMember(ctx context.Context, networkID, email string) error RemoveMember(ctx context.Context, networkID, humanId string) error
ListForEmail(ctx context.Context, email string) ([]*Network, error) ListForHuman(ctx context.Context, humanId string) ([]*Network, error)
IsMember(ctx context.Context, networkID, email string) (bool, error) IsMember(ctx context.Context, networkID, humanId string) (bool, error)
// SetOpenStreamCapacity sets the max open streams for a network. Returns ErrNotFound. // Invitations (email-based, for users who haven't registered yet)
SetOpenStreamCapacity(ctx context.Context, networkID string, capacity int) error InviteByEmail(ctx context.Context, networkID string, emails []string) error
// IncrementOpenStreamCount returns ErrNotFound or ErrCapacityExceeded. ListInvitationsForEmail(ctx context.Context, email string) ([]*Invitation, error)
IncrementOpenStreamCount(ctx context.Context, networkID string) error ListInvitationsForNetwork(ctx context.Context, networkID string) ([]*Invitation, error)
// DecrementOpenStreamCount returns ErrNotFound. AcceptInvitation(ctx context.Context, networkID, email, humanId string) error
DecrementOpenStreamCount(ctx context.Context, networkID string) error RevokeInvitation(ctx context.Context, networkID, email string) error
} }
type serviceImpl struct { type serviceImpl struct {
@@ -42,23 +42,18 @@ func NewService(pool *pgxpool.Pool) Service {
return &serviceImpl{repo: newRepository(pool)} 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) name = strings.TrimSpace(name)
if name == "" { if name == "" {
return nil, ErrInvalidName return nil, ErrInvalidName
} }
adminEmail, err := utils.NormalizeEmail(adminEmail) network, err := s.repo.create(ctx, name, adminHumanId)
if err != nil { if err != nil {
return nil, err return nil, err
} }
network, err := s.repo.create(ctx, name, adminEmail) err = s.AddMembers(ctx, network.ID, []string{adminHumanId})
if err != nil {
return nil, err
}
err = s.AddMembers(ctx, network.ID, []string{adminEmail})
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -87,69 +82,85 @@ func (s *serviceImpl) SetName(ctx context.Context, id, name string) error {
return err return err
} }
func (s *serviceImpl) AddMembers(ctx context.Context, networkID string, emails []string) error { func (s *serviceImpl) AddMembers(ctx context.Context, networkID string, humanIds []string) error {
for _, email := range emails { for _, humanId := range humanIds {
normalized, err := utils.NormalizeEmail(email) if humanId == "" {
if err != nil { return fmt.Errorf("invalid humanId")
return fmt.Errorf("invalid email: %w", err)
} }
if err := s.repo.addMember(ctx, networkID, normalized); err != nil { if err := s.repo.addMember(ctx, networkID, humanId); err != nil {
return err return err
} }
} }
return nil return nil
} }
func (s *serviceImpl) RemoveMember(ctx context.Context, networkID, email string) error { func (s *serviceImpl) RemoveMember(ctx context.Context, networkID, humanId string) error {
email, err := utils.NormalizeEmail(email) if humanId == "" {
if err != nil { return fmt.Errorf("invalid humanId")
return fmt.Errorf("invalid email: %w", err)
} }
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) { func (s *serviceImpl) ListForHuman(ctx context.Context, humanId string) ([]*Network, error) {
email, err := utils.NormalizeEmail(email) 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 { if err != nil {
return nil, fmt.Errorf("invalid email: %w", err) 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) { func (s *serviceImpl) ListInvitationsForNetwork(ctx context.Context, networkID string) ([]*Invitation, error) {
email, err := utils.NormalizeEmail(email) 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 { 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 { func (s *serviceImpl) RevokeInvitation(ctx context.Context, networkID, email string) error {
if capacity < 0 { normalized, err := utils.NormalizeEmail(email)
capacity = 0 if err != nil {
return fmt.Errorf("invalid email: %w", err)
} }
err := s.repo.setOpenStreamCapacity(ctx, networkID, capacity) return s.repo.deleteInvitation(ctx, networkID, normalized)
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
} }
+83 -16
View File
@@ -25,12 +25,17 @@ func TestNetworkService(t *testing.T) {
ctx := context.Background() ctx := context.Background()
svc := network.NewService(dbPool) svc := network.NewService(dbPool)
adminHumanId := "human_admin123"
member1HumanId := "human_member1abc"
member2HumanId := "human_member2def"
strangerHumanId := "human_stranger789"
// Test Create // Test Create
createdNetwork, err := svc.Create(ctx, "Test Network", "[email protected]") createdNetwork, err := svc.Create(ctx, "Test Network", adminHumanId)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotEmpty(t, createdNetwork.ID) assert.NotEmpty(t, createdNetwork.ID)
assert.Equal(t, "Test Network", createdNetwork.Name) assert.Equal(t, "Test Network", createdNetwork.Name)
assert.Equal(t, "[email protected]", createdNetwork.AdminEmail) assert.Equal(t, adminHumanId, createdNetwork.AdminHumanId)
assert.NotZero(t, createdNetwork.CreatedAt) assert.NotZero(t, createdNetwork.CreatedAt)
// Test GetByID // Test GetByID
@@ -38,7 +43,7 @@ func TestNetworkService(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, createdNetwork.ID, foundNetwork.ID) assert.Equal(t, createdNetwork.ID, foundNetwork.ID)
assert.Equal(t, createdNetwork.Name, foundNetwork.Name) 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 // Test GetByID with non-existent id
_, err = svc.GetByID(ctx, "network_nonexistent") _, err = svc.GetByID(ctx, "network_nonexistent")
@@ -60,45 +65,45 @@ func TestNetworkService(t *testing.T) {
assert.ErrorIs(t, err, network.ErrNotFound) assert.ErrorIs(t, err, network.ErrNotFound)
// Test AddMembers // Test AddMembers
err = svc.AddMembers(ctx, createdNetwork.ID, []string{"[email protected]", "[email protected]"}) err = svc.AddMembers(ctx, createdNetwork.ID, []string{member1HumanId, member2HumanId})
assert.NoError(t, err) assert.NoError(t, err)
// Test ListForEmail - should find network for admin // Test ListForHuman - should find network for admin
networks, err := svc.ListForEmail(ctx, "[email protected]") networks, err := svc.ListForHuman(ctx, adminHumanId)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, networks, 1) assert.Len(t, networks, 1)
assert.Equal(t, createdNetwork.ID, networks[0].ID) assert.Equal(t, createdNetwork.ID, networks[0].ID)
// Test ListForEmail - should find network for member // Test ListForHuman - should find network for member
networks, err = svc.ListForEmail(ctx, "[email protected]") networks, err = svc.ListForHuman(ctx, member1HumanId)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, networks, 1) assert.Len(t, networks, 1)
assert.Equal(t, createdNetwork.ID, networks[0].ID) assert.Equal(t, createdNetwork.ID, networks[0].ID)
// Test ListForEmail - should return empty for non-member // Test ListForHuman - should return empty for non-member
networks, err = svc.ListForEmail(ctx, "[email protected]") networks, err = svc.ListForHuman(ctx, strangerHumanId)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, networks, 0) assert.Len(t, networks, 0)
// Test RemoveMember // Test RemoveMember
err = svc.RemoveMember(ctx, createdNetwork.ID, "[email protected]") err = svc.RemoveMember(ctx, createdNetwork.ID, member1HumanId)
assert.NoError(t, err) assert.NoError(t, err)
// Verify member was removed // Verify member was removed
networks, err = svc.ListForEmail(ctx, "[email protected]") networks, err = svc.ListForHuman(ctx, member1HumanId)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, networks, 0) assert.Len(t, networks, 0)
// member2 should still have access // member2 should still have access
networks, err = svc.ListForEmail(ctx, "[email protected]") networks, err = svc.ListForHuman(ctx, member2HumanId)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, networks, 1) assert.Len(t, networks, 1)
// Create another network and verify ListForEmail returns multiple // Create another network and verify ListForHuman returns multiple
network2, err := svc.Create(ctx, "Second Network", "[email protected]") network2, err := svc.Create(ctx, "Second Network", member2HumanId)
assert.NoError(t, err) assert.NoError(t, err)
networks, err = svc.ListForEmail(ctx, "[email protected]") networks, err = svc.ListForHuman(ctx, member2HumanId)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, networks, 2) assert.Len(t, networks, 2)
@@ -107,3 +112,65 @@ func TestNetworkService(t *testing.T) {
assert.Contains(t, networkIDs, createdNetwork.ID) assert.Contains(t, networkIDs, createdNetwork.ID)
assert.Contains(t, networkIDs, network2.ID) assert.Contains(t, networkIDs, network2.ID)
} }
func TestNetworkInvitations(t *testing.T) {
ctx := context.Background()
svc := network.NewService(dbPool)
adminHumanId := "human_invtest_admin"
inviteeEmail := "[email protected]"
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 := "[email protected]"
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)
}
+3 -42
View File
@@ -4,7 +4,6 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"log/slog"
"time" "time"
"github.com/flowy-live/llink/internal/network" "github.com/flowy-live/llink/internal/network"
@@ -14,6 +13,7 @@ import (
const defaultPageSize = 50 const defaultPageSize = 50
// NOTE: this service is deprecated as we use firestore for particle data
type Service interface { type Service interface {
// Create creates a new particle. Caller must be a network member (verified by handler). // Create creates a new particle. Caller must be a network member (verified by handler).
// Returns ErrInvalidType, ErrInvalidData, ErrMembersRequired, ErrInvalidParent, ErrAccessDenied, or ErrCapacityExceeded. // 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 return nil, err
} }
p.Data = data 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 // Create the particle
created, err := s.repo.create(ctx, p) created, err := s.repo.create(ctx, p)
if err != nil { 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 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 // 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 err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
return ErrNotFound return ErrNotFound
@@ -342,13 +327,6 @@ func (s *serviceImpl) Delete(ctx context.Context, id, requesterEmail string) err
return 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) err = s.repo.delete(ctx, id)
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
return ErrNotFound return ErrNotFound
@@ -454,30 +432,14 @@ func (s *serviceImpl) OpenStream(ctx context.Context, id, requesterEmail string)
return ErrStreamAlreadyOpen 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 // 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 {
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 return err
} }
err = s.repo.update(ctx, id, newData, time.Now()) err = s.repo.update(ctx, id, newData, time.Now())
if err != nil { 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) { if errors.Is(err, errNotFound) {
return ErrNotFound return ErrNotFound
} }
@@ -536,8 +498,7 @@ func (s *serviceImpl) CloseStream(ctx context.Context, id, requesterEmail string
return err return err
} }
// Decrement capacity return nil
return s.networkSvc.DecrementOpenStreamCount(ctx, p.NetworkID)
} }
func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode VisibilityMode, requesterEmail string) error { func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode VisibilityMode, requesterEmail string) error {
-52
View File
@@ -70,58 +70,6 @@ func TestParticleService_CreateAndGet(t *testing.T) {
// Service assumes caller is already verified as network member // 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", "[email protected]")
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, "[email protected]")
assert.NoError(t, err)
// Create second stream - should succeed
input.Data = json.RawMessage(`{"name":"Stream 2","status":"open"}`)
stream2, err := svc.Create(ctx, input, "[email protected]")
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, "[email protected]")
assert.Error(t, err)
assert.ErrorIs(t, err, particle.ErrCapacityExceeded)
// Close a stream
err = svc.CloseStream(ctx, stream1.ID, "[email protected]")
assert.NoError(t, err)
// Now we can create another stream
stream3, err := svc.Create(ctx, input, "[email protected]")
assert.NoError(t, err)
assert.NotEmpty(t, stream3.ID)
// Verify stream2 is still open
found, err := svc.GetByID(ctx, stream2.ID, "[email protected]")
assert.NoError(t, err)
assert.Equal(t, string(particle.StreamStatusOpen), getStreamStatus(found.Data))
// Verify stream1 is closed
found, err = svc.GetByID(ctx, stream1.ID, "[email protected]")
assert.NoError(t, err)
assert.Equal(t, string(particle.StreamStatusClosed), getStreamStatus(found.Data))
}
func TestParticleService_NestedParticles(t *testing.T) { func TestParticleService_NestedParticles(t *testing.T) {
ctx := context.Background() ctx := context.Background()
networkSvc := network.NewService(dbPool) networkSvc := network.NewService(dbPool)
+3 -3
View File
@@ -1,13 +1,13 @@
CREATE TABLE networks ( CREATE TABLE networks (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
admin_email VARCHAR(255) NOT NULL, admin_human_id VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW() created_at TIMESTAMPTZ DEFAULT NOW()
); );
CREATE TABLE network_members ( CREATE TABLE network_members (
network_id TEXT NOT NULL REFERENCES networks(id) ON DELETE CASCADE, 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(), joined_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (network_id, email) PRIMARY KEY (network_id, human_id)
); );
@@ -0,0 +1 @@
DROP TABLE IF EXISTS network_invitations;
@@ -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)
);
+8 -7
View File
@@ -1,8 +1,8 @@
import { z } from "zod"; import { z } from "zod";
export const HumanSchema = z.object({ export const HumanSchema = z.object({
id: z.string().nullable(), id: z.string(),
created_at: z.coerce.date().nullable(), created_at: z.coerce.date(),
email: z.string().email(), email: z.string().email(),
email_prefix: z.string(), email_prefix: z.string(),
}); });
@@ -101,7 +101,8 @@ export const QuestPropertiesSchema = z.object({
title: z.string(), title: z.string(),
description: z.string(), description: z.string(),
status: z.string().optional(), status: z.string().optional(),
assigned_to: z.string().email().optional(), // humanId
assigned_to: z.string().optional(),
}); });
export type QuestProperties = z.infer<typeof QuestPropertiesSchema>; export type QuestProperties = z.infer<typeof QuestPropertiesSchema>;
@@ -126,15 +127,15 @@ export interface ParticlePropertiesMap {
const ParticleBaseSchema = z.object({ const ParticleBaseSchema = z.object({
id: z.string(), id: z.string(),
created_at: z.coerce.date(), created_at: z.coerce.date(),
created_by_email: z.string().email(), created_by_human_id: z.string(),
updated_at: z.coerce.date().optional(), updated_at: z.coerce.date().optional(),
}); });
export const ParticleSchema = z.discriminatedUnion("type", [ export const ParticleSchema = z.discriminatedUnion("type", [
ParticleBaseSchema.extend({ ParticleBaseSchema.extend({
type: z.literal("stream"), properties: StreamPropertiesSchema, type: z.literal("stream"), properties: StreamPropertiesSchema,
// e.g. ["human:[email protected]", "human:[email protected]"] - 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 // e.g. ["network:xywx"] - visible to everyone in the network
visible_to: z.array(z.string()), visible_to: z.array(z.string()),
// Marks human_id to their `playback_position_at`: where they left off in a conversation // 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(), playback_markers: z.record(z.string(), z.coerce.date()).optional(),
@@ -144,7 +145,7 @@ export const ParticleSchema = z.discriminatedUnion("type", [
}), }),
ParticleBaseSchema.extend({ ParticleBaseSchema.extend({
type: z.literal("folder"), properties: FolderPropertiesSchema, type: z.literal("folder"), properties: FolderPropertiesSchema,
// e.g. ["human:[email protected]", "human:[email protected]"] - 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 // e.g. ["network:123"] - visible to everyone in the network
visible_to: z.array(z.string()), visible_to: z.array(z.string()),
}), }),
+9 -9
View File
@@ -42,7 +42,7 @@ export function ComposeOverlay({
const [reviewMimeType, setReviewMimeType] = useState<string | null>(null); const [reviewMimeType, setReviewMimeType] = useState<string | null>(null);
const recordingMode = useMediaSettingsStore((s) => s.recordingMode); const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
const userEmail = useAuthStore((s) => s.user?.email); const userId = useAuthStore((s) => s.user?.id);
const createParticle = useCreateParticle(); const createParticle = useCreateParticle();
const createStream = useCreateStreamParticle(); const createStream = useCreateStreamParticle();
@@ -113,7 +113,7 @@ export function ComposeOverlay({
const createChildParticle = useCallback( const createChildParticle = useCallback(
async (path: ParticlePath) => { async (path: ParticlePath) => {
if (!userEmail) return; if (!userId) return;
let particleId = ''; let particleId = '';
if (textContent.trim()) { if (textContent.trim()) {
@@ -121,7 +121,7 @@ export function ComposeOverlay({
path, path,
type: "text", type: "text",
properties: { content: textContent }, properties: { content: textContent },
createdByEmail: userEmail, createdByHumanId: userId,
}); });
} else if (reviewBlob && reviewMimeType) { } else if (reviewBlob && reviewMimeType) {
const { object_id, size_bytes } = await uploadMedia( const { object_id, size_bytes } = await uploadMedia(
@@ -138,14 +138,14 @@ export function ComposeOverlay({
duration_ms: reviewDurationMs, duration_ms: reviewDurationMs,
size_bytes, size_bytes,
}, },
createdByEmail: userEmail, createdByHumanId: userId,
}); });
} }
onParticleCreated?.(particleId); onParticleCreated?.(particleId);
}, },
[ [
userEmail, userId,
textContent, textContent,
reviewBlob, reviewBlob,
reviewMimeType, reviewMimeType,
@@ -158,7 +158,7 @@ export function ComposeOverlay({
// Reply mode: create particle directly under targetPath // Reply mode: create particle directly under targetPath
const onSubmitReply = useEffectEvent(async () => { const onSubmitReply = useEffectEvent(async () => {
if (!targetPath || !userEmail || stepRef.current === "submitting") return; if (!targetPath || !userId || stepRef.current === "submitting") return;
setStepSync("submitting"); setStepSync("submitting");
await createChildParticle(targetPath); await createChildParticle(targetPath);
cancel(); cancel();
@@ -167,7 +167,7 @@ export function ComposeOverlay({
// New stream mode: create stream + first child // New stream mode: create stream + first child
const handleStreamSubmit = useCallback( const handleStreamSubmit = useCallback(
async (streamName: string, visibleTo: string[]) => { async (streamName: string, visibleTo: string[]) => {
if (!userEmail || stepRef.current === "submitting") return; if (!userId || stepRef.current === "submitting") return;
setStepSync("submitting"); setStepSync("submitting");
const streamId = await createStream.mutateAsync({ const streamId = await createStream.mutateAsync({
@@ -176,7 +176,7 @@ export function ComposeOverlay({
name: streamName, name: streamName,
status: "open", status: "open",
}, },
createdByEmail: userEmail, createdByHumanId: userId,
visibleTo, visibleTo,
}); });
@@ -185,7 +185,7 @@ export function ComposeOverlay({
cancel(); cancel();
}, },
[networkId, userEmail, createParticle, createChildParticle, cancel], [networkId, userId, createParticle, createChildParticle, cancel],
); );
// --- Keyboard handling --- // --- Keyboard handling ---
@@ -24,16 +24,16 @@ export function ConfigureStreamStep({
const [name, setName] = useState(() => generateRandomName()); const [name, setName] = useState(() => generateRandomName());
const [everyone, setEveryone] = useState(true); const [everyone, setEveryone] = useState(true);
const userEmail = useAuthStore((s) => s.user?.email); const userId = useAuthStore((s) => s.user?.id);
const [selectedEmails, setSelectedEmails] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(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) => { const toggleMember = useCallback((id: string) => {
setSelectedEmails((prev) => { setSelectedIds((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(email)) next.delete(email); if (next.has(id)) next.delete(id);
else next.add(email); else next.add(id);
return next; return next;
}); });
}, []); }, []);
@@ -41,8 +41,8 @@ export function ConfigureStreamStep({
const buildVisibleTo = useCallback((): string[] => { const buildVisibleTo = useCallback((): string[] => {
if (everyone && networkId) return [`network:${networkId}`]; if (everyone && networkId) return [`network:${networkId}`];
return Array.from(removeDuplicates([...selectedEmails, userEmail])).map((e) => `human:${e}`); return Array.from(removeDuplicates([...selectedIds, userId].filter(Boolean) as string[])).map((id) => `human:${id}`);
}, [everyone, networkId, selectedEmails, userEmail]); }, [everyone, networkId, selectedIds, userId]);
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(() => {
if (!name.trim() || !networkId) return; if (!name.trim() || !networkId) return;
@@ -117,16 +117,16 @@ export function ConfigureStreamStep({
<ScrollArea className="max-h-48"> <ScrollArea className="max-h-48">
<div className="space-y-0.5 p-1"> <div className="space-y-0.5 p-1">
{members.map((member, index) => { {members.map((member, index) => {
const isSelected = selectedEmails.has(member.email); const isSelected = selectedIds.has(member.id);
const initials = member.email_prefix const initials = member.email_prefix
.slice(0, 2) .slice(0, 2)
.toUpperCase(); .toUpperCase();
return ( return (
<div <div
key={member.email} key={member.id}
role="button" role="button"
onClick={() => toggleMember(member.email)} onClick={() => toggleMember(member.id)}
className={cn( className={cn(
"flex w-full cursor-pointer items-center gap-2.5 rounded px-3 py-1.5 text-left text-sm transition-colors", "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", "text-white/70 hover:bg-white/5",
+1 -1
View File
@@ -77,7 +77,7 @@ function TopBar() {
const { networkId, "*": rest } = useParams(); const { networkId, "*": rest } = useParams();
const segments = [networkId, ...rest?.split("/") ?? []].filter(Boolean); 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); const { data: particle } = useParticle(path);
@@ -5,6 +5,7 @@ import { useDownloadUrl } from "@/hooks/use-download-url";
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Small } from "@/components/ui/typography"; import { Small } from "@/components/ui/typography";
import { getInitials } from "@/lib/utils"; import { getInitials } from "@/lib/utils";
import { useNetwork } from "@/hooks/use-networks";
interface AutoplayOverlayProps { interface AutoplayOverlayProps {
networkId: string; networkId: string;
@@ -17,12 +18,14 @@ export function AutoplayOverlay({ networkId }: AutoplayOverlayProps) {
const { data: url } = useDownloadUrl(activeParticle?.properties.object_id); const { data: url } = useDownloadUrl(activeParticle?.properties.object_id);
const navigate = useNavigate(); const navigate = useNavigate();
const network = useNetwork(networkId);
if (!activeParticle || !url) return null; if (!activeParticle || !url) return null;
const isVideo = activeParticle.properties.mime_type?.startsWith("video/"); const isVideo = activeParticle.properties.mime_type?.startsWith("video/");
const senderEmail = activeParticle.created_by_email; const creator = network?.humans?.find((h) => h.id === activeParticle.created_by_human_id);
const senderInitials = getInitials(senderEmail); const senderInitials = creator ? getInitials(creator.email) : activeParticle.created_by_human_id.slice(0, 2).toUpperCase();
const senderName = senderEmail.split("@")[0]; const senderName = creator?.email_prefix ?? activeParticle.created_by_human_id;
const handleClick = () => { const handleClick = () => {
stop(); stop();
@@ -51,7 +51,7 @@ export function FallbackParticleView({ particle }: FallbackParticleViewProps) {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
From {particle.created_by_email} From {particle.created_by_human_id}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -30,6 +30,7 @@ import type { Particle, StreamProperties } from "@/api/types";
import { useAutoplayStore } from "@/stores/autoplay-store"; import { useAutoplayStore } from "@/stores/autoplay-store";
import { where, Timestamp } from "firebase/firestore"; import { where, Timestamp } from "firebase/firestore";
import { RECENCY_WINDOW_HOURS } from "@/lib/constants"; import { RECENCY_WINDOW_HOURS } from "@/lib/constants";
import { useNetwork } from "@/hooks/use-networks";
function getParticleTypeIcon(particle: Particle): LucideIcon { function getParticleTypeIcon(particle: Particle): LucideIcon {
switch (particle.type) { switch (particle.type) {
@@ -88,7 +89,7 @@ function StreamRow({
const { latestChild } = useLiveLatestChild(streamPath); const { latestChild } = useLiveLatestChild(streamPath);
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const userId = user?.id ?? ""; const userId = user?.id ?? "";
const userEmail = user?.email ?? ""; const network = useNetwork(networkId);
// Autoplay: trigger only when latestChild *changes* to a new media particle, // 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 // 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; if (latestChild.id === settledIdRef.current) return;
settledIdRef.current = latestChild.id; settledIdRef.current = latestChild.id;
if (latestChild.created_by_email === userEmail) return; if (latestChild.created_by_human_id === userId) return;
if (latestChild.type === "text") { if (latestChild.type === "text") {
new Audio(beepSound).play().catch(() => {}); new Audio(beepSound).play().catch(() => {});
@@ -125,20 +126,22 @@ function StreamRow({
const initials = useMemo(() => { const initials = useMemo(() => {
if (isDM) { if (isDM) {
const otherEntry = particle.visible_to.find( const otherEntry = particle.visible_to.find(
(v) => v !== `human:${userEmail}`, (v) => v !== `human:${userId}`,
); );
if (otherEntry) { if (otherEntry) {
const otherEmail = otherEntry.replace("human:", ""); const otherId = otherEntry.replace("human:", "");
return getInitials(otherEmail); const otherHuman = network?.humans?.find((h) => h.id === otherId);
if (otherHuman) return getInitials(otherHuman.email);
} }
} }
if (latestChild) { 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(); 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(() => { const isUnseen = useMemo(() => {
if (!latestChild) return false; if (!latestChild) return false;
@@ -150,17 +153,17 @@ function StreamRow({
const senderPrefix = useMemo(() => { const senderPrefix = useMemo(() => {
if (!latestChild) return null; if (!latestChild) return null;
const isCurrentUser = latestChild.created_by_email === userEmail; const isCurrentUser = latestChild.created_by_human_id === userId;
if (isDM) { if (isDM) {
return isCurrentUser ? "You: " : null; return isCurrentUser ? "You: " : null;
} }
// Group stream // Group stream
if (isCurrentUser) return "You: "; if (isCurrentUser) return "You: ";
const emailPrefix = latestChild.created_by_email.split("@")[0]; const creator = network?.humans?.find((h) => h.id === latestChild.created_by_human_id);
const capitalized = const name = creator?.email_prefix ?? latestChild.created_by_human_id;
emailPrefix.charAt(0).toUpperCase() + emailPrefix.slice(1); const capitalized = name.charAt(0).toUpperCase() + name.slice(1);
return `${capitalized}: `; return `${capitalized}: `;
}, [latestChild, userEmail, isDM]); }, [latestChild, userId, isDM, network]);
const subtitle = latestChild const subtitle = latestChild
? getMessagePreview(latestChild) ? getMessagePreview(latestChild)
@@ -237,19 +240,19 @@ function StreamRow({
// Generates the scopes for filtering particles to those that the user has access to // Generates the scopes for filtering particles to those that the user has access to
function useVisibilityScopes( function useVisibilityScopes(
userEmail?: string, userId?: string,
networkId?: string, networkId?: string,
) { ) {
return useMemo(() => { return useMemo(() => {
let scopes: string[] = []; let scopes: string[] = [];
if (userEmail) { if (userId) {
scopes.push(`human:${userEmail}`); scopes.push(`human:${userId}`);
} }
if (networkId) { if (networkId) {
scopes.push(`network:${networkId}`); scopes.push(`network:${networkId}`);
} }
return scopes; return scopes;
}, [userEmail, networkId]); }, [userId, networkId]);
} }
interface ParticleListViewProps { interface ParticleListViewProps {
@@ -262,7 +265,7 @@ interface ParticleListViewProps {
export function ParticleListView({ path }: ParticleListViewProps) { export function ParticleListView({ path }: ParticleListViewProps) {
const { networkId } = parseParticlePath(path); const { networkId } = parseParticlePath(path);
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const visibilityScopes = useVisibilityScopes(user?.email, networkId); const visibilityScopes = useVisibilityScopes(user?.id, networkId);
const [recencyCutoff, setRecencyCutoff] = useState(() => { const [recencyCutoff, setRecencyCutoff] = useState(() => {
const d = new Date(); const d = new Date();
+17 -16
View File
@@ -202,7 +202,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
</p> </p>
<ControlsIndicator type="reply" /> <ControlsIndicator type="reply" />
<ComposeOverlay <ComposeOverlay
networkId={networkId!} networkId={networkId}
targetPath={path} targetPath={path}
onActiveChange={setComposeActive} onActiveChange={setComposeActive}
/> />
@@ -267,7 +267,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
</div> </div>
<ComposeOverlay <ComposeOverlay
networkId={networkId!} networkId={networkId}
targetPath={path} targetPath={path}
onActiveChange={setComposeActive} onActiveChange={setComposeActive}
onParticleCreated={onLocalParticleCreated} onParticleCreated={onLocalParticleCreated}
@@ -333,7 +333,7 @@ function TopBar({ networkId, particle, streamParticle }: { networkId: string; pa
<> <>
<BreadcrumbSeparator /> <BreadcrumbSeparator />
<BreadcrumbItem className="text-xs"> <BreadcrumbItem className="text-xs">
<BreadcrumbPage><ParticleBreadcrumbContent particle={particle} /></BreadcrumbPage> <BreadcrumbPage><ParticleBreadcrumbContent particle={particle} networkId={networkId} /></BreadcrumbPage>
</BreadcrumbItem> </BreadcrumbItem>
</> </>
)} )}
@@ -370,10 +370,11 @@ function NetworkBreadcrumbContent({ networkId }: { networkId: string }) {
); );
} }
function ParticleBreadcrumbContent({ particle }: { particle: Particle }) { function ParticleBreadcrumbContent({ particle, networkId }: { particle: Particle; networkId: string }) {
const createdByEmail = particle.created_by_email; const network = useNetwork(networkId);
const prefix = createdByEmail.split('@')[0]; const creator = network?.humans?.find((h) => h.id === particle.created_by_human_id);
const initials = createdByEmail.slice(0, 2).toUpperCase(); const prefix = creator?.email_prefix ?? particle.created_by_human_id;
const initials = prefix.slice(0, 2).toUpperCase();
return ( return (
<span className="flex <span className="flex
@@ -398,27 +399,27 @@ const SeenIndicator = ({ stream, currentParticle, networkId }: { stream: Particl
.filter(([userId, timestamp]) => timestamp.getTime() >= currentParticle.created_at.getTime() && userId !== authedUser?.id) .filter(([userId, timestamp]) => timestamp.getTime() >= currentParticle.created_at.getTime() && userId !== authedUser?.id)
.map(([userId, _]) => userId); .map(([userId, _]) => userId);
const seenUserEmails = seenUserIds const seenHumans = seenUserIds
.map((userId) => network?.humans?.find((h) => h.id === userId)?.email) .map((userId) => network?.humans?.find((h) => h.id === userId))
.filter((email): email is string => !!email && email !== currentParticle.created_by_email); .filter((h): h is NonNullable<typeof h> => !!h && h.id !== currentParticle.created_by_human_id);
if (seenUserIds.length === 0) return null; if (seenHumans.length === 0) return null;
return ( return (
<> <>
{seenUserEmails.length > 0 && "Seen by"} {"Seen by"}
<AvatarGroup> <AvatarGroup>
{seenUserEmails.map((email) => ( {seenHumans.map((human) => (
<Tooltip key={email}> <Tooltip key={human.id}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Avatar size="sm"> <Avatar size="sm">
<AvatarFallback> <AvatarFallback>
{email.split("@")[0].slice(0, 2)} {human.email_prefix.slice(0, 2)}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Seen by {email.split("@")[0]}</p> <p>Seen by {human.email_prefix}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
))} ))}
+4 -4
View File
@@ -8,7 +8,7 @@ interface CreateParticleParams<T extends ParticleType = ParticleType> {
path: ParticlePath; path: ParticlePath;
type: T; type: T;
properties: ParticlePropertiesMap[T]; properties: ParticlePropertiesMap[T];
createdByEmail: string; createdByHumanId: string;
} }
export function useCreateParticle() { export function useCreateParticle() {
@@ -19,7 +19,7 @@ export function useCreateParticle() {
collectionPath, collectionPath,
params.type, params.type,
params.properties, params.properties,
params.createdByEmail, params.createdByHumanId,
); );
const streamDocPath = toFirestoreDocPath(params.path); const streamDocPath = toFirestoreDocPath(params.path);
@@ -32,7 +32,7 @@ export function useCreateParticle() {
type CreateStreamParticleParams = { type CreateStreamParticleParams = {
networkId: string; networkId: string;
properties: ParticlePropertiesMap["stream"]; properties: ParticlePropertiesMap["stream"];
createdByEmail: string; createdByHumanId: string;
visibleTo?: string[]; visibleTo?: string[];
}; };
@@ -45,7 +45,7 @@ export function useCreateStreamParticle() {
networkCollectionPath, networkCollectionPath,
"stream", "stream",
params.properties, params.properties,
params.createdByEmail, params.createdByHumanId,
params.visibleTo, params.visibleTo,
); );
} }
+5 -5
View File
@@ -49,7 +49,7 @@ const particleConverter: FirestoreDataConverter<Particle> = {
type: raw.type, type: raw.type,
properties: raw.properties, properties: raw.properties,
created_at: (raw.created_at as Timestamp).toDate(), 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, updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined,
visible_to: raw.visible_to, visible_to: raw.visible_to,
playback_markers: raw.playback_markers playback_markers: raw.playback_markers
@@ -68,7 +68,7 @@ const particleConverter: FirestoreDataConverter<Particle> = {
type: raw.type, type: raw.type,
properties: raw.properties, properties: raw.properties,
created_at: (raw.created_at as Timestamp).toDate(), 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, updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined,
visible_to: raw.visible_to, visible_to: raw.visible_to,
}); });
@@ -82,7 +82,7 @@ const particleConverter: FirestoreDataConverter<Particle> = {
type: raw.type, type: raw.type,
properties: raw.properties, properties: raw.properties,
created_at: (raw.created_at as Timestamp).toDate(), 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, updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined,
}); });
default: default:
@@ -188,7 +188,7 @@ export async function createParticle<T extends ParticleType>(
collectionPath: string, collectionPath: string,
type: T, type: T,
properties: ParticlePropertiesMap[T], properties: ParticlePropertiesMap[T],
createdByEmail: string, createdByHumanId: string,
// Must be passed for container types // Must be passed for container types
visibleTo?: string[], visibleTo?: string[],
): Promise<string> { ): Promise<string> {
@@ -203,7 +203,7 @@ export async function createParticle<T extends ParticleType>(
type, type,
properties, properties,
created_at: new Date(), created_at: new Date(),
created_by_email: createdByEmail, created_by_human_id: createdByHumanId,
...(visibleTo ? { visible_to: visibleTo } : {}), ...(visibleTo ? { visible_to: visibleTo } : {}),
}); });
const ref = await addDoc(typedCollection(collectionPath), particle); const ref = await addDoc(typedCollection(collectionPath), particle);