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
This commit was merged in pull request #73.
This commit is contained in:
Arjun Patel
2026-03-24 19:48:02 -07:00
committed by GitHub
parent 3bf3f16be7
commit bf0147d542
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/{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))
+28 -12
View File
@@ -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)
}
+249 -67
View File
@@ -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{
+10
View File
@@ -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
}
+18 -3
View File
@@ -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))
})
}
+8 -2
View File
@@ -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
}
+91 -79
View File
@@ -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
}
+76 -65
View File
@@ -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)
}
+83 -16
View File
@@ -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)
}
+3 -42
View File
@@ -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 {
-52
View File
@@ -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)
+3 -3
View File
@@ -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)
);
@@ -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";
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<typeof QuestPropertiesSchema>;
@@ -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()),
}),
+9 -9
View File
@@ -42,7 +42,7 @@ export function ComposeOverlay({
const [reviewMimeType, setReviewMimeType] = useState<string | null>(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 ---
@@ -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<Set<string>>(new Set());
const userId = useAuthStore((s) => s.user?.id);
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) => {
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({
<ScrollArea className="max-h-48">
<div className="space-y-0.5 p-1">
{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 (
<div
key={member.email}
key={member.id}
role="button"
onClick={() => 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",
+1 -1
View File
@@ -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);
@@ -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();
@@ -51,7 +51,7 @@ export function FallbackParticleView({ particle }: FallbackParticleViewProps) {
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-xs">
From {particle.created_by_email}
From {particle.created_by_human_id}
</p>
</CardContent>
</Card>
@@ -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();
+17 -16
View File
@@ -202,7 +202,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
</p>
<ControlsIndicator type="reply" />
<ComposeOverlay
networkId={networkId!}
networkId={networkId}
targetPath={path}
onActiveChange={setComposeActive}
/>
@@ -267,7 +267,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
</div>
<ComposeOverlay
networkId={networkId!}
networkId={networkId}
targetPath={path}
onActiveChange={setComposeActive}
onParticleCreated={onLocalParticleCreated}
@@ -333,7 +333,7 @@ function TopBar({ networkId, particle, streamParticle }: { networkId: string; pa
<>
<BreadcrumbSeparator />
<BreadcrumbItem className="text-xs">
<BreadcrumbPage><ParticleBreadcrumbContent particle={particle} /></BreadcrumbPage>
<BreadcrumbPage><ParticleBreadcrumbContent particle={particle} networkId={networkId} /></BreadcrumbPage>
</BreadcrumbItem>
</>
)}
@@ -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 (
<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)
.map(([userId, _]) => userId);
const seenUserEmails = seenUserIds
.map((userId) => network?.humans?.find((h) => h.id === userId)?.email)
.filter((email): email is string => !!email && email !== currentParticle.created_by_email);
const seenHumans = seenUserIds
.map((userId) => network?.humans?.find((h) => h.id === userId))
.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 (
<>
{seenUserEmails.length > 0 && "Seen by"}
{"Seen by"}
<AvatarGroup>
{seenUserEmails.map((email) => (
<Tooltip key={email}>
{seenHumans.map((human) => (
<Tooltip key={human.id}>
<TooltipTrigger asChild>
<Avatar size="sm">
<AvatarFallback>
{email.split("@")[0].slice(0, 2)}
{human.email_prefix.slice(0, 2)}
</AvatarFallback>
</Avatar>
</TooltipTrigger>
<TooltipContent>
<p>Seen by {email.split("@")[0]}</p>
<p>Seen by {human.email_prefix}</p>
</TooltipContent>
</Tooltip>
))}
+4 -4
View File
@@ -8,7 +8,7 @@ interface CreateParticleParams<T extends ParticleType = ParticleType> {
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,
);
}
+5 -5
View File
@@ -49,7 +49,7 @@ const particleConverter: FirestoreDataConverter<Particle> = {
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<Particle> = {
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<Particle> = {
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<T extends ParticleType>(
collectionPath: string,
type: T,
properties: ParticlePropertiesMap[T],
createdByEmail: string,
createdByHumanId: string,
// Must be passed for container types
visibleTo?: string[],
): Promise<string> {
@@ -203,7 +203,7 @@ export async function createParticle<T extends ParticleType>(
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);