refactor: update api and client to reference humanIds

This commit is contained in:
talksik
2026-03-24 19:08:51 -07:00
parent 3bf3f16be7
commit 57a1cbc696
24 changed files with 666 additions and 416 deletions
+249 -67
View File
@@ -6,7 +6,6 @@ import (
"errors"
"log/slog"
"net/http"
"strings"
"time"
"github.com/flowy-live/llink/internal/auth"
@@ -39,12 +38,10 @@ func NewHandler(authSvc auth.AuthService, humanSvc human.Service, networkSvc net
// Response DTOs
type Human struct {
// Id will be nil if this human is not registered
Id *string `json:"id"`
Id string `json:"id"`
Email string `json:"email"`
EmailPrefix string `json:"email_prefix"`
// CreatedAt will be nil if this human is not registered
CreatedAt *time.Time `json:"created_at"`
CreatedAt time.Time `json:"created_at"`
}
type Network struct {
@@ -89,6 +86,20 @@ type MembersRequest struct {
Emails []string `json:"emails"`
}
type Invitation struct {
NetworkId string `json:"network_id"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
}
type AcceptInvitationRequest struct {
NetworkId string `json:"network_id"`
}
type RevokeInvitationRequest struct {
Email string `json:"email"`
}
// Depot DTOs
type PrepareUploadRequest struct {
@@ -162,7 +173,19 @@ func (h *Handler) SignIn(w http.ResponseWriter, r *http.Request) {
return
}
token, err := h.authSvc.VerifySignInCode(r.Context(), req.Email, req.Code)
// Look up human first so we can store humanId in the session
hum, err := h.humanSvc.GetByEmail(r.Context(), req.Email)
if err != nil {
if errors.Is(err, human.ErrNotFound) {
http.Error(w, "human not found", http.StatusNotFound)
return
}
slog.Error("failed to get human for sign-in", "error", err, "email", req.Email)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
token, err := h.authSvc.VerifySignInCode(r.Context(), req.Email, req.Code, hum.ID)
if err != nil {
if errors.Is(err, auth.ErrInvalidCode) {
http.Error(w, "invalid code", http.StatusUnauthorized)
@@ -173,14 +196,6 @@ func (h *Handler) SignIn(w http.ResponseWriter, r *http.Request) {
return
}
// Get the human
hum, err := h.humanSvc.GetByEmail(r.Context(), req.Email)
if err != nil {
slog.Error("failed to get human after sign-in", "error", err, "email", req.Email)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
resp := SignInResponse{
Human: humanToDTO(hum),
Token: token,
@@ -237,7 +252,7 @@ func (h *Handler) GetCurrentHuman(w http.ResponseWriter, r *http.Request) {
// CreateNetwork creates a new network
func (h *Handler) CreateNetwork(w http.ResponseWriter, r *http.Request) {
email, ok := middleware.EmailFromContext(r.Context())
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
@@ -249,13 +264,13 @@ func (h *Handler) CreateNetwork(w http.ResponseWriter, r *http.Request) {
return
}
net, err := h.networkSvc.Create(r.Context(), req.Name, email)
net, err := h.networkSvc.Create(r.Context(), req.Name, humanId)
if err != nil {
if errors.Is(err, network.ErrInvalidName) {
http.Error(w, "name cannot be empty", http.StatusBadRequest)
return
}
slog.Error("failed to create network", "error", err, "email", email, "name", req.Name)
slog.Error("failed to create network", "error", err, "humanId", humanId, "name", req.Name)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
@@ -274,15 +289,15 @@ func (h *Handler) CreateNetwork(w http.ResponseWriter, r *http.Request) {
// ListNetworks retrieves networks for the authenticated human
func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) {
email, ok := middleware.EmailFromContext(r.Context())
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
networks, err := h.networkSvc.ListForEmail(r.Context(), email)
networks, err := h.networkSvc.ListForHuman(r.Context(), humanId)
if err != nil {
slog.Error("failed to list networks", "error", err, "email", email)
slog.Error("failed to list networks", "error", err, "humanId", humanId)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
@@ -303,7 +318,7 @@ func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) {
// GetNetwork retrieves a specific network
func (h *Handler) GetNetwork(w http.ResponseWriter, r *http.Request) {
email, ok := middleware.EmailFromContext(r.Context())
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
@@ -315,10 +330,9 @@ func (h *Handler) GetNetwork(w http.ResponseWriter, r *http.Request) {
return
}
// Check membership
isMember, err := h.networkSvc.IsMember(r.Context(), networkID, email)
isMember, err := h.networkSvc.IsMember(r.Context(), networkID, humanId)
if err != nil {
slog.Error("failed to check network membership", "error", err, "network_id", networkID, "email", email)
slog.Error("failed to check network membership", "error", err, "network_id", networkID, "humanId", humanId)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
@@ -349,9 +363,10 @@ func (h *Handler) GetNetwork(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(resp)
}
// AddMembersToNetwork adds members to a network
// AddMembersToNetwork adds members to a network. Registered users are added as members,
// unregistered users receive email invitations.
func (h *Handler) AddMembersToNetwork(w http.ResponseWriter, r *http.Request) {
email, ok := middleware.EmailFromContext(r.Context())
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
@@ -363,10 +378,9 @@ func (h *Handler) AddMembersToNetwork(w http.ResponseWriter, r *http.Request) {
return
}
// Check membership
isMember, err := h.networkSvc.IsMember(r.Context(), networkID, email)
isMember, err := h.networkSvc.IsMember(r.Context(), networkID, humanId)
if err != nil {
slog.Error("failed to check network membership", "error", err, "network_id", networkID, "email", email)
slog.Error("failed to check network membership", "error", err, "network_id", networkID, "humanId", humanId)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
@@ -386,10 +400,42 @@ func (h *Handler) AddMembersToNetwork(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.networkSvc.AddMembers(r.Context(), networkID, req.EmailAddresses); err != nil {
slog.Error("failed to add members to network", "error", err, "network_id", networkID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
// Resolve emails: registered users become members, unregistered get invitations
var memberHumanIds []string
var inviteEmails []string
for _, email := range req.EmailAddresses {
normalized, err := utils.NormalizeEmail(email)
if err != nil {
http.Error(w, "invalid email: "+email, http.StatusBadRequest)
return
}
hum, err := h.humanSvc.GetByEmail(r.Context(), normalized)
if err != nil {
if errors.Is(err, human.ErrNotFound) {
inviteEmails = append(inviteEmails, normalized)
continue
}
slog.Error("failed to look up human by email", "error", err, "email", normalized)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
memberHumanIds = append(memberHumanIds, hum.ID)
}
if len(memberHumanIds) > 0 {
if err := h.networkSvc.AddMembers(r.Context(), networkID, memberHumanIds); err != nil {
slog.Error("failed to add members to network", "error", err, "network_id", networkID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
}
if len(inviteEmails) > 0 {
if err := h.networkSvc.InviteByEmail(r.Context(), networkID, inviteEmails); err != nil {
slog.Error("failed to invite members to network", "error", err, "network_id", networkID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
}
// Return updated network
@@ -413,23 +459,22 @@ func (h *Handler) AddMembersToNetwork(w http.ResponseWriter, r *http.Request) {
// RemoveMemberFromNetwork removes a member from a network
func (h *Handler) RemoveMemberFromNetwork(w http.ResponseWriter, r *http.Request) {
email, ok := middleware.EmailFromContext(r.Context())
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
networkID := r.PathValue("id")
memberEmail := r.PathValue("email")
if networkID == "" || memberEmail == "" {
http.Error(w, "network id and member email are required", http.StatusBadRequest)
memberHumanId := r.PathValue("humanId")
if networkID == "" || memberHumanId == "" {
http.Error(w, "network id and member humanId are required", http.StatusBadRequest)
return
}
// Check membership
isMember, err := h.networkSvc.IsMember(r.Context(), networkID, email)
isMember, err := h.networkSvc.IsMember(r.Context(), networkID, humanId)
if err != nil {
slog.Error("failed to check network membership", "error", err, "network_id", networkID, "email", email)
slog.Error("failed to check network membership", "error", err, "network_id", networkID, "humanId", humanId)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
@@ -438,8 +483,159 @@ func (h *Handler) RemoveMemberFromNetwork(w http.ResponseWriter, r *http.Request
return
}
if err := h.networkSvc.RemoveMember(r.Context(), networkID, memberEmail); err != nil {
slog.Error("failed to remove member from network", "error", err, "network_id", networkID, "member_email", memberEmail)
if err := h.networkSvc.RemoveMember(r.Context(), networkID, memberHumanId); err != nil {
slog.Error("failed to remove member from network", "error", err, "network_id", networkID, "memberHumanId", memberHumanId)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ListInvitationsForNetwork returns pending invitations for a network
func (h *Handler) ListInvitationsForNetwork(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
networkID := r.PathValue("id")
if networkID == "" {
http.Error(w, "network id is required", http.StatusBadRequest)
return
}
isMember, err := h.networkSvc.IsMember(r.Context(), networkID, humanId)
if err != nil {
slog.Error("failed to check network membership", "error", err, "network_id", networkID, "humanId", humanId)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if !isMember {
http.Error(w, "access denied", http.StatusForbidden)
return
}
invitations, err := h.networkSvc.ListInvitationsForNetwork(r.Context(), networkID)
if err != nil {
slog.Error("failed to list invitations", "error", err, "network_id", networkID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
resp := make([]Invitation, 0, len(invitations))
for _, inv := range invitations {
resp = append(resp, Invitation{
NetworkId: inv.NetworkID,
Email: inv.Email,
CreatedAt: inv.CreatedAt,
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// ListMyInvitations returns pending invitations for the authenticated user
func (h *Handler) ListMyInvitations(w http.ResponseWriter, r *http.Request) {
email, ok := middleware.EmailFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
invitations, err := h.networkSvc.ListInvitationsForEmail(r.Context(), email)
if err != nil {
slog.Error("failed to list invitations for email", "error", err, "email", email)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
resp := make([]Invitation, 0, len(invitations))
for _, inv := range invitations {
resp = append(resp, Invitation{
NetworkId: inv.NetworkID,
Email: inv.Email,
CreatedAt: inv.CreatedAt,
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// AcceptInvitation accepts a pending network invitation for the authenticated user
func (h *Handler) AcceptInvitation(w http.ResponseWriter, r *http.Request) {
email, ok := middleware.EmailFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req AcceptInvitationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.NetworkId == "" {
http.Error(w, "network_id is required", http.StatusBadRequest)
return
}
if err := h.networkSvc.AcceptInvitation(r.Context(), req.NetworkId, email, humanId); err != nil {
slog.Error("failed to accept invitation", "error", err, "network_id", req.NetworkId, "email", email)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// RevokeInvitation revokes a pending invitation from a network
func (h *Handler) RevokeInvitation(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
networkID := r.PathValue("id")
if networkID == "" {
http.Error(w, "network id is required", http.StatusBadRequest)
return
}
isMember, err := h.networkSvc.IsMember(r.Context(), networkID, humanId)
if err != nil {
slog.Error("failed to check network membership", "error", err, "network_id", networkID, "humanId", humanId)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if !isMember {
http.Error(w, "access denied", http.StatusForbidden)
return
}
var req RevokeInvitationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.Email == "" {
http.Error(w, "email is required", http.StatusBadRequest)
return
}
if err := h.networkSvc.RevokeInvitation(r.Context(), networkID, req.Email); err != nil {
slog.Error("failed to revoke invitation", "error", err, "network_id", networkID, "email", req.Email)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
@@ -476,7 +672,7 @@ func (h *Handler) DownloadParticleMedia(w http.ResponseWriter, r *http.Request)
// PrepareUpload prepares an upload and returns a signed URL for direct upload to GCS
func (h *Handler) PrepareUpload(w http.ResponseWriter, r *http.Request) {
email, ok := middleware.EmailFromContext(r.Context())
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
@@ -493,10 +689,9 @@ func (h *Handler) PrepareUpload(w http.ResponseWriter, r *http.Request) {
return
}
// Check network membership
isMember, err := h.networkSvc.IsMember(r.Context(), req.NetworkId, email)
isMember, err := h.networkSvc.IsMember(r.Context(), req.NetworkId, humanId)
if err != nil {
slog.Error("failed to check network membership", "error", err, "network_id", req.NetworkId, "email", email)
slog.Error("failed to check network membership", "error", err, "network_id", req.NetworkId, "humanId", humanId)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
@@ -582,40 +777,27 @@ func (h *Handler) ConfirmUpload(w http.ResponseWriter, r *http.Request) {
func humanToDTO(h *human.Human) Human {
return Human{
Id: utils.CreateOptionalString(h.ID),
Id: h.ID,
Email: h.Email,
EmailPrefix: h.EmailPrefix,
CreatedAt: &h.CreatedAt,
CreatedAt: h.CreatedAt,
}
}
func emailPrefix(email string) string {
return strings.Split(email, "@")[0]
}
func (h *Handler) networkToDTO(ctx context.Context, n *network.Network) (Network, error) {
adminHuman, err := h.humanSvc.GetByEmail(ctx, n.AdminEmail)
adminHuman, err := h.humanSvc.GetByID(ctx, n.AdminHumanId)
if err != nil {
return Network{}, err
}
// Get member humans
humans := make([]Human, 0, len(n.MemberEmails))
for _, email := range n.MemberEmails {
hum, err := h.humanSvc.GetByEmail(ctx, email)
humans := make([]Human, 0, len(n.MemberHumanIds))
for _, memberHumanId := range n.MemberHumanIds {
hum, err := h.humanSvc.GetByID(ctx, memberHumanId)
if err != nil {
if err == human.ErrNotFound {
humans = append(humans, Human{
Id: nil,
CreatedAt: nil,
Email: email,
EmailPrefix: emailPrefix(email),
})
}
slog.Warn("failed to look up network member", "humanId", memberHumanId, "error", err)
continue
} else {
humans = append(humans, humanToDTO(hum))
}
humans = append(humans, humanToDTO(hum))
}
return Network{