From 60330f65e05fdfbc5dbf5858db2fe2bd25ef3e81 Mon Sep 17 00:00:00 2001 From: talksik Date: Tue, 17 Mar 2026 10:11:56 -0700 Subject: [PATCH] chore: cleanup orion api to only include essentials --- go/cmd/orion/main.go | 25 +- go/docs/api.md | 314 +------ go/internal/handler/handler.go | 1490 +------------------------------- 3 files changed, 51 insertions(+), 1778 deletions(-) diff --git a/go/cmd/orion/main.go b/go/cmd/orion/main.go index 24a1e05..7b867f3 100644 --- a/go/cmd/orion/main.go +++ b/go/cmd/orion/main.go @@ -106,37 +106,14 @@ func main() { mux.Handle("POST /auth/sign-out", withAuth(h.SignOut)) mux.Handle("GET /auth/me", withAuth(h.GetCurrentHuman)) - // Bootstrap startup data - mux.Handle("GET /startup", withAuth(h.StartupData)) - // Networks mux.Handle("POST /networks", withAuth(h.CreateNetwork)) mux.Handle("GET /networks", withAuth(h.ListNetworks)) mux.Handle("GET /networks/{id}", withAuth(h.GetNetwork)) mux.Handle("POST /networks/{id}/members", withAuth(h.AddMembersToNetwork)) - // TODO: what about members who are part of streams visibility within this network? - mux.Handle("DELETE /networks/{id}/members/{email}", withAuth(h.RemoveMemberFromNetwork)) - mux.Handle("PUT /networks/{id}/capacity", withAuth(h.SetOpenStreamCapacity)) - - // Streams - mux.Handle("POST /networks/{network_id}/streams", withAuth(h.CreateStream)) - mux.Handle("GET /streams/{id}", withAuth(h.GetStream)) - mux.Handle("PATCH /streams/{id}", withAuth(h.UpdateStream)) - mux.Handle("POST /streams/{id}/particles", withAuth(h.CreateStreamParticle)) - mux.Handle("POST /streams/{id}/open", withAuth(h.OpenStream)) - mux.Handle("POST /streams/{id}/close", withAuth(h.CloseStream)) - mux.Handle("POST /streams/{id}/members", withAuth(h.AddMembers)) - mux.Handle("DELETE /streams/{id}/members", withAuth(h.RemoveMembers)) // Particles - mux.Handle("GET /networks/{network_id}/particles", withAuth(h.ListParticles)) - mux.Handle("GET /particles/{id}", withAuth(h.GetParticle)) - mux.Handle("PATCH /particles/{id}", withAuth(h.UpdateParticle)) - mux.Handle("DELETE /particles/{id}", withAuth(h.DeleteParticle)) - mux.Handle("POST /particles/{id}/seen", withAuth(h.MarkSeen)) - mux.Handle("POST /particles/{id}/ack", withAuth(h.AckParticle)) - mux.Handle("GET /particles/{id}/download", withAuth(h.DownloadParticle)) - mux.Handle("POST /particles/seen", withAuth(h.MarkSeenBatch)) + mux.Handle("GET /particles/{id}/download", withAuth(h.DownloadParticleMedia)) // Depot mux.Handle("POST /depot/upload", withAuth(h.PrepareUpload)) diff --git a/go/docs/api.md b/go/docs/api.md index c44ac1c..2cb43a2 100644 --- a/go/docs/api.md +++ b/go/docs/api.md @@ -51,54 +51,6 @@ Returns the authenticated user. --- -## Startup - -### Get Startup Data -`GET /startup` (Protected) - -Bootstrap endpoint for initial app load. Returns all networks the user belongs to, with all streams and their particles fully enriched. - -**Response:** -```json -{ - "networks": [ - { - "id": "net-456", - "name": "My Team", - "admin_human": { "id": "...", "email": "...", "email_prefix": "...", "created_at": "..." }, - "humans": [{ "id": "...", "email": "...", "email_prefix": "...", "created_at": "..." }], - "open_stream_count": 2, - "open_stream_capacity": 5, - "created_at": "2025-01-15T10:30:00Z", - "streams": [ - { - "id": "p-001", - "name": "Sprint Planning", - "description": "Weekly sync", - "status": "open", - "members": ["alice@example.com"], - "particles": [ - { - "id": "p-002", - "type": "text", - "data": { "content": "Hello" }, - "created_by_email": "alice@example.com", - "seen": true, - "acks": [], - "updated_at": "...", - "created_at": "..." - } - ], - "unseen_count": 0 - } - ] - } - ] -} -``` - ---- - ## Networks ### Create Network @@ -136,183 +88,15 @@ Returns a specific network by ID. Removes a member by email from the network. -### Set Open Stream Capacity -`PUT /networks/{id}/capacity` (Protected, Admin only) - -**Request Body:** -```json -{ - "capacity": 10 -} -``` - --- -## Streams - -Streams are top-level particles of type `stream`. They have dedicated endpoints for creation and management, and contain child particles. - -### Create Stream -`POST /networks/{network_id}/streams` (Protected) - -**Request Body:** -```json -{ - "name": "Sprint Planning", - "description": "Weekly sync", - "visibility": "custom", - "members": ["user@example.com"] -} -``` - -- `visibility`: `network_all` (default) or `custom` -- `members` is required when visibility is `custom` - -**Response:** `201 Created` — returns a [Stream](#stream-1) object. - -### Get Stream -`GET /streams/{id}` (Protected) - -Returns a stream with all its child particles, enriched with seen/ack state. - -**Response:** returns a [Stream](#stream-1) object. - -### Update Stream -`PATCH /streams/{id}` (Protected) - -Updates a stream's name and/or description. Status is not affected (use the open/close endpoints instead). Only provided fields are updated. - -**Request Body:** -```json -{ - "name": "New Name", - "description": "New description" -} -``` - -- Both fields are optional — omit a field to leave it unchanged -- `name` cannot be empty if provided - -**Response:** returns the updated [Stream](#stream-1) object. - -### Create Stream Particle -`POST /streams/{id}/particles` (Protected) - -Creates a child particle inside a stream. Child particles inherit visibility from the stream. - -**Request Body:** -```json -{ - "type": "text|media|file|quest|paper", - "data": {} -} -``` - -- Cannot create `stream` or `folder` types as children -- For `media` and `file` types, `data` must include a valid `object_id` from depot - -**Response:** `201 Created` — returns a [StreamParticle](#streamparticle) object. - -### Open Stream -`POST /streams/{id}/open` (Protected) - -Opens a closed stream. Fails with `409` if capacity would be exceeded. - -### Close Stream -`POST /streams/{id}/close` (Protected) - -Closes an open stream. - -### Add Members to Stream -`POST /streams/{id}/members` (Protected) - -**Request Body:** -```json -{ - "emails": ["user@example.com"] -} -``` - -### Remove Members from Stream -`DELETE /streams/{id}/members` (Protected) - -**Request Body:** -```json -{ - "emails": ["user@example.com"] -} -``` - ---- - -## Particles - -### List Particles -`GET /networks/{network_id}/particles` (Protected) - -**Query Parameters:** -- `parent_id` (optional): Filter by parent particle -- `cursor` (optional): Pagination cursor -- `direction` (optional): `after` or `before` (default: `after`) -- `type` (optional, repeatable): Filter by particle type - -**Response enrichment:** -Each particle in the response includes: -- `seen` (boolean): Whether the requester has marked this particle as seen -- `acks` (array): List of acknowledgments `[{email, acked_at}]` -- `unseen_count` (integer, streams only): Count of unseen child particles - -### Get Particle -`GET /particles/{id}` (Protected) - -### Update Particle -`PATCH /particles/{id}` (Protected) - -**Request Body:** -```json -{ - "data": {} -} -``` - -### Delete Particle -`DELETE /particles/{id}` (Protected) - -Deletes the particle and all children. If it references a depot object, that is also deleted. - -### Download Particle +### Download Particle Object `GET /particles/{id}/download` (Protected) +For now, the `{id}` should be an object id. Not the particle id. + Returns a `302` redirect to a signed download URL. Only works for `media` and `file` particles. -### Mark Seen -`POST /particles/{id}/seen` (Protected) - -Marks a particle as seen by the requester. This is private state, only visible to the requester. - -Returns `204 No Content` on success. - -### Mark Seen (Batch) -`POST /particles/seen` (Protected) - -Marks multiple particles as seen by the requester. - -**Request Body:** -```json -{ - "particle_ids": ["particle_uuid1", "particle_uuid2"] -} -``` - -Returns `204 No Content` on success. - -### Acknowledge Particle -`POST /particles/{id}/ack` (Protected) - -Acknowledges a particle. Acknowledgments are public and permanent, visible to all users with access. Also marks the particle as seen. - -Returns `204 No Content` on success. - --- ## Depot (File Storage) @@ -395,8 +179,6 @@ Confirms that an upload has been completed. | `name` | `string` | Display name of the network. | | `admin_human` | `Human` | The network administrator. | | `humans` | `Human[]` | All members of the network (including admin). | -| `open_stream_count` | `integer` | Number of currently open streams. | -| `open_stream_capacity` | `integer` | Maximum number of concurrent open streams (default: 5). | | `created_at` | `string` | ISO 8601 timestamp. | ```json @@ -407,86 +189,10 @@ Confirms that an upload has been completed. "humans": [ { "id": "abc-123", "email": "alice@example.com", "email_prefix": "alice", "created_at": "..." } ], - "open_stream_count": 2, - "open_stream_capacity": 5, "created_at": "2025-01-15T10:30:00Z" } ``` -### Stream - -| Field | Type | Description | -|-------|------|-------------| -| `id` | `string` | Unique identifier (this is a particle ID). | -| `name` | `string` | Stream name. | -| `description` | `string` | Stream description. | -| `status` | `string` | `"open"`, `"closed"`, or `"unspecified"`. | -| `members` | `string[]` | Emails of stream members. Omitted for `network_all` visibility. | -| `particles` | `StreamParticle[]` | Child particles in the stream. | -| `unseen_count` | `integer` | Number of unseen child particles for the requester. | - -```json -{ - "id": "p-001", - "name": "Sprint Planning", - "description": "Weekly sync", - "status": "open", - "members": ["alice@example.com"], - "particles": [], - "unseen_count": 0 -} -``` - -### StreamParticle - -| Field | Type | Description | -|-------|------|-------------| -| `id` | `string` | Unique identifier. | -| `type` | `string` | One of: `media`, `file`, `text`, `quest`, `paper`. | -| `data` | `object` | Type-specific payload (see [Particle Data by Type](#particle-data-by-type)). | -| `created_by_email` | `string` | Email of the creator. | -| `seen` | `boolean` | Whether the requester has seen this particle. | -| `acks` | `AckInfo[]` | Acknowledgments from users. | -| `updated_at` | `string` | ISO 8601 timestamp. | -| `created_at` | `string` | ISO 8601 timestamp. | - -### Particle - -| Field | Type | Description | -|-------|------|-------------| -| `id` | `string` | Unique identifier. | -| `type` | `string` | One of: `stream`, `folder`, `media`, `file`, `text`, `quest`, `paper`. | -| `network_id` | `string` | The network this particle belongs to. | -| `parent_id` | `string \| null` | Parent particle ID, if nested. | -| `created_by_email` | `string` | Email of the creator. | -| `visibility` | `string` | `"network_all"`, `"custom"`, or `"inherited"`. | -| `stream_status` | `string \| null` | Only on `stream` type: `"open"` or `"closed"`. | -| `data` | `object` | Type-specific payload (see [Particle Data by Type](#particle-data-by-type)). | -| `download_url` | `string \| null` | Signed download URL. Only on `media`/`file` particles. | -| `seen` | `boolean \| null` | Whether the requester has seen this particle. Only in list responses. | -| `acks` | `AckInfo[]` | Acknowledgments. Only in list responses. | -| `unseen_count` | `integer \| null` | Unseen child count. Only on `stream` particles in list responses. | -| `updated_at` | `string` | ISO 8601 timestamp. | -| `created_at` | `string` | ISO 8601 timestamp. | - -### AckInfo - -| Field | Type | Description | -|-------|------|-------------| -| `email` | `string` | Email of the user who acknowledged. | -| `acked_at` | `string` | ISO 8601 timestamp of the acknowledgment. | - -### ParticleList - -Returned by `GET /networks/{network_id}/particles`. - -| Field | Type | Description | -|-------|------|-------------| -| `particles` | `Particle[]` | Array of enriched particle objects. | -| `has_more` | `boolean` | Whether more results exist beyond this page. | -| `next_cursor` | `string \| null` | Cursor to fetch the next page. | -| `prev_cursor` | `string \| null` | Cursor to fetch the previous page. | - ### DepotObject Returned by `POST /depot/objects/{id}/confirm`. @@ -591,20 +297,6 @@ The `data` field on a Particle is a JSON object whose schema depends on the part --- -## Visibility - -Particles support three visibility modes: - -| Mode | Description | -|------|-------------| -| `network_all` | Visible to all network members. | -| `custom` | Visible only to specified members (requires `members` list). | -| `inherited` | Inherits visibility from parent particle. Used for child particles in streams. | - -Root-level particles (streams, folders) use `network_all` or `custom`. Child particles created via `POST /streams/{id}/particles` automatically use `inherited`. - ---- - ## Error Responses All endpoints return standard HTTP status codes with plain text error bodies: diff --git a/go/internal/handler/handler.go b/go/internal/handler/handler.go index 727d552..cb261a1 100644 --- a/go/internal/handler/handler.go +++ b/go/internal/handler/handler.go @@ -43,85 +43,16 @@ type Human struct { Id *string `json:"id"` Email string `json:"email"` EmailPrefix string `json:"email_prefix"` + // CreatedAt will be nil if this human is not registered CreatedAt *time.Time `json:"created_at"` } 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"` + 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 @@ -154,37 +85,10 @@ 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 { @@ -303,583 +207,6 @@ func (h *Handler) SignOut(w http.ResponseWriter, r *http.Request) { 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()) @@ -1120,677 +447,48 @@ func (h *Handler) RemoveMemberFromNetwork(w http.ResponseWriter, r *http.Request 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()) +// DownloadParticleMedia redirects to 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 } - 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 - } + // TODO: integrate firebase to fetch particle, and verify visibility for this particle's media + + objectID := r.PathValue("id") + + // particleID := r.PathValue("id") + // if particleID == "" { + // http.Error(w, "object 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) + slog.Error("failed to get download URL", "error", err, "object_id", objectID) http.Error(w, "internal server error", http.StatusInternalServerError) return } @@ -1896,6 +594,7 @@ func (h *Handler) ConfirmUpload(w http.ResponseWriter, r *http.Request) { ContentType: obj.ContentType, ContentLength: obj.ContentLength, ContainsContent: obj.ContainsContent, + DownloadURL: "", CreatedAt: obj.CreatedAt, } @@ -1907,46 +606,6 @@ func (h *Handler) ConfirmUpload(w http.ResponseWriter, r *http.Request) { // 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), @@ -1959,6 +618,7 @@ func humanToDTO(h *human.Human) Human { 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 { @@ -1985,70 +645,14 @@ func (h *Handler) networkToDTO(ctx context.Context, n *network.Network) (Network } return Network{ - Id: n.ID, - Name: n.Name, - AdminHuman: humanToDTO(adminHuman), - Humans: humans, - OpenStreamCount: n.OpenStreamCount, - OpenStreamCapacity: n.OpenStreamCapacity, - CreatedAt: n.CreatedAt, + Id: n.ID, + Name: n.Name, + AdminHuman: humanToDTO(adminHuman), + Humans: humans, + 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 == "" {