refactor: update api and client to reference humanIds
This commit is contained in:
@@ -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
@@ -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
@@ -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{
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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()),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user