2064 lines
60 KiB
Go
2064 lines
60 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/flowy-live/llink/internal/auth"
|
|
"github.com/flowy-live/llink/internal/depot"
|
|
"github.com/flowy-live/llink/internal/human"
|
|
"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"
|
|
)
|
|
|
|
type Handler struct {
|
|
authSvc auth.AuthService
|
|
humanSvc human.Service
|
|
networkSvc network.Service
|
|
particleSvc particle.Service
|
|
depotSvc depot.Service
|
|
}
|
|
|
|
func NewHandler(authSvc auth.AuthService, humanSvc human.Service, networkSvc network.Service, particleSvc particle.Service, depotSvc depot.Service) *Handler {
|
|
return &Handler{
|
|
authSvc: authSvc,
|
|
humanSvc: humanSvc,
|
|
networkSvc: networkSvc,
|
|
particleSvc: particleSvc,
|
|
depotSvc: depotSvc,
|
|
}
|
|
}
|
|
|
|
// Response DTOs
|
|
|
|
type Human struct {
|
|
// Id will be nil if this human is not registered
|
|
Id *string `json:"id"`
|
|
Email string `json:"email"`
|
|
EmailPrefix string `json:"email_prefix"`
|
|
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"`
|
|
OpenStreamCount int `json:"open_stream_count"`
|
|
OpenStreamCapacity int `json:"open_stream_capacity"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
type StreamParticle struct {
|
|
Id string `json:"id"`
|
|
Type string `json:"type"`
|
|
Data json.RawMessage `json:"data"`
|
|
CreatedByEmail string `json:"created_by_email"`
|
|
Seen bool `json:"seen"`
|
|
Acks []*AckInfo `json:"acks"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
type Particle struct {
|
|
Id string `json:"id"`
|
|
Type string `json:"type"`
|
|
Data json.RawMessage `json:"data"`
|
|
CreatedByEmail string `json:"created_by_email"`
|
|
Visibility string `json:"visibility"`
|
|
Members []string `json:"members,omitempty"`
|
|
StreamStatus *string `json:"stream_status,omitempty"`
|
|
Seen bool `json:"seen,omitempty"`
|
|
Acks []*AckInfo `json:"acks,omitempty"`
|
|
UnseenCount *int `json:"unseen_count,omitempty"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
type Stream struct {
|
|
Id string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Status StreamStatus `json:"status"`
|
|
// The emails of the members in this stream
|
|
Members []string `json:"members,omitempty"`
|
|
Particles []*StreamParticle `json:"particles"`
|
|
UnseenCount int `json:"unseen_count"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
type StreamStatus string
|
|
|
|
const (
|
|
STREAM_STATUS_OPEN StreamStatus = "open"
|
|
STREAM_STATUS_CLOSED StreamStatus = "closed"
|
|
STREAM_STATUS_UNSPECIFIED StreamStatus = "unspecified"
|
|
)
|
|
|
|
type NetworkWithStreams struct {
|
|
Network
|
|
Streams []*Stream `json:"streams"`
|
|
}
|
|
|
|
type StartData struct {
|
|
Networks []*NetworkWithStreams `json:"networks"`
|
|
}
|
|
|
|
type AckInfo struct {
|
|
Email string `json:"email"`
|
|
AckedAt time.Time `json:"acked_at"`
|
|
}
|
|
|
|
type ParticleList struct {
|
|
Particles []Particle `json:"particles"`
|
|
HasMore bool `json:"has_more"`
|
|
NextCursor *string `json:"next_cursor,omitempty"`
|
|
PrevCursor *string `json:"prev_cursor,omitempty"`
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// Network Request DTOs
|
|
|
|
type CreateNetworkRequest struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
type AddMembersToNetworkRequest struct {
|
|
EmailAddresses []string `json:"email_addresses"`
|
|
}
|
|
|
|
type SetOpenStreamCapacityRequest struct {
|
|
Capacity int `json:"capacity"`
|
|
}
|
|
|
|
// Particle Request DTOs
|
|
|
|
type CreateStreamParticleRequest struct {
|
|
Type string `json:"type"`
|
|
Data json.RawMessage `json:"data"`
|
|
}
|
|
|
|
type CreateStreamRequest struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Visibility string `json:"visibility"`
|
|
Members []string `json:"members"`
|
|
}
|
|
|
|
type UpdateStreamRequest struct {
|
|
Name *string `json:"name"`
|
|
Description *string `json:"description"`
|
|
}
|
|
|
|
type UpdateParticleRequest struct {
|
|
Data json.RawMessage `json:"data"`
|
|
}
|
|
|
|
type MembersRequest struct {
|
|
Emails []string `json:"emails"`
|
|
}
|
|
|
|
type MarkSeenBatchRequest struct {
|
|
ParticleIDs []string `json:"particle_ids"`
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
token, err := h.authSvc.VerifySignInCode(r.Context(), req.Email, req.Code)
|
|
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
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
func (h *Handler) StartupData(w http.ResponseWriter, r *http.Request) {
|
|
email, ok := middleware.EmailFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
networks, err := h.networkSvc.ListForEmail(r.Context(), email)
|
|
if err != nil {
|
|
http.Error(w, "unable to fetch networks", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
data := StartData{
|
|
Networks: make([]*NetworkWithStreams, 0, len(networks)),
|
|
}
|
|
|
|
streamFilter := particle.ListFilter{
|
|
Types: []particle.ParticleType{particle.TypeStream},
|
|
}
|
|
|
|
for _, net := range networks {
|
|
networkDTO, err := h.networkToDTO(r.Context(), net)
|
|
if err != nil {
|
|
slog.Error("unable to convert db network to dto", "error", err, "network_id", net.ID)
|
|
continue
|
|
}
|
|
|
|
nws := &NetworkWithStreams{
|
|
Network: networkDTO,
|
|
}
|
|
|
|
// Fetch all top-level streams for this network
|
|
streams, err := h.listAllParticles(r.Context(), net.ID, nil, email, streamFilter)
|
|
if err != nil {
|
|
slog.Error("unable to list streams for network", "error", err, "network_id", net.ID)
|
|
data.Networks = append(data.Networks, nws)
|
|
continue
|
|
}
|
|
|
|
// Collect stream IDs for unseen counts
|
|
streamIDs := make([]string, len(streams))
|
|
for i, s := range streams {
|
|
streamIDs[i] = s.ID
|
|
}
|
|
|
|
// Get unseen counts and members for all streams in this network
|
|
var unseenCounts map[string]int
|
|
var streamMembersMap map[string][]string
|
|
if len(streamIDs) > 0 {
|
|
unseenCounts, err = h.particleSvc.GetUnseenCounts(r.Context(), net.ID, streamIDs, email)
|
|
if err != nil {
|
|
slog.Warn("failed to get unseen counts for streams", "error", err, "network_id", net.ID)
|
|
unseenCounts = make(map[string]int)
|
|
}
|
|
|
|
streamMembersMap, err = h.particleSvc.GetMembersMap(r.Context(), streamIDs)
|
|
if err != nil {
|
|
slog.Warn("failed to get members map for streams", "error", err, "network_id", net.ID)
|
|
streamMembersMap = make(map[string][]string)
|
|
}
|
|
}
|
|
|
|
nws.Streams = make([]*Stream, 0, len(streams))
|
|
for _, sp := range streams {
|
|
// Parse stream metadata from particle data
|
|
var streamData particle.StreamData
|
|
if err := json.Unmarshal(sp.Data, &streamData); err != nil {
|
|
slog.Warn("failed to parse stream data", "error", err, "particle_id", sp.ID)
|
|
}
|
|
|
|
status := parseStreamStatus(streamData.Status)
|
|
|
|
stream := &Stream{
|
|
Id: sp.ID,
|
|
Name: streamData.Name,
|
|
Description: utils.OptionalString(streamData.Description),
|
|
Status: status,
|
|
Members: h.getStreamMembers(r.Context(), sp, streamMembersMap),
|
|
UnseenCount: unseenCounts[sp.ID],
|
|
UpdatedAt: sp.UpdatedAt,
|
|
CreatedAt: sp.CreatedAt,
|
|
}
|
|
|
|
// Fetch child particles for this stream
|
|
children, err := h.listAllParticles(r.Context(), net.ID, &sp.ID, email, particle.ListFilter{})
|
|
if err != nil {
|
|
slog.Error("unable to list particles for stream", "error", err, "stream_id", sp.ID)
|
|
nws.Streams = append(nws.Streams, stream)
|
|
continue
|
|
}
|
|
|
|
// Collect child particle IDs for bulk enrichment
|
|
childIDs := make([]string, len(children))
|
|
for i, p := range children {
|
|
childIDs[i] = p.ID
|
|
}
|
|
|
|
seenMap, err := h.particleSvc.GetSeenMap(r.Context(), childIDs, email)
|
|
if err != nil {
|
|
slog.Warn("failed to get seen map", "error", err)
|
|
seenMap = make(map[string]bool)
|
|
}
|
|
|
|
acksMap, err := h.particleSvc.GetAcksMap(r.Context(), childIDs)
|
|
if err != nil {
|
|
slog.Warn("failed to get acks map", "error", err)
|
|
acksMap = make(map[string][]particle.AckInfo)
|
|
}
|
|
|
|
// Build enriched particle DTOs
|
|
stream.Particles = make([]*StreamParticle, 0, len(children))
|
|
for _, p := range children {
|
|
dto := h.streamParticleToDTO(r.Context(), p)
|
|
|
|
if seen, ok := seenMap[p.ID]; ok {
|
|
dto.Seen = seen
|
|
}
|
|
|
|
if acks, ok := acksMap[p.ID]; ok && len(acks) > 0 {
|
|
dto.Acks = make([]*AckInfo, len(acks))
|
|
for i, a := range acks {
|
|
dto.Acks[i] = &AckInfo{Email: a.Email, AckedAt: a.AckedAt}
|
|
}
|
|
} else {
|
|
dto.Acks = []*AckInfo{}
|
|
}
|
|
|
|
stream.Particles = append(stream.Particles, &dto)
|
|
}
|
|
|
|
nws.Streams = append(nws.Streams, stream)
|
|
}
|
|
|
|
data.Networks = append(data.Networks, nws)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(data)
|
|
}
|
|
|
|
func (h *Handler) CreateStream(w http.ResponseWriter, r *http.Request) {
|
|
email, ok := middleware.EmailFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
networkID := r.PathValue("network_id")
|
|
if networkID == "" {
|
|
http.Error(w, "network_id is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Check network membership
|
|
isMember, err := h.networkSvc.IsMember(r.Context(), networkID, email)
|
|
if err != nil {
|
|
slog.Error("failed to check network membership", "error", err, "network_id", networkID, "email", email)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !isMember {
|
|
http.Error(w, "access denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
var req CreateStreamRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.Name == "" {
|
|
http.Error(w, "name is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
streamData := &particle.StreamData{
|
|
Name: req.Name,
|
|
Description: utils.CreateOptionalString(req.Description),
|
|
Status: string(particle.StreamStatusOpen),
|
|
}
|
|
data, err := json.Marshal(streamData)
|
|
if err != nil {
|
|
slog.Error("failed to marshal stream data", "error", err, "stream_name", req.Name)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
visibilityMode, err := particle.ParseVisibilityMode(req.Visibility)
|
|
if err != nil {
|
|
slog.Error("invalid visibility mode", "error", err, "visibility", req.Visibility)
|
|
http.Error(w, "invalid visibility mode", http.StatusBadRequest)
|
|
return
|
|
}
|
|
params := particle.CreateInput{
|
|
Type: particle.TypeStream,
|
|
NetworkID: networkID,
|
|
ParentID: nil,
|
|
Data: data,
|
|
Members: req.Members,
|
|
Visibility: visibilityMode,
|
|
}
|
|
created, err := h.particleSvc.Create(r.Context(), params, email)
|
|
if err != nil {
|
|
if errors.Is(err, particle.ErrMembersRequired) {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if errors.Is(err, particle.ErrCapacityExceeded) {
|
|
http.Error(w, "stream capacity exceeded", http.StatusConflict)
|
|
return
|
|
}
|
|
if errors.Is(err, particle.ErrInvalidData) {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
slog.Error("failed to create stream particle", "error", err, "stream_name", req.Name, "network_id", networkID)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Get members for the created stream
|
|
membersMap, _ := h.particleSvc.GetMembersMap(r.Context(), []string{created.ID})
|
|
|
|
var parsedData particle.StreamData
|
|
_ = json.Unmarshal(created.Data, &parsedData)
|
|
|
|
resp := Stream{
|
|
Id: created.ID,
|
|
Name: parsedData.Name,
|
|
Description: utils.OptionalString(parsedData.Description),
|
|
Status: parseStreamStatus(parsedData.Status),
|
|
Members: h.getStreamMembers(r.Context(), created, membersMap),
|
|
Particles: []*StreamParticle{},
|
|
UnseenCount: 0,
|
|
UpdatedAt: created.UpdatedAt,
|
|
CreatedAt: created.CreatedAt,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// GetStream returns a stream with its child particles, members, and unseen count
|
|
func (h *Handler) GetStream(w http.ResponseWriter, r *http.Request) {
|
|
email, ok := middleware.EmailFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
streamID := r.PathValue("id")
|
|
if streamID == "" {
|
|
http.Error(w, "stream id is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Get the stream particle and verify access
|
|
sp, err := h.particleSvc.GetByID(r.Context(), streamID, email)
|
|
if err != nil {
|
|
if errors.Is(err, particle.ErrNotFound) {
|
|
http.Error(w, "stream not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if errors.Is(err, particle.ErrAccessDenied) {
|
|
http.Error(w, "access denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
slog.Error("failed to get stream", "error", err, "stream_id", streamID, "email", email)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if sp.Type != particle.TypeStream {
|
|
http.Error(w, "particle is not a stream", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Parse stream data
|
|
var streamData particle.StreamData
|
|
if err := json.Unmarshal(sp.Data, &streamData); err != nil {
|
|
slog.Warn("failed to parse stream data", "error", err, "stream_id", streamID)
|
|
}
|
|
|
|
// Get members
|
|
membersMap, err := h.particleSvc.GetMembersMap(r.Context(), []string{streamID})
|
|
if err != nil {
|
|
slog.Warn("failed to get members for stream", "error", err, "stream_id", streamID)
|
|
membersMap = make(map[string][]string)
|
|
}
|
|
|
|
// Get unseen count
|
|
unseenCounts, err := h.particleSvc.GetUnseenCounts(r.Context(), sp.NetworkID, []string{streamID}, email)
|
|
if err != nil {
|
|
slog.Warn("failed to get unseen counts", "error", err, "stream_id", streamID)
|
|
unseenCounts = make(map[string]int)
|
|
}
|
|
|
|
stream := &Stream{
|
|
Id: sp.ID,
|
|
Name: streamData.Name,
|
|
Description: utils.OptionalString(streamData.Description),
|
|
Status: parseStreamStatus(streamData.Status),
|
|
Members: h.getStreamMembers(r.Context(), sp, membersMap),
|
|
UnseenCount: unseenCounts[sp.ID],
|
|
UpdatedAt: sp.UpdatedAt,
|
|
CreatedAt: sp.CreatedAt,
|
|
}
|
|
|
|
// Fetch child particles
|
|
children, err := h.listAllParticles(r.Context(), sp.NetworkID, &sp.ID, email, particle.ListFilter{})
|
|
if err != nil {
|
|
slog.Error("unable to list particles for stream", "error", err, "stream_id", sp.ID)
|
|
stream.Particles = []*StreamParticle{}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(stream)
|
|
return
|
|
}
|
|
|
|
// Collect child IDs for bulk enrichment
|
|
childIDs := make([]string, len(children))
|
|
for i, p := range children {
|
|
childIDs[i] = p.ID
|
|
}
|
|
|
|
seenMap, err := h.particleSvc.GetSeenMap(r.Context(), childIDs, email)
|
|
if err != nil {
|
|
slog.Warn("failed to get seen map", "error", err)
|
|
seenMap = make(map[string]bool)
|
|
}
|
|
|
|
acksMap, err := h.particleSvc.GetAcksMap(r.Context(), childIDs)
|
|
if err != nil {
|
|
slog.Warn("failed to get acks map", "error", err)
|
|
acksMap = make(map[string][]particle.AckInfo)
|
|
}
|
|
|
|
stream.Particles = make([]*StreamParticle, 0, len(children))
|
|
for _, p := range children {
|
|
dto := h.streamParticleToDTO(r.Context(), p)
|
|
|
|
if seen, ok := seenMap[p.ID]; ok {
|
|
dto.Seen = seen
|
|
}
|
|
|
|
if acks, ok := acksMap[p.ID]; ok && len(acks) > 0 {
|
|
dto.Acks = make([]*AckInfo, len(acks))
|
|
for i, a := range acks {
|
|
dto.Acks[i] = &AckInfo{Email: a.Email, AckedAt: a.AckedAt}
|
|
}
|
|
} else {
|
|
dto.Acks = []*AckInfo{}
|
|
}
|
|
|
|
stream.Particles = append(stream.Particles, &dto)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(stream)
|
|
}
|
|
|
|
// UpdateStream updates a stream's name and/or description (not status)
|
|
func (h *Handler) UpdateStream(w http.ResponseWriter, r *http.Request) {
|
|
email, ok := middleware.EmailFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
streamID := r.PathValue("id")
|
|
if streamID == "" {
|
|
http.Error(w, "stream id is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var req UpdateStreamRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Get the existing stream to preserve status and merge fields
|
|
sp, err := h.particleSvc.GetByID(r.Context(), streamID, email)
|
|
if err != nil {
|
|
if errors.Is(err, particle.ErrNotFound) {
|
|
http.Error(w, "stream not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if errors.Is(err, particle.ErrAccessDenied) {
|
|
http.Error(w, "access denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
slog.Error("failed to get stream for update", "error", err, "stream_id", streamID, "email", email)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if sp.Type != particle.TypeStream {
|
|
http.Error(w, "particle is not a stream", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Parse existing data to preserve status
|
|
var existing particle.StreamData
|
|
if err := json.Unmarshal(sp.Data, &existing); err != nil {
|
|
slog.Error("failed to parse existing stream data", "error", err, "stream_id", streamID)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Merge: only update fields that were provided
|
|
if req.Name != nil {
|
|
if *req.Name == "" {
|
|
http.Error(w, "name cannot be empty", http.StatusBadRequest)
|
|
return
|
|
}
|
|
existing.Name = *req.Name
|
|
}
|
|
if req.Description != nil {
|
|
existing.Description = utils.CreateOptionalString(*req.Description)
|
|
}
|
|
|
|
newData, err := json.Marshal(existing)
|
|
if err != nil {
|
|
slog.Error("failed to marshal updated stream data", "error", err, "stream_id", streamID)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
updated, err := h.particleSvc.Update(r.Context(), streamID, newData, email)
|
|
if err != nil {
|
|
if errors.Is(err, particle.ErrNotFound) {
|
|
http.Error(w, "stream not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if errors.Is(err, particle.ErrAccessDenied) {
|
|
http.Error(w, "access denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
if errors.Is(err, particle.ErrInvalidData) {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
slog.Error("failed to update stream", "error", err, "stream_id", streamID, "email", email)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Build response
|
|
var parsedData particle.StreamData
|
|
_ = json.Unmarshal(updated.Data, &parsedData)
|
|
|
|
membersMap, _ := h.particleSvc.GetMembersMap(r.Context(), []string{updated.ID})
|
|
|
|
unseenCounts, _ := h.particleSvc.GetUnseenCounts(r.Context(), updated.NetworkID, []string{updated.ID}, email)
|
|
|
|
resp := Stream{
|
|
Id: updated.ID,
|
|
Name: parsedData.Name,
|
|
Description: utils.OptionalString(parsedData.Description),
|
|
Status: parseStreamStatus(parsedData.Status),
|
|
Members: h.getStreamMembers(r.Context(), updated, membersMap),
|
|
Particles: []*StreamParticle{},
|
|
UnseenCount: unseenCounts[updated.ID],
|
|
UpdatedAt: updated.UpdatedAt,
|
|
CreatedAt: updated.CreatedAt,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// CreateStreamParticle creates a particle inside a stream
|
|
func (h *Handler) CreateStreamParticle(w http.ResponseWriter, r *http.Request) {
|
|
email, ok := middleware.EmailFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
streamID := r.PathValue("id")
|
|
if streamID == "" {
|
|
http.Error(w, "stream id is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var req CreateStreamParticleRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Get the stream to find NetworkID and verify it's a stream
|
|
stream, err := h.particleSvc.GetByID(r.Context(), streamID, email)
|
|
if err != nil {
|
|
if errors.Is(err, particle.ErrNotFound) {
|
|
http.Error(w, "stream not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if errors.Is(err, particle.ErrAccessDenied) {
|
|
http.Error(w, "access denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
slog.Error("failed to get stream for particle creation", "error", err, "stream_id", streamID, "email", email)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if stream.Type != particle.TypeStream {
|
|
http.Error(w, "particle is not a stream", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Parse and validate particle type
|
|
particleType, err := particle.ParseParticleType(req.Type)
|
|
if err != nil {
|
|
http.Error(w, "invalid particle type", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Reject streams and folders as children
|
|
if particleType == particle.TypeStream || particleType == particle.TypeFolder {
|
|
http.Error(w, "cannot create streams or folders inside a stream", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// For media/file types, validate object_id exists in depot
|
|
if req.Type == "media" || req.Type == "file" {
|
|
var data struct {
|
|
ObjectID string `json:"object_id"`
|
|
}
|
|
if err := json.Unmarshal(req.Data, &data); err == nil && data.ObjectID != "" {
|
|
exists, err := h.depotSvc.Exists(r.Context(), data.ObjectID)
|
|
if err != nil {
|
|
slog.Error("failed to check depot object existence", "error", err, "object_id", data.ObjectID)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !exists {
|
|
http.Error(w, "object_id does not exist in depot", http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Service will force inherited visibility and nil members
|
|
input := particle.CreateInput{
|
|
Type: particleType,
|
|
NetworkID: stream.NetworkID,
|
|
ParentID: &streamID,
|
|
Data: req.Data,
|
|
}
|
|
|
|
created, err := h.particleSvc.Create(r.Context(), input, email)
|
|
if err != nil {
|
|
if errors.Is(err, particle.ErrInvalidType) || errors.Is(err, particle.ErrInvalidData) || errors.Is(err, particle.ErrInvalidParent) {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if errors.Is(err, particle.ErrAccessDenied) {
|
|
http.Error(w, "access denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
slog.Error("failed to create stream particle", "error", err, "stream_id", streamID, "type", req.Type, "email", email)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
resp := h.streamParticleToDTO(r.Context(), created)
|
|
resp.Acks = []*AckInfo{}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Network Handlers
|
|
// ============================================================================
|
|
|
|
// CreateNetwork creates a new network
|
|
func (h *Handler) CreateNetwork(w http.ResponseWriter, r *http.Request) {
|
|
email, ok := middleware.EmailFromContext(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, email)
|
|
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)
|
|
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) {
|
|
email, ok := middleware.EmailFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
networks, err := h.networkSvc.ListForEmail(r.Context(), email)
|
|
if err != nil {
|
|
slog.Error("failed to list networks", "error", err, "email", email)
|
|
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) {
|
|
email, ok := middleware.EmailFromContext(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
|
|
}
|
|
|
|
// Check membership
|
|
isMember, err := h.networkSvc.IsMember(r.Context(), networkID, email)
|
|
if err != nil {
|
|
slog.Error("failed to check network membership", "error", err, "network_id", networkID, "email", email)
|
|
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
|
|
func (h *Handler) AddMembersToNetwork(w http.ResponseWriter, r *http.Request) {
|
|
email, ok := middleware.EmailFromContext(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
|
|
}
|
|
|
|
// Check membership
|
|
isMember, err := h.networkSvc.IsMember(r.Context(), networkID, email)
|
|
if err != nil {
|
|
slog.Error("failed to check network membership", "error", err, "network_id", networkID, "email", email)
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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
|
|
func (h *Handler) RemoveMemberFromNetwork(w http.ResponseWriter, r *http.Request) {
|
|
email, ok := middleware.EmailFromContext(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)
|
|
return
|
|
}
|
|
|
|
// Check membership
|
|
isMember, err := h.networkSvc.IsMember(r.Context(), networkID, email)
|
|
if err != nil {
|
|
slog.Error("failed to check network membership", "error", err, "network_id", networkID, "email", email)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !isMember {
|
|
http.Error(w, "access denied", http.StatusForbidden)
|
|
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)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// SetOpenStreamCapacity sets the open stream capacity for a network
|
|
func (h *Handler) SetOpenStreamCapacity(w http.ResponseWriter, r *http.Request) {
|
|
email, ok := middleware.EmailFromContext(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
|
|
}
|
|
|
|
// Get network to check admin
|
|
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 for capacity update", "error", err, "network_id", networkID)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Only admin can set capacity
|
|
if net.AdminEmail != email {
|
|
http.Error(w, "only admin can set capacity", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
var req SetOpenStreamCapacityRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := h.networkSvc.SetOpenStreamCapacity(r.Context(), networkID, req.Capacity); err != nil {
|
|
if errors.Is(err, network.ErrNotFound) {
|
|
http.Error(w, "network not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
slog.Error("failed to set open stream capacity", "error", err, "network_id", networkID, "capacity", req.Capacity)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Particle Handlers
|
|
// ============================================================================
|
|
|
|
// ListParticles returns particles in a network
|
|
func (h *Handler) ListParticles(w http.ResponseWriter, r *http.Request) {
|
|
email, ok := middleware.EmailFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
networkID := r.PathValue("network_id")
|
|
if networkID == "" {
|
|
http.Error(w, "network id is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Check network membership
|
|
isMember, err := h.networkSvc.IsMember(r.Context(), networkID, email)
|
|
if err != nil {
|
|
slog.Error("failed to check network membership", "error", err, "network_id", networkID, "email", email)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !isMember {
|
|
http.Error(w, "access denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
// Parse query parameters
|
|
parentID := r.URL.Query().Get("parent_id")
|
|
var parentIDPtr *string
|
|
if parentID != "" {
|
|
parentIDPtr = &parentID
|
|
}
|
|
|
|
// Parse cursor
|
|
var cursor *particle.Cursor
|
|
cursorStr := r.URL.Query().Get("cursor")
|
|
direction := r.URL.Query().Get("direction")
|
|
if cursorStr != "" {
|
|
cursor = &particle.Cursor{
|
|
Position: cursorStr,
|
|
Direction: direction,
|
|
}
|
|
if cursor.Direction == "" {
|
|
cursor.Direction = "after"
|
|
}
|
|
}
|
|
|
|
// Parse type filter
|
|
filter := particle.ListFilter{}
|
|
typeFilter := r.URL.Query()["type"]
|
|
for _, t := range typeFilter {
|
|
pt, err := particle.ParseParticleType(t)
|
|
if err != nil {
|
|
http.Error(w, "invalid particle type: "+t, http.StatusBadRequest)
|
|
return
|
|
}
|
|
filter.Types = append(filter.Types, pt)
|
|
}
|
|
|
|
list, err := h.particleSvc.List(r.Context(), networkID, parentIDPtr, email, filter, cursor, 50)
|
|
if err != nil {
|
|
if errors.Is(err, particle.ErrNotFound) {
|
|
http.Error(w, "parent not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if errors.Is(err, particle.ErrAccessDenied) {
|
|
http.Error(w, "access denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
slog.Error("failed to list particles", "error", err, "network_id", networkID, "email", email)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Extract particle IDs for enrichment
|
|
particleIDs := make([]string, len(list.Particles))
|
|
streamIDs := make([]string, 0)
|
|
for i, p := range list.Particles {
|
|
particleIDs[i] = p.ID
|
|
if p.Type == particle.TypeStream {
|
|
streamIDs = append(streamIDs, p.ID)
|
|
}
|
|
}
|
|
|
|
// Get seen map for enrichment
|
|
seenMap, err := h.particleSvc.GetSeenMap(r.Context(), particleIDs, email)
|
|
if err != nil {
|
|
slog.Warn("failed to get seen map", "error", err)
|
|
seenMap = make(map[string]bool)
|
|
}
|
|
|
|
// Get acks map for enrichment
|
|
acksMap, err := h.particleSvc.GetAcksMap(r.Context(), particleIDs)
|
|
if err != nil {
|
|
slog.Warn("failed to get acks map", "error", err)
|
|
acksMap = make(map[string][]particle.AckInfo)
|
|
}
|
|
|
|
// Get members map for enrichment
|
|
membersMap, err := h.particleSvc.GetMembersMap(r.Context(), particleIDs)
|
|
if err != nil {
|
|
slog.Warn("failed to get members map", "error", err)
|
|
membersMap = make(map[string][]string)
|
|
}
|
|
|
|
// Get unseen counts for streams
|
|
var unseenCounts map[string]int
|
|
if len(streamIDs) > 0 {
|
|
unseenCounts, err = h.particleSvc.GetUnseenCounts(r.Context(), networkID, streamIDs, email)
|
|
if err != nil {
|
|
slog.Warn("failed to get unseen counts", "error", err)
|
|
unseenCounts = make(map[string]int)
|
|
}
|
|
}
|
|
|
|
resp := ParticleList{
|
|
Particles: make([]Particle, 0, len(list.Particles)),
|
|
HasMore: list.HasMore,
|
|
}
|
|
|
|
for _, p := range list.Particles {
|
|
dto := h.particleToDTO(r.Context(), p)
|
|
|
|
// Enrich with seen status
|
|
seen, ok := seenMap[p.ID]
|
|
if ok {
|
|
dto.Seen = seen
|
|
}
|
|
|
|
// Enrich with acks
|
|
if acks, ok := acksMap[p.ID]; ok && len(acks) > 0 {
|
|
dto.Acks = make([]*AckInfo, len(acks))
|
|
for i, a := range acks {
|
|
dto.Acks[i] = &AckInfo{Email: a.Email, AckedAt: a.AckedAt}
|
|
}
|
|
}
|
|
|
|
// Enrich with members
|
|
if members, ok := membersMap[p.ID]; ok && len(members) > 0 {
|
|
dto.Members = members
|
|
}
|
|
|
|
// Enrich with unseen count for streams
|
|
if p.Type == particle.TypeStream {
|
|
count := unseenCounts[p.ID]
|
|
dto.UnseenCount = &count
|
|
}
|
|
|
|
resp.Particles = append(resp.Particles, dto)
|
|
}
|
|
|
|
if list.NextCursor != nil {
|
|
encoded := list.NextCursor.Position + ":" + list.NextCursor.Direction
|
|
resp.NextCursor = &encoded
|
|
}
|
|
if list.PrevCursor != nil {
|
|
encoded := list.PrevCursor.Position + ":" + list.PrevCursor.Direction
|
|
resp.PrevCursor = &encoded
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// GetParticle gets the details of a particle
|
|
func (h *Handler) GetParticle(w http.ResponseWriter, r *http.Request) {
|
|
email, ok := middleware.EmailFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
particleID := r.PathValue("id")
|
|
if particleID == "" {
|
|
http.Error(w, "particle id is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
p, err := h.particleSvc.GetByID(r.Context(), particleID, email)
|
|
if err != nil {
|
|
if errors.Is(err, particle.ErrNotFound) {
|
|
http.Error(w, "particle not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if errors.Is(err, particle.ErrAccessDenied) {
|
|
http.Error(w, "access denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
slog.Error("failed to get particle", "error", err, "particle_id", particleID, "email", email)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Verify network membership
|
|
isMember, err := h.networkSvc.IsMember(r.Context(), p.NetworkID, email)
|
|
if err != nil {
|
|
slog.Error("failed to check network membership", "error", err, "network_id", p.NetworkID, "email", email)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !isMember {
|
|
http.Error(w, "access denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
resp := h.particleToDTO(r.Context(), p)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// UpdateParticle updates the data of a particle
|
|
func (h *Handler) UpdateParticle(w http.ResponseWriter, r *http.Request) {
|
|
email, ok := middleware.EmailFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
particleID := r.PathValue("id")
|
|
if particleID == "" {
|
|
http.Error(w, "particle id is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var req UpdateParticleRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
updated, err := h.particleSvc.Update(r.Context(), particleID, req.Data, email)
|
|
if err != nil {
|
|
if errors.Is(err, particle.ErrNotFound) {
|
|
http.Error(w, "particle not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if errors.Is(err, particle.ErrAccessDenied) {
|
|
http.Error(w, "access denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
if errors.Is(err, particle.ErrInvalidData) {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
slog.Error("failed to update particle", "error", err, "particle_id", particleID, "email", email)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
resp := h.particleToDTO(r.Context(), updated)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// DeleteParticle deletes a particle and cascades to depot if applicable
|
|
func (h *Handler) DeleteParticle(w http.ResponseWriter, r *http.Request) {
|
|
email, ok := middleware.EmailFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
particleID := r.PathValue("id")
|
|
if particleID == "" {
|
|
http.Error(w, "particle id is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Get particle first to check for object_id (for cascade delete)
|
|
p, err := h.particleSvc.GetByID(r.Context(), particleID, email)
|
|
if err != nil {
|
|
if errors.Is(err, particle.ErrNotFound) {
|
|
http.Error(w, "particle not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if errors.Is(err, particle.ErrAccessDenied) {
|
|
http.Error(w, "access denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
slog.Error("failed to get particle for deletion", "error", err, "particle_id", particleID, "email", email)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Extract object_id if media/file
|
|
objectID := extractObjectID(p)
|
|
|
|
// Delete particle
|
|
if err := h.particleSvc.Delete(r.Context(), particleID, email); err != nil {
|
|
if errors.Is(err, particle.ErrNotFound) {
|
|
http.Error(w, "particle not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if errors.Is(err, particle.ErrAccessDenied) {
|
|
http.Error(w, "access denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
slog.Error("failed to delete particle", "error", err, "particle_id", particleID, "email", email)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Cascade delete depot object if applicable
|
|
if objectID != "" {
|
|
if err := h.depotSvc.Delete(r.Context(), objectID); err != nil {
|
|
slog.Warn("failed to cascade delete depot object", "error", err, "object_id", objectID, "particle_id", particleID)
|
|
}
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// OpenStream opens a stream particle
|
|
func (h *Handler) OpenStream(w http.ResponseWriter, r *http.Request) {
|
|
email, ok := middleware.EmailFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
particleID := r.PathValue("id")
|
|
if particleID == "" {
|
|
http.Error(w, "particle id is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := h.particleSvc.OpenStream(r.Context(), particleID, email); err != nil {
|
|
if errors.Is(err, particle.ErrNotFound) {
|
|
http.Error(w, "particle not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if errors.Is(err, particle.ErrAccessDenied) {
|
|
http.Error(w, "access denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
if errors.Is(err, particle.ErrNotAStream) {
|
|
http.Error(w, "particle is not a stream", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if errors.Is(err, particle.ErrStreamAlreadyOpen) {
|
|
http.Error(w, "stream is already open", http.StatusConflict)
|
|
return
|
|
}
|
|
if errors.Is(err, particle.ErrCapacityExceeded) {
|
|
http.Error(w, "stream capacity exceeded", http.StatusConflict)
|
|
return
|
|
}
|
|
slog.Error("failed to open stream", "error", err, "particle_id", particleID, "email", email)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// CloseStream closes a stream particle
|
|
func (h *Handler) CloseStream(w http.ResponseWriter, r *http.Request) {
|
|
email, ok := middleware.EmailFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
particleID := r.PathValue("id")
|
|
if particleID == "" {
|
|
http.Error(w, "particle id is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := h.particleSvc.CloseStream(r.Context(), particleID, email); err != nil {
|
|
if errors.Is(err, particle.ErrNotFound) {
|
|
http.Error(w, "particle not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if errors.Is(err, particle.ErrAccessDenied) {
|
|
http.Error(w, "access denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
if errors.Is(err, particle.ErrNotAStream) {
|
|
http.Error(w, "particle is not a stream", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if errors.Is(err, particle.ErrStreamAlreadyClosed) {
|
|
http.Error(w, "stream is already closed", http.StatusConflict)
|
|
return
|
|
}
|
|
slog.Error("failed to close stream", "error", err, "particle_id", particleID, "email", email)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// AddMembers adds members to a particle with custom visibility
|
|
func (h *Handler) AddMembers(w http.ResponseWriter, r *http.Request) {
|
|
email, ok := middleware.EmailFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
particleID := r.PathValue("id")
|
|
if particleID == "" {
|
|
http.Error(w, "particle id is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var req MembersRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if len(req.Emails) == 0 {
|
|
http.Error(w, "emails are required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := h.particleSvc.AddMembers(r.Context(), particleID, req.Emails, email); err != nil {
|
|
if errors.Is(err, particle.ErrNotFound) {
|
|
http.Error(w, "particle not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if errors.Is(err, particle.ErrAccessDenied) {
|
|
http.Error(w, "access denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
if errors.Is(err, particle.ErrNotAContainer) {
|
|
http.Error(w, "only streams can have members", http.StatusBadRequest)
|
|
return
|
|
}
|
|
slog.Error("failed to add members to particle", "error", err, "particle_id", particleID, "email", email)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// RemoveMembers removes members from a particle with custom visibility
|
|
func (h *Handler) RemoveMembers(w http.ResponseWriter, r *http.Request) {
|
|
email, ok := middleware.EmailFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
particleID := r.PathValue("id")
|
|
if particleID == "" {
|
|
http.Error(w, "particle id is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var req MembersRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if len(req.Emails) == 0 {
|
|
http.Error(w, "emails are required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := h.particleSvc.RemoveMembers(r.Context(), particleID, req.Emails, email); err != nil {
|
|
if errors.Is(err, particle.ErrNotFound) {
|
|
http.Error(w, "particle not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if errors.Is(err, particle.ErrAccessDenied) {
|
|
http.Error(w, "access denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
if errors.Is(err, particle.ErrNotAContainer) {
|
|
http.Error(w, "only streams can have members", http.StatusBadRequest)
|
|
return
|
|
}
|
|
slog.Error("failed to remove members from particle", "error", err, "particle_id", particleID, "email", email)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// MarkSeen marks a particle as seen by the requester
|
|
func (h *Handler) MarkSeen(w http.ResponseWriter, r *http.Request) {
|
|
email, ok := middleware.EmailFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
particleID := r.PathValue("id")
|
|
if particleID == "" {
|
|
http.Error(w, "particle id is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := h.particleSvc.MarkSeen(r.Context(), particleID, email); err != nil {
|
|
if errors.Is(err, particle.ErrNotFound) {
|
|
http.Error(w, "particle not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if errors.Is(err, particle.ErrAccessDenied) {
|
|
http.Error(w, "access denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
slog.Error("failed to mark particle as seen", "error", err, "particle_id", particleID, "email", email)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// MarkSeenBatch marks multiple particles as seen by the requester
|
|
func (h *Handler) MarkSeenBatch(w http.ResponseWriter, r *http.Request) {
|
|
email, ok := middleware.EmailFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var req MarkSeenBatchRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if len(req.ParticleIDs) == 0 {
|
|
http.Error(w, "particle_ids are required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := h.particleSvc.MarkSeenBatch(r.Context(), req.ParticleIDs, email); err != nil {
|
|
slog.Error("failed to mark particles as seen", "error", err, "email", email)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// AckParticle acknowledges a particle (public, permanent)
|
|
func (h *Handler) AckParticle(w http.ResponseWriter, r *http.Request) {
|
|
email, ok := middleware.EmailFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
particleID := r.PathValue("id")
|
|
if particleID == "" {
|
|
http.Error(w, "particle id is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := h.particleSvc.Ack(r.Context(), particleID, email); err != nil {
|
|
if errors.Is(err, particle.ErrNotFound) {
|
|
http.Error(w, "particle not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if errors.Is(err, particle.ErrAccessDenied) {
|
|
http.Error(w, "access denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
slog.Error("failed to ack particle", "error", err, "particle_id", particleID, "email", email)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// DownloadParticle redirects to a fresh signed download URL for media/file particles
|
|
func (h *Handler) DownloadParticle(w http.ResponseWriter, r *http.Request) {
|
|
email, ok := middleware.EmailFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
particleID := r.PathValue("id")
|
|
if particleID == "" {
|
|
http.Error(w, "particle id is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
p, err := h.particleSvc.GetByID(r.Context(), particleID, email)
|
|
if err != nil {
|
|
if errors.Is(err, particle.ErrNotFound) {
|
|
http.Error(w, "particle not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if errors.Is(err, particle.ErrAccessDenied) {
|
|
http.Error(w, "access denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
slog.Error("failed to get particle for download", "error", err, "particle_id", particleID, "email", email)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
objectID := extractObjectID(p)
|
|
if objectID == "" {
|
|
http.Error(w, "particle has no downloadable content", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
downloadURL, err := h.depotSvc.GetDownloadURL(r.Context(), objectID)
|
|
if err != nil {
|
|
slog.Error("failed to get download URL", "error", err, "object_id", objectID, "particle_id", particleID)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, downloadURL, http.StatusFound)
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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) {
|
|
email, ok := middleware.EmailFromContext(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
|
|
}
|
|
|
|
// Check network membership
|
|
isMember, err := h.networkSvc.IsMember(r.Context(), req.NetworkId, email)
|
|
if err != nil {
|
|
slog.Error("failed to check network membership", "error", err, "network_id", req.NetworkId, "email", email)
|
|
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,
|
|
CreatedAt: obj.CreatedAt,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Helper Functions
|
|
// ============================================================================
|
|
|
|
const listAllBatchSize = 100
|
|
|
|
// listAllParticles fetches all particles matching the query by paginating in batches.
|
|
// This keeps the particle service pagination contract intact.
|
|
func (h *Handler) listAllParticles(ctx context.Context, networkID string, parentID *string, email string, filter particle.ListFilter) ([]*particle.Particle, error) {
|
|
var all []*particle.Particle
|
|
var cursor *particle.Cursor
|
|
|
|
for {
|
|
page, err := h.particleSvc.List(ctx, networkID, parentID, email, filter, cursor, listAllBatchSize)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
all = append(all, page.Particles...)
|
|
|
|
if !page.HasMore || page.NextCursor == nil {
|
|
break
|
|
}
|
|
cursor = page.NextCursor
|
|
}
|
|
|
|
return all, nil
|
|
}
|
|
|
|
// getStreamMembers returns the effective members for a stream.
|
|
// For custom visibility, returns particle_members. For network_all, returns all network members.
|
|
func (h *Handler) getStreamMembers(ctx context.Context, sp *particle.Particle, membersMap map[string][]string) []string {
|
|
if sp.Visibility == particle.VisibilityCustom {
|
|
return membersMap[sp.ID]
|
|
}
|
|
// network_all — return all network members
|
|
net, err := h.networkSvc.GetByID(ctx, sp.NetworkID)
|
|
if err != nil {
|
|
slog.Warn("failed to get network for stream members", "error", err, "network_id", sp.NetworkID)
|
|
return nil
|
|
}
|
|
return net.MemberEmails
|
|
}
|
|
|
|
func humanToDTO(h *human.Human) Human {
|
|
return Human{
|
|
Id: utils.CreateOptionalString(h.ID),
|
|
Email: h.Email,
|
|
EmailPrefix: h.EmailPrefix,
|
|
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)
|
|
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)
|
|
if err != nil {
|
|
if err == human.ErrNotFound {
|
|
humans = append(humans, Human{
|
|
Id: nil,
|
|
CreatedAt: nil,
|
|
Email: email,
|
|
EmailPrefix: emailPrefix(email),
|
|
})
|
|
}
|
|
continue
|
|
} else {
|
|
humans = append(humans, humanToDTO(hum))
|
|
}
|
|
}
|
|
|
|
return Network{
|
|
Id: n.ID,
|
|
Name: n.Name,
|
|
AdminHuman: humanToDTO(adminHuman),
|
|
Humans: humans,
|
|
OpenStreamCount: n.OpenStreamCount,
|
|
OpenStreamCapacity: n.OpenStreamCapacity,
|
|
CreatedAt: n.CreatedAt,
|
|
}, nil
|
|
}
|
|
|
|
func (h *Handler) particleToDTO(ctx context.Context, p *particle.Particle) Particle {
|
|
dto := Particle{
|
|
Id: p.ID,
|
|
Type: string(p.Type),
|
|
CreatedByEmail: p.CreatedByEmail,
|
|
Data: p.Data,
|
|
UpdatedAt: p.UpdatedAt,
|
|
CreatedAt: p.CreatedAt,
|
|
Visibility: string(p.Visibility),
|
|
Seen: false,
|
|
Acks: []*AckInfo{},
|
|
}
|
|
|
|
return dto
|
|
}
|
|
|
|
func (h *Handler) streamParticleToDTO(ctx context.Context, p *particle.Particle) StreamParticle {
|
|
dto := StreamParticle{
|
|
Id: p.ID,
|
|
Type: string(p.Type),
|
|
CreatedByEmail: p.CreatedByEmail,
|
|
Data: p.Data,
|
|
UpdatedAt: p.UpdatedAt,
|
|
CreatedAt: p.CreatedAt,
|
|
}
|
|
|
|
return dto
|
|
}
|
|
|
|
func parseStreamStatus(s string) StreamStatus {
|
|
switch s {
|
|
case "open":
|
|
return STREAM_STATUS_OPEN
|
|
case "closed":
|
|
return STREAM_STATUS_CLOSED
|
|
default:
|
|
return STREAM_STATUS_UNSPECIFIED
|
|
}
|
|
}
|
|
|
|
func extractObjectID(p *particle.Particle) string {
|
|
if p.Type != particle.TypeMedia && p.Type != particle.TypeFile {
|
|
return ""
|
|
}
|
|
|
|
var data struct {
|
|
ObjectID string `json:"object_id"`
|
|
}
|
|
if err := json.Unmarshal(p.Data, &data); err == nil {
|
|
return data.ObjectID
|
|
}
|
|
return ""
|
|
}
|
|
|
|
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 ""
|
|
}
|