Files
llink/go/internal/handler/handler.go
T
2026-04-16 18:00:51 -07:00

1174 lines
35 KiB
Go

package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"cloud.google.com/go/firestore"
"github.com/flowy-live/llink/internal/auth"
"github.com/flowy-live/llink/internal/billing"
"github.com/flowy-live/llink/internal/depot"
"github.com/flowy-live/llink/internal/human"
"github.com/flowy-live/llink/internal/livekit"
"github.com/flowy-live/llink/internal/middleware"
"github.com/flowy-live/llink/internal/network"
"github.com/flowy-live/llink/internal/particle"
"github.com/flowy-live/llink/internal/utils"
"github.com/flowy-live/llink/internal/waitlist"
"github.com/livekit/protocol/webhook"
)
type Handler struct {
authSvc auth.AuthService
humanSvc human.Service
networkSvc network.Service
particleSvc particle.Service
depotSvc depot.Service
waitlistSvc waitlist.Service
billingSvc billing.Service
livekitClient livekit.Client
firestoreClient *firestore.Client
}
func NewHandler(
authSvc auth.AuthService,
humanSvc human.Service,
networkSvc network.Service,
particleSvc particle.Service,
depotSvc depot.Service,
waitlistSvc waitlist.Service,
billingSvc billing.Service,
livekitClient livekit.Client,
firestoreClient *firestore.Client,
) *Handler {
return &Handler{
authSvc: authSvc,
humanSvc: humanSvc,
networkSvc: networkSvc,
particleSvc: particleSvc,
depotSvc: depotSvc,
waitlistSvc: waitlistSvc,
billingSvc: billingSvc,
livekitClient: livekitClient,
firestoreClient: firestoreClient,
}
}
// Response DTOs
type Human struct {
Id string `json:"id"`
Email string `json:"email"`
EmailPrefix string `json:"email_prefix"`
EmailNotificationsEnabled bool `json:"email_notifications_enabled"`
CreatedAt time.Time `json:"created_at"`
}
type Network struct {
Id string `json:"id"`
Name string `json:"name"`
AdminHuman Human `json:"admin_human"`
Humans []Human `json:"humans"`
CreatedAt time.Time `json:"created_at"`
}
// Auth Request/Response DTOs
type RequestSignInCodeRequest struct {
Email string `json:"email"`
}
type SignInRequest struct {
Email string `json:"email"`
Code string `json:"code"`
}
type SignInResponse struct {
Human Human `json:"human"`
Token string `json:"token"`
}
type FirebaseTokenResponse struct {
Token string `json:"token"`
}
// Network Request DTOs
type CreateNetworkRequest struct {
Name string `json:"name"`
}
type AddMembersToNetworkRequest struct {
EmailAddresses []string `json:"email_addresses"`
}
type MembersRequest struct {
Emails []string `json:"emails"`
}
type Invitation struct {
NetworkId string `json:"network_id"`
NetworkName string `json:"network_name"`
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"`
}
// LiveKit DTOs
type GetLivekitTokenRequest struct {
NetworkId string `json:"network_id"`
StreamId string `json:"stream_id"`
}
type GetLivekitTokenResponse struct {
Token string `json:"token"`
ServerUrl string `json:"server_url"`
}
// Depot DTOs
type PrepareUploadRequest struct {
NetworkId string `json:"network_id"`
Name string `json:"name"`
ContentType string `json:"content_type"`
ContentLength int64 `json:"content_length"`
}
type PrepareUploadResponse struct {
ObjectID string `json:"object_id"`
UploadURL string `json:"upload_url"`
UploadHeaders map[string]string `json:"upload_headers"`
}
type DepotObject struct {
ID string `json:"id"`
Name string `json:"name"`
ContentType string `json:"content_type"`
ContentLength int64 `json:"content_length"`
ContainsContent bool `json:"contains_content"`
DownloadURL string `json:"download_url,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ============================================================================
// Auth Handlers
// ============================================================================
// RequestSignInCode creates a human account if not already existent and sends a sign-in code
func (h *Handler) RequestSignInCode(w http.ResponseWriter, r *http.Request) {
var req RequestSignInCodeRequest
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
}
// Auto-create human if doesn't exist
_, err := h.humanSvc.GetOrCreateByEmail(r.Context(), req.Email)
if err != nil {
slog.Error("failed to get or create human", "error", err, "email", req.Email)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// Request sign-in code
if err := h.authSvc.RequestSignInCode(r.Context(), req.Email); err != nil {
slog.Error("failed to request sign-in code", "error", err, "email", req.Email)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// SignIn verifies the code and returns a session token
func (h *Handler) SignIn(w http.ResponseWriter, r *http.Request) {
var req SignInRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.Email == "" || req.Code == "" {
http.Error(w, "email and code are required", http.StatusBadRequest)
return
}
// 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)
return
}
slog.Error("failed to verify sign-in code", "error", err, "email", req.Email)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
resp := SignInResponse{
Human: humanToDTO(hum),
Token: token,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// FirebaseToken mints a Firebase custom token for the authenticated human so
// the client can signInWithCustomToken and have request.auth.uid populated in
// Firestore security rules.
func (h *Handler) FirebaseToken(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
token, err := h.authSvc.MintFirebaseCustomToken(r.Context(), humanId)
if err != nil {
slog.Error("failed to mint Firebase custom token", "error", err, "humanId", humanId)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(FirebaseTokenResponse{Token: token})
}
// SignOut deletes the session from the token in headers
func (h *Handler) SignOut(w http.ResponseWriter, r *http.Request) {
token := extractBearerToken(r)
if token == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if err := h.authSvc.SignOut(r.Context(), token); err != nil {
slog.Error("failed to sign out", "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// GetCurrentHuman returns the authenticated human
func (h *Handler) GetCurrentHuman(w http.ResponseWriter, r *http.Request) {
email, ok := middleware.EmailFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
hum, err := h.humanSvc.GetByEmail(r.Context(), email)
if err != nil {
if errors.Is(err, human.ErrNotFound) {
http.Error(w, "human not found", http.StatusNotFound)
return
}
slog.Error("failed to get current human", "error", err, "email", email)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
dto := humanToDTO(hum)
json.NewEncoder(w).Encode(dto)
}
type UpdateSettingsRequest struct {
EmailNotificationsEnabled *bool `json:"email_notifications_enabled"`
}
// UpdateSettings updates the authenticated human's settings
func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req UpdateSettingsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.EmailNotificationsEnabled != nil {
if err := h.humanSvc.UpdateEmailNotificationsEnabled(r.Context(), humanId, *req.EmailNotificationsEnabled); err != nil {
slog.Error("failed to update email notifications setting", "error", err, "humanId", humanId)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
}
w.WriteHeader(http.StatusNoContent)
}
// ============================================================================
// Network Handlers
// ============================================================================
// CreateNetwork creates a new network
func (h *Handler) CreateNetwork(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req CreateNetworkRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
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, "humanId", humanId, "name", req.Name)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
resp, err := h.networkToDTO(r.Context(), net)
if err != nil {
slog.Error("failed to convert network to DTO", "error", err, "network_id", net.ID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(resp)
}
// ListNetworks retrieves networks for the authenticated human
func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
networks, err := h.networkSvc.ListForHuman(r.Context(), humanId)
if err != nil {
slog.Error("failed to list networks", "error", err, "humanId", humanId)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
resp := make([]Network, 0, len(networks))
for _, net := range networks {
dto, err := h.networkToDTO(r.Context(), net)
if err != nil {
slog.Warn("failed to convert network to DTO in list", "error", err, "network_id", net.ID)
continue
}
resp = append(resp, dto)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// GetNetwork retrieves a specific network
func (h *Handler) GetNetwork(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
}
net, err := h.networkSvc.GetByID(r.Context(), networkID)
if err != nil {
if errors.Is(err, network.ErrNotFound) {
http.Error(w, "network not found", http.StatusNotFound)
return
}
slog.Error("failed to get network", "error", err, "network_id", networkID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
resp, err := h.networkToDTO(r.Context(), net)
if err != nil {
slog.Error("failed to convert network to DTO", "error", err, "network_id", networkID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// 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) {
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 AddMembersToNetworkRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if len(req.EmailAddresses) == 0 {
http.Error(w, "email addresses are required", http.StatusBadRequest)
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
net, err := h.networkSvc.GetByID(r.Context(), networkID)
if err != nil {
slog.Error("failed to get network after adding members", "error", err, "network_id", networkID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
resp, err := h.networkToDTO(r.Context(), net)
if err != nil {
slog.Error("failed to convert network to DTO", "error", err, "network_id", networkID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// RemoveMemberFromNetwork removes a member from a network. Admin-only.
// Admins cannot remove themselves — doing so would leave networks.admin_human_id
// dangling. Removal of a non-member is a no-op (204).
func (h *Handler) RemoveMemberFromNetwork(w http.ResponseWriter, r *http.Request) {
net, _, ok := h.loadNetworkForAdmin(w, r)
if !ok {
return
}
memberHumanId := r.PathValue("humanId")
if memberHumanId == "" {
http.Error(w, "member humanId is required", http.StatusBadRequest)
return
}
if memberHumanId == net.AdminHumanId {
http.Error(w, "admin cannot remove themselves", http.StatusConflict)
return
}
if err := h.networkSvc.RemoveMember(r.Context(), net.ID, memberHumanId); err != nil {
slog.Error("failed to remove member from network", "error", err, "network_id", net.ID, "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,
NetworkName: inv.NetworkName,
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,
NetworkName: inv.NetworkName,
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
}
w.WriteHeader(http.StatusNoContent)
}
// DownloadParticleMedia returns a fresh signed download URL for media/file particles
func (h *Handler) DownloadParticleMedia(w http.ResponseWriter, r *http.Request) {
_, ok := middleware.EmailFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// TODO: integrate firebase to fetch particle, and verify visibility for this particle's media
objectID := r.PathValue("id")
downloadURL, err := h.depotSvc.GetDownloadURL(r.Context(), objectID)
if err != nil {
slog.Error("failed to get download URL", "error", err, "object_id", objectID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"url": downloadURL})
}
// ============================================================================
// Depot Handlers
// ============================================================================
// PrepareUpload prepares an upload and returns a signed URL for direct upload to GCS
func (h *Handler) PrepareUpload(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req PrepareUploadRequest
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
}
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, "humanId", humanId)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if !isMember {
http.Error(w, "access denied", http.StatusForbidden)
return
}
input := depot.PrepareUploadInput{
Prefix: req.NetworkId,
Name: req.Name,
ContentType: req.ContentType,
ContentLength: req.ContentLength,
}
result, err := h.depotSvc.PrepareUpload(r.Context(), input)
if err != nil {
if errors.Is(err, depot.ErrInvalidInput) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
slog.Error("failed to prepare upload", "error", err, "network_id", req.NetworkId, "name", req.Name)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
resp := PrepareUploadResponse{
ObjectID: result.ObjectID,
UploadURL: result.UploadURL,
UploadHeaders: result.UploadHeaders,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// ConfirmUpload confirms that an upload has been completed
func (h *Handler) ConfirmUpload(w http.ResponseWriter, r *http.Request) {
_, ok := middleware.EmailFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
objectID := r.PathValue("id")
if objectID == "" {
http.Error(w, "object id is required", http.StatusBadRequest)
return
}
obj, err := h.depotSvc.ConfirmUpload(r.Context(), objectID)
if err != nil {
if errors.Is(err, depot.ErrNotFound) {
http.Error(w, "object not found", http.StatusNotFound)
return
}
if errors.Is(err, depot.ErrInvalidInput) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
slog.Error("failed to confirm upload", "error", err, "object_id", objectID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
resp := DepotObject{
ID: obj.ID,
Name: obj.Name,
ContentType: obj.ContentType,
ContentLength: obj.ContentLength,
ContainsContent: obj.ContainsContent,
DownloadURL: "",
CreatedAt: obj.CreatedAt,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// ============================================================================
// Waitlist DTOs
// ============================================================================
type AddToWaitlistRequest struct {
Email string `json:"email"`
Metadata map[string]string `json:"metadata"`
}
type WaitlistEntryResponse struct {
Id int `json:"id"`
Email string `json:"email"`
Metadata map[string]string `json:"metadata"`
CreatedAt time.Time `json:"created_at"`
InvitedAt *time.Time `json:"invited_at,omitempty"`
}
type InviteWaitlistEntrantRequest struct {
Email string `json:"email"`
}
// ============================================================================
// Waitlist Handlers
// ============================================================================
// AddToWaitlist adds an email to the waitlist (public, no auth)
func (h *Handler) AddToWaitlist(w http.ResponseWriter, r *http.Request) {
var req AddToWaitlistRequest
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
}
err := h.waitlistSvc.AddToWaitlist(r.Context(), req.Email, req.Metadata)
if err != nil {
if errors.Is(err, waitlist.AlreadyInWaitlistError) {
http.Error(w, "already in the waitlist", http.StatusConflict)
return
}
slog.Error("failed to add to waitlist", "error", err, "email", req.Email)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
}
// GetWaitlist returns all waitlist entries (admin-only)
func (h *Handler) GetWaitlist(w http.ResponseWriter, r *http.Request) {
if !middleware.IsAdminFromContext(r.Context()) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
filterParam := r.URL.Query().Get("filter")
filter := waitlist.GetWaitlistFilterAll
switch filterParam {
case "invited":
filter = waitlist.GetWaitlistFilterInvitedOnly
case "uninvited":
filter = waitlist.GetWaitlistFilterUninvitedOnly
}
entries, err := h.waitlistSvc.GetWaitlist(r.Context(), filter)
if err != nil {
slog.Error("failed to get waitlist", "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
resp := make([]WaitlistEntryResponse, 0, len(entries))
for _, e := range entries {
resp = append(resp, waitlistEntryToDTO(e))
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// GetWaitlistEntry returns a single waitlist entry by email (admin-only)
func (h *Handler) GetWaitlistEntry(w http.ResponseWriter, r *http.Request) {
if !middleware.IsAdminFromContext(r.Context()) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
email := r.PathValue("email")
if email == "" {
http.Error(w, "email is required", http.StatusBadRequest)
return
}
entry, err := h.waitlistSvc.GetWaitlistEntryByEmail(r.Context(), email)
if err != nil {
if errors.Is(err, waitlist.EntryNotFoundError) {
http.Error(w, "entry not found", http.StatusNotFound)
return
}
slog.Error("failed to get waitlist entry", "error", err, "email", email)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(waitlistEntryToDTO(entry))
}
// InviteWaitlistEntrant marks a waitlist entry as invited (admin-only)
func (h *Handler) InviteWaitlistEntrant(w http.ResponseWriter, r *http.Request) {
if !middleware.IsAdminFromContext(r.Context()) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
var req InviteWaitlistEntrantRequest
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.waitlistSvc.MarkWaitlistEntryInvited(r.Context(), req.Email); err != nil {
if errors.Is(err, waitlist.EntryNotFoundError) {
http.Error(w, "entry not found", http.StatusNotFound)
return
}
slog.Error("failed to invite waitlist entrant", "error", err, "email", req.Email)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func waitlistEntryToDTO(e *waitlist.WaitlistEntry) WaitlistEntryResponse {
return WaitlistEntryResponse{
Id: e.Id,
Email: e.Email,
Metadata: e.Metadata,
CreatedAt: e.CreatedAt,
InvitedAt: e.InvitedAt,
}
}
// ============================================================================
// Helper Functions
// ============================================================================
func humanToDTO(h *human.Human) Human {
return Human{
Id: h.ID,
Email: h.Email,
EmailPrefix: h.EmailPrefix,
EmailNotificationsEnabled: h.EmailNotificationsEnabled,
CreatedAt: h.CreatedAt,
}
}
func (h *Handler) networkToDTO(ctx context.Context, n *network.Network) (Network, error) {
adminHuman, err := h.humanSvc.GetByID(ctx, n.AdminHumanId)
if err != nil {
return Network{}, err
}
humans := make([]Human, 0, len(n.MemberHumanIds))
for _, memberHumanId := range n.MemberHumanIds {
hum, err := h.humanSvc.GetByID(ctx, memberHumanId)
if err != nil {
slog.Warn("failed to look up network member", "humanId", memberHumanId, "error", err)
continue
}
humans = append(humans, humanToDTO(hum))
}
return Network{
Id: n.ID,
Name: n.Name,
AdminHuman: humanToDTO(adminHuman),
Humans: humans,
CreatedAt: n.CreatedAt,
}, nil
}
// ============================================================================
// LiveKit Handlers
// ============================================================================
func (h *Handler) GetLivekitToken(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
humanEmail, ok := middleware.EmailFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req GetLivekitTokenRequest
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 req.StreamId == "" {
http.Error(w, "stream_id is required", http.StatusBadRequest)
return
}
// Compose room name encoding both network and stream IDs for webhook resolution
roomName := req.NetworkId + "/" + req.StreamId
token, err := h.livekitClient.GetJoinToken(roomName, humanId, humanEmail)
if err != nil {
slog.Error("failed to generate livekit token", "error", err, "humanId", humanId, "roomName", roomName)
http.Error(w, "failed to generate token", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(GetLivekitTokenResponse{Token: token, ServerUrl: h.livekitClient.ServerUrl()})
}
// HandleLivekitWebhook processes LiveKit webhook events for huddle presence.
// It verifies the webhook signature (not user auth), then updates the stream
// particle's huddle_active_participants field in Firestore.
func (h *Handler) HandleLivekitWebhook(w http.ResponseWriter, r *http.Request) {
event, err := webhook.ReceiveWebhookEvent(r, h.livekitClient.KeyProvider())
if err != nil {
slog.Error("failed to verify livekit webhook", "error", err)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
eventType := event.GetEvent()
slog.Info("received livekit webhook", "event", eventType, "room", event.GetRoom().GetName())
switch eventType {
case "participant_joined", "participant_left", "room_finished":
// Handle these events
default:
w.WriteHeader(http.StatusOK)
return
}
// Parse room name to extract networkId and streamId
roomName := event.GetRoom().GetName()
parts := strings.SplitN(roomName, "/", 2)
if len(parts) != 2 {
slog.Error("invalid room name format", "room", roomName)
http.Error(w, "invalid room name", http.StatusBadRequest)
return
}
networkId, streamId := parts[0], parts[1]
docPath := fmt.Sprintf("networks/%s/children/%s", networkId, streamId)
docRef := h.firestoreClient.Doc(docPath)
ctx := r.Context()
var participantIds []string
if eventType == "room_finished" {
// Room is done — clear the participants
participantIds = []string{}
} else {
// Use ListParticipants for authoritative state (avoids drift from missed webhooks)
participants, err := h.livekitClient.ListParticipants(ctx, roomName)
if err != nil {
slog.Error("failed to list participants", "error", err, "room", roomName)
// Return 200 so LiveKit doesn't retry
w.WriteHeader(http.StatusOK)
return
}
participantIds = make([]string, 0, len(participants))
for _, p := range participants {
participantIds = append(participantIds, p.Identity)
}
}
_, err = docRef.Update(ctx, []firestore.Update{
{Path: "huddle_active_participants", Value: participantIds},
})
if err != nil {
slog.Error("failed to update huddle participants in firestore", "error", err, "path", docPath)
}
w.WriteHeader(http.StatusOK)
}
func extractBearerToken(r *http.Request) string {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
return ""
}
const prefix = "Bearer "
if len(authHeader) > len(prefix) && authHeader[:len(prefix)] == prefix {
return authHeader[len(prefix):]
}
return ""
}