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 == "" { diff --git a/js/package.json b/js/package.json index 931e744..e7bc81a 100644 --- a/js/package.json +++ b/js/package.json @@ -31,6 +31,7 @@ "@electron-forge/plugin-vite": "^7.11.1", "@electron/fuses": "^1.8.0", "@tailwindcss/vite": "^4.2.0", + "@tanstack/eslint-plugin-query": "^5.91.4", "@types/electron-squirrel-startup": "^1.0.2", "@types/node": "^25.3.0", "@types/react": "^19.2.14", @@ -41,13 +42,15 @@ "electron": "40.6.0", "eslint": "^8.57.1", "eslint-plugin-import": "^2.32.0", - "typescript": "~4.5.4", + "typescript": "^5.9.3", "vite": "^5.4.21" }, "dependencies": { + "@tanstack/react-query": "^5.90.21", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "electron-squirrel-startup": "^1.0.1", + "firebase": "^12.10.0", "lucide-react": "^0.575.0", "radix-ui": "^1.4.3", "react": "^19.2.4", @@ -57,6 +60,7 @@ "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.0", "tw-animate-css": "^1.4.0", + "zod": "^4.3.6", "zustand": "^5.0.11" } } diff --git a/js/src/App.tsx b/js/src/App.tsx index c372cd3..72ffd96 100644 --- a/js/src/App.tsx +++ b/js/src/App.tsx @@ -1,39 +1,16 @@ import { useEffect } from "react"; import { HashRouter, Routes, Route } from "react-router-dom"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { useAppStore } from "@/stores/app-store"; import { useAuthStore } from "@/stores/auth-store"; import { LoginPage } from "@/features/auth/login-page"; -import { StreamsPage } from "@/pages/streams-page"; -import { StreamPlayerPage } from "@/pages/stream-player-page"; +import { } from "@/firebase"; +import { + QueryClient, + QueryClientProvider, +} from '@tanstack/react-query' +import PathResolver from "./pages/path-resolver"; -function AuthenticatedApp() { - const fetchStartup = useAppStore((s) => s.fetchStartup); - const isLoading = useAppStore((s) => s.isLoading); - - useEffect(() => { - fetchStartup(); - }, [fetchStartup]); - - if (isLoading) { - return ( -
-

Loading...

-
- ); - } - - return ( - - - - } /> - } /> - - - - ); -} +const queryClient = new QueryClient(); const App = () => { const status = useAuthStore((s) => s.status); @@ -58,4 +35,23 @@ const App = () => { return ; }; -export default App; +function AuthenticatedApp() { + // NOTE: Hash router provides history, despite using catch-all + return ( + + + } /> + + + ); +} + +const AppWithProviders = () => ( + + + + + +); + +export default AppWithProviders; diff --git a/js/src/api/client.ts b/js/src/api/client.ts index f08f0d8..d27afa0 100644 --- a/js/src/api/client.ts +++ b/js/src/api/client.ts @@ -1,17 +1,19 @@ import { useSessionStore } from "@/stores/session-store"; +import type { z } from "zod"; +import { + DepotObjectSchema, + HumanSchema, + ListNetworksResponseSchema, + NetworkSchema, + PrepareUploadResponseSchema, + SignInResponseSchema, +} from "./types"; import type { - CreateStreamParticleRequest, - CreateStreamRequest, - Human, - MarkSeenBatchRequest, + AddMembersRequest, + CreateNetworkRequest, PrepareUploadRequest, - PrepareUploadResponse, RequestCodeRequest, SignInRequest, - SignInResponse, - StartupResponse, - Stream, - StreamParticle, } from "./types"; export class ApiError extends Error { @@ -37,11 +39,11 @@ class ApiClient { this.config = config; } - private async request( + private async fetch( method: string, path: string, body?: unknown, - ): Promise { + ): Promise { const headers: Record = { "Content-Type": "application/json", }; @@ -67,109 +69,106 @@ class ApiClient { throw new ApiError(response.status, text); } - if (response.status === 204) { - return undefined as T; - } + return response; + } - return response.json() as Promise; + private async request( + schema: z.ZodType, + method: string, + path: string, + body?: unknown, + ): Promise { + const response = await this.fetch(method, path, body); + const json = await response.json(); + return schema.parse(json); + } + + private async requestVoid( + method: string, + path: string, + body?: unknown, + ): Promise { + await this.fetch(method, path, body); } // --- Auth --- async requestCode(data: RequestCodeRequest): Promise { - await this.request("POST", "/auth/request-code", data); + await this.requestVoid("POST", "/auth/request-code", data); } - async signIn(data: SignInRequest): Promise { - return this.request("POST", "/auth/sign-in", data); + async signIn(data: SignInRequest) { + return this.request(SignInResponseSchema, "POST", "/auth/sign-in", data); } - async me(): Promise { - return this.request("GET", "/auth/me"); + async me() { + return this.request(HumanSchema, "GET", "/auth/me"); } async signOut(): Promise { - await this.request("POST", "/auth/sign-out"); + await this.requestVoid("POST", "/auth/sign-out"); } - // --- Startup --- - - async startup(): Promise { - return this.request("GET", "/startup"); - } - - // --- Streams --- - - async createStream( - networkId: string, - data: CreateStreamRequest, - ): Promise { - return this.request( - "POST", - `/networks/${networkId}/streams`, - data, + // TODO: security: require passing in the particle id once api deprecates this + async getParticleDownloadUrl(objectId: string): Promise { + const response = await this.fetch( + "GET", + `/particles/${objectId}/download`, ); - } - - async createStreamParticle( - streamId: string, - data: CreateStreamParticleRequest, - ): Promise { - return this.request( - "POST", - `/streams/${streamId}/particles`, - data, - ); - } - - // --- Particles --- - - async markSeen(particleId: string): Promise { - await this.request("POST", `/particles/${particleId}/seen`); - } - - async ackParticle(particleId: string): Promise { - await this.request("POST", `/particles/${particleId}/ack`); - } - - async markSeenBatch(data: MarkSeenBatchRequest): Promise { - await this.request("POST", "/particles/seen", data); - } - - async getParticleDownloadUrl(particleId: string): Promise { - const token = this.config.getToken(); - const headers: Record = {}; - if (token) { - headers["Authorization"] = `Bearer ${token}`; - } - - const response = await fetch( - `${this.config.baseUrl}/particles/${particleId}/download`, - { headers, redirect: "follow" }, - ); - - if (response.status === 401) { - this.config.onUnauthorized(); - throw new ApiError(401, "Unauthorized"); - } - - if (!response.ok) { - throw new ApiError(response.status, "Failed to get download URL"); - } - return response.url; } // --- Depot --- - async prepareUpload( - data: PrepareUploadRequest, - ): Promise { - return this.request("POST", "/depot/upload", data); + async prepareUpload(data: PrepareUploadRequest) { + return this.request( + PrepareUploadResponseSchema, + "POST", + "/depot/upload", + data, + ); } - async confirmUpload(objectId: string): Promise { - await this.request("POST", `/depot/objects/${objectId}/confirm`); + async confirmUpload(objectId: string) { + return this.request( + DepotObjectSchema, + "POST", + `/depot/objects/${objectId}/confirm`, + ); + } + + + // --- Networks --- + + async listNetworks() { + return this.request( + ListNetworksResponseSchema, + "GET", + "/networks", + ); + } + + async createNetwork(data: CreateNetworkRequest) { + return this.request(NetworkSchema, "POST", "/networks", data); + } + + async getNetwork(id: string) { + return this.request(NetworkSchema, "GET", `/networks/${id}`); + } + + async addMembers(networkId: string, data: AddMembersRequest): Promise { + await this.requestVoid( + "POST", + `/networks/${networkId}/members`, + data, + ); + } + + async removeMember(networkId: string, email: string): Promise { + await this.requestVoid( + "DELETE", + `/networks/${networkId}/members/${email}`, + ); } } diff --git a/js/src/api/types.ts b/js/src/api/types.ts index e0eb6fa..1583c24 100644 --- a/js/src/api/types.ts +++ b/js/src/api/types.ts @@ -1,168 +1,174 @@ -// --- Core entities --- +import { z } from "zod"; -export interface Human { - id: string | null; - email: string; - email_prefix: string; - created_at: string | null; -} +export const HumanSchema = z.object({ + id: z.string().nullable(), + created_at: z.coerce.date().nullable(), + email: z.string().email(), + email_prefix: z.string(), +}); -export type StreamStatus = "open" | "closed" | "unspecified"; +export type Human = z.infer; -export interface AckInfo { - email: string; - acked_at: string; -} +export const NetworkSchema = z.object({ + id: z.string(), + name: z.string(), + admin_human: HumanSchema, + humans: z.array(HumanSchema), + created_at: z.coerce.date(), +}); -// --- Particle types --- +export type Network = z.infer; -export type ParticleType = - | "media" - | "text" - | "quest" - | "paper" - | "file" - | "folder"; +export const ListNetworksResponseSchema = z.array(NetworkSchema); +export type ListNetworksResponse = z.infer; -export interface MediaParticleData { - object_id: string; - duration_ms: number; - mime_type: string; -} +// --- Network request/response types --- -export interface TextParticleData { - content: string; -} +const CreateNetworkRequestSchema = z.object({ + name: z.string(), +}); +export type CreateNetworkRequest = z.infer; -export interface QuestParticleData { - title: string; - description: string; - status?: string; - assigned_to?: string; - due_date?: string; -} - -export interface PaperParticleData { - title: string; - content: string; -} - -export interface FileParticleData { - object_id: string; - filename: string; - mime_type: string; - size: number; -} - -export interface FolderParticleData { - name: string; - color?: string; -} - -export interface ParticleDataMap { - media: MediaParticleData; - text: TextParticleData; - quest: QuestParticleData; - paper: PaperParticleData; - file: FileParticleData; - folder: FolderParticleData; -} - -export function getParticleData( - particle: StreamParticle, - type: T, -): ParticleDataMap[T] { - return particle.data as ParticleDataMap[T]; -} - -export interface StreamParticle { - id: string; - type: ParticleType; - data: unknown; - created_by_email: string; - seen: boolean; - acks: AckInfo[]; - updated_at: string; - created_at: string; -} - -export interface Stream { - id: string; - name: string; - description: string; - status: StreamStatus; - members?: string[]; - particles: StreamParticle[]; - unseen_count: number; - updated_at: string; - created_at: string; -} - -export interface Network { - id: string; - name: string; - admin_human: Human; - humans: Human[]; - open_stream_count: number; - open_stream_capacity: number; - created_at: string; -} - -export interface NetworkWithStreams extends Network { - streams: Stream[]; -} +const AddMembersRequestSchema = z.object({ + email_addresses: z.array(z.string().email()), +}); +export type AddMembersRequest = z.infer; // --- Depot types --- -export interface PrepareUploadRequest { - network_id: string; - name: string; - content_type: string; - content_length: number; +const PrepareUploadRequestSchema = z.object({ + network_id: z.string(), + name: z.string(), + content_type: z.string(), + content_length: z.number(), +}); +export type PrepareUploadRequest = z.infer; + +export const PrepareUploadResponseSchema = z.object({ + object_id: z.string(), + upload_url: z.string(), + upload_headers: z.record(z.string(), z.string()), +}); +export type PrepareUploadResponse = z.infer; + +export const DepotObjectSchema = z.object({ + id: z.string(), + name: z.string(), + content_type: z.string(), + content_length: z.number(), + contains_content: z.boolean(), + created_at: z.coerce.date(), +}); +export type DepotObject = z.infer; + +// --- Particle property schemas --- + +export const StreamPropertiesSchema = z.object({ + name: z.string(), + status: z.enum(["open", "closed"]), + description: z.string().optional(), +}); +export type StreamProperties = z.infer; + +export const FolderPropertiesSchema = z.object({ + name: z.string(), + color: z.string().optional(), +}); +export type FolderProperties = z.infer; + +export const MediaPropertiesSchema = z.object({ + object_id: z.string(), + mime_type: z.string(), + duration_ms: z.number(), + size_bytes: z.number(), +}); +export type MediaProperties = z.infer; + +export const FilePropertiesSchema = z.object({ + object_id: z.string(), + filename: z.string(), + mime_type: z.string(), + size_bytes: z.number(), +}); +export type FileProperties = z.infer; + +export const TextPropertiesSchema = z.object({ + content: z.string(), +}); +export type TextProperties = z.infer; + +export const QuestPropertiesSchema = z.object({ + title: z.string(), + description: z.string(), + status: z.string().optional(), + assigned_to: z.string().email().optional(), +}); +export type QuestProperties = z.infer; + +export const PaperPropertiesSchema = z.object({ + title: z.string(), + content: z.string(), +}); +export type PaperProperties = z.infer; + +export interface ParticlePropertiesMap { + stream: StreamProperties; + folder: FolderProperties; + media: MediaProperties; + file: FileProperties; + text: TextProperties; + quest: QuestProperties; + paper: PaperProperties; } -export interface PrepareUploadResponse { - object_id: string; - upload_url: string; - upload_headers: Record; -} +// --- Unified Particle types --- -// --- Stream mutation types --- +const ParticleBaseSchema = z.object({ + id: z.string(), + created_at: z.coerce.date(), + created_by_email: z.string().email(), + updated_at: z.coerce.date().optional(), + // e.g. ["human:aron@acme.com", "human:john@acme.com"] - visible only to Aron and John + // e.g. ["network:123"] - visible to everyone in the network + visible_to: z.array(z.string()) +}); -export interface CreateStreamRequest { - name: string; - description: string; - visibility: "network_all" | "custom"; - member_emails?: string[]; -} +export const ParticleSchema = z.discriminatedUnion("type", [ + ParticleBaseSchema.extend({ type: z.literal("stream"), properties: StreamPropertiesSchema }), + ParticleBaseSchema.extend({ type: z.literal("folder"), properties: FolderPropertiesSchema }), + ParticleBaseSchema.extend({ type: z.literal("media"), properties: MediaPropertiesSchema }), + ParticleBaseSchema.extend({ type: z.literal("file"), properties: FilePropertiesSchema }), + ParticleBaseSchema.extend({ type: z.literal("text"), properties: TextPropertiesSchema }), + ParticleBaseSchema.extend({ type: z.literal("quest"), properties: QuestPropertiesSchema }), + ParticleBaseSchema.extend({ type: z.literal("paper"), properties: PaperPropertiesSchema }), +]); -export interface CreateStreamParticleRequest { - type: ParticleType; - data: unknown; -} +export type Particle = z.infer; -export interface MarkSeenBatchRequest { - particle_ids: string[]; +export type ParticleType = Particle["type"]; + +/** Container types can have children subcollections */ +export const CONTAINER_TYPES: ReadonlySet = new Set(["stream", "folder"]); + +export function isContainerType(type: ParticleType): boolean { + return CONTAINER_TYPES.has(type); } // --- Auth types --- -export interface RequestCodeRequest { - email: string; -} +const RequestCodeRequestSchema = z.object({ + email: z.string().email(), +}); +export type RequestCodeRequest = z.infer; -export interface SignInRequest { - email: string; - code: string; -} +const SignInRequestSchema = z.object({ + email: z.string().email(), + code: z.string(), +}); +export type SignInRequest = z.infer; -export interface SignInResponse { - human: Human; - token: string; -} - -// --- Startup --- - -export interface StartupResponse { - networks: NetworkWithStreams[]; -} +export const SignInResponseSchema = z.object({ + human: HumanSchema, + token: z.string(), +}); +export type SignInResponse = z.infer; diff --git a/js/src/features/network-selector.tsx b/js/src/features/network-selector.tsx new file mode 100644 index 0000000..2e8383a --- /dev/null +++ b/js/src/features/network-selector.tsx @@ -0,0 +1,86 @@ +import { useNavigate } from "react-router-dom"; +import { LogOut } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Muted } from "@/components/ui/typography"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useAuthStore } from "@/stores/auth-store"; +import { WindowControls } from "@/components/window-controls"; +import { useQuery } from "@tanstack/react-query"; +import { apiClient } from "@/api/client"; +import { Progress } from "@/components/ui/progress"; + +export function NetworkSelector() { + const navigate = useNavigate(); + const signOut = useAuthStore((s) => s.signOut); + const user = useAuthStore((s) => s.user); + + const { data, isPending, error } = useQuery({ + queryKey: ["networks"], + queryFn: () => apiClient.listNetworks(), + }); + + if (isPending) { + return ; + } + + if (error) { + return ( +
+

Failed to load networks

+

{error.message}

+
+ ); + } + + if (data?.length === 0) { + return ( +
+

+ You don't have access to any networks yet. Please email us to get started. +

+ + team@flowylabs.ai + +
+ ); + } + + return ( +
+
+ +
+ {user && {user.email_prefix}} + +
+ +
+ +
+
+ ); +} diff --git a/js/src/features/particles/folder-view.tsx b/js/src/features/particles/folder-view.tsx new file mode 100644 index 0000000..41866d4 --- /dev/null +++ b/js/src/features/particles/folder-view.tsx @@ -0,0 +1,20 @@ +import { Particle } from "@/api/types"; +import { useParticleChildren } from "@/hooks/use-particle-children"; + +interface FolderViewProps { + folderParticle: Particle; + networkId: string; + particleSegments: string[]; +} + +export function FolderView({ networkId, particleSegments, folderParticle }: FolderViewProps) { + const { children, error, isLoading } = useParticleChildren(networkId, particleSegments); + + return ( +
+

+ Folder view — {networkId}/{particleSegments.join("/")} +

+
+ ); +} diff --git a/js/src/features/particles/particle-list-view.tsx b/js/src/features/particles/particle-list-view.tsx new file mode 100644 index 0000000..54f66cd --- /dev/null +++ b/js/src/features/particles/particle-list-view.tsx @@ -0,0 +1,43 @@ +import { useParticleChildren } from "@/hooks/use-particle-children"; + +interface ParticleListViewProps { + networkId: string; + particleSegments: string[]; +} + +/** + * Grid/list of child particles for a container (folder, stream root, or network root). + */ +export function ParticleListView({ networkId, particleSegments }: ParticleListViewProps) { + const { children, isLoading } = useParticleChildren(networkId, particleSegments); + + if (isLoading) { + return ( +
+

Loading particles...

+
+ ); + } + + if (children.length === 0) { + return ( +
+

No particles yet

+
+ ); + } + + return ( +
+ {children.map((child) => ( +
+

{child.id}

+

{child.type}

+
+ ))} +
+ ); +} diff --git a/js/src/features/particles/particle-view-resolver.tsx b/js/src/features/particles/particle-view-resolver.tsx new file mode 100644 index 0000000..7d05ea3 --- /dev/null +++ b/js/src/features/particles/particle-view-resolver.tsx @@ -0,0 +1,65 @@ +import { useParticle } from "@/hooks/use-particle"; +import { StreamView } from "./stream-view"; +import { FolderView } from "./folder-view"; +import { ParticleListView } from "./particle-list-view"; +import { isContainerType } from "@/api/types"; + +interface ParticleViewResolverProps { + networkId: string; + particleSegments: string[]; +} + +/** + * Resolves a particle by its path segments and renders the appropriate view + * based on particle type (e.g. stream would show clips in story mode, folder would list files, etc.) + */ +export function ParticleViewResolver({ networkId, particleSegments }: ParticleViewResolverProps) { + const { particle, isLoading, error } = useParticle(networkId, particleSegments); + + if (isLoading) { + return ( +
+

Loading...

+
+ ); + } + + if (error) { + return ( +
+

Failed to load particle

+
+ ); + } + + // While the hook is stubbed, particle will be null — show a placeholder + if (!particle) { + return ( +
+

+ Particle: {particleSegments.join(" / ")} +

+
+ ); + } + + switch (particle.type) { + case "stream": + return ; + case "folder": + return ; + default: + // For container types we haven't built a view for, fall back to list + if (isContainerType(particle.type)) { + return ; + } + // Leaf particle — placeholder + return ( +
+

+ {particle.type} particle: {particle.id} +

+
+ ); + } +} diff --git a/js/src/features/particles/stream-view.tsx b/js/src/features/particles/stream-view.tsx new file mode 100644 index 0000000..836ca16 --- /dev/null +++ b/js/src/features/particles/stream-view.tsx @@ -0,0 +1,36 @@ +import { Particle } from "@/api/types"; +import { useParticleChildren } from "@/hooks/use-particle-children"; + +interface StreamViewProps { + streamParticle: Particle; + networkId: string; + particleSegments: string[]; +} + +export function StreamView({ networkId, particleSegments, streamParticle }: StreamViewProps) { + const { children, error, isLoading } = useParticleChildren(networkId, particleSegments); + + return ( +
+

+ Stream view — {networkId}/{particleSegments.join("/")} +

+ + {isLoading &&

Loading stream data...

} + {error &&

Failed to load stream data

} + + {!isLoading && !error && ( +
+

Stream Children:

+
    + {children.map((child) => ( +
  • + {child.id} ({child.type}) +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/js/src/features/streams/stream-list.tsx b/js/src/features/streams/stream-list.tsx deleted file mode 100644 index 0124621..0000000 --- a/js/src/features/streams/stream-list.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { useNavigate } from "react-router-dom"; -import { Badge } from "@/components/ui/badge"; -import { Card } from "@/components/ui/card"; -import { Avatar, AvatarFallback } from "@/components/ui/avatar"; -import { useAppStore } from "@/stores/app-store"; -import { flattenStreams } from "@/lib/stream-utils"; -import { formatDistanceToNow } from "@/lib/time-utils"; -import { ParticlePreview } from "./particle-preview"; - -function getInitials(email: string): string { - const prefix = email.split("@")[0] ?? ""; - return prefix.slice(0, 2).toUpperCase(); -} - -export function StreamList() { - const networks = useAppStore((s) => s.networks); - const selectedNetworkId = useAppStore((s) => s.selectedNetworkId); - const navigate = useNavigate(); - - const streams = flattenStreams(networks, selectedNetworkId); - - if (streams.length === 0) { - return ( -
-

No streams yet

-
- ); - } - - return ( -
- {streams.map((stream) => { - const lastParticle = - stream.particles.length > 0 - ? stream.particles[stream.particles.length - 1] - : null; - - const timeSource = lastParticle?.created_at ?? stream.updated_at; - const senderEmail = lastParticle?.created_by_email; - const senderPrefix = senderEmail?.split("@")[0]; - - return ( - navigate(`/streams/${stream.id}`)} - > - {/* Preview hero area */} -
- {lastParticle ? ( - - ) : ( -
-

- No messages yet -

-
- )} - - {/* Unseen badge overlay */} - {stream.unseen_count > 0 && ( - - {stream.unseen_count} - - )} -
- - {/* Footer: avatar + stream info */} -
- {senderEmail ? ( - - - {getInitials(senderEmail)} - - - ) : ( -
- )} - -
-

{stream.name}

-

- {senderPrefix && {senderPrefix}} - {senderPrefix && timeSource && · } - {timeSource && {formatDistanceToNow(timeSource)}} -

-
-
- - ); - })} -
- ); -} diff --git a/js/src/firebase.ts b/js/src/firebase.ts new file mode 100644 index 0000000..06e569b --- /dev/null +++ b/js/src/firebase.ts @@ -0,0 +1,19 @@ +import { initializeApp } from 'firebase/app'; +import { initializeFirestore, persistentLocalCache, persistentMultipleTabManager } from "firebase/firestore"; + +const firebaseConfig = { + apiKey: "AIzaSyDF_fbk7tDY9tNxUiOwwi--2nYZWZygMGk", + appId: "1:1006580076785:web:e2a0736d60a78e02b15950", + authDomain: "flowy-dev-440017.firebaseapp.com", + messagingSenderId: "1006580076785", + projectId: "flowy-dev-440017", + storageBucket: "flowy-dev-440017.firebasestorage.app", +}; + +export const firebaseApp = initializeApp(firebaseConfig); + +export const firestoreDb = initializeFirestore(firebaseApp, + { + localCache: + persistentLocalCache(/*settings*/{ tabManager: persistentMultipleTabManager() }) + }); diff --git a/js/src/hooks/use-create-particle.ts b/js/src/hooks/use-create-particle.ts new file mode 100644 index 0000000..d6df5b0 --- /dev/null +++ b/js/src/hooks/use-create-particle.ts @@ -0,0 +1,24 @@ +import { useMutation } from "@tanstack/react-query"; +import { createParticle } from "@/lib/firestore-particles"; +import type { ParticleType, ParticlePropertiesMap } from "@/api/types"; + +interface CreateParticleParams { + collectionPath: string; + type: T; + properties: ParticlePropertiesMap[T]; + createdByEmail: string; + visibleTo: string[]; +} + +export function useCreateParticle() { + return useMutation({ + mutationFn: (params: CreateParticleParams) => + createParticle( + params.collectionPath, + params.type, + params.properties, + params.createdByEmail, + params.visibleTo, + ), + }); +} diff --git a/js/src/hooks/use-particle-children.ts b/js/src/hooks/use-particle-children.ts new file mode 100644 index 0000000..03d3be9 --- /dev/null +++ b/js/src/hooks/use-particle-children.ts @@ -0,0 +1,46 @@ +import { useState, useEffect, useMemo } from "react"; +import { subscribeToParticleChildren } from "@/lib/firestore-particles"; +import { firestorePath } from "@/lib/firestore-paths"; +import type { Particle } from "@/api/types"; + +interface UseParticleChildrenResult { + children: Particle[]; + isLoading: boolean; + error: Error | null; +} + +export function useParticleChildren( + networkId: string, + parentSegments: string[], +): UseParticleChildrenResult { + const [children, setChildren] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const collectionPath = useMemo(() => { + if (parentSegments.length === 0) return firestorePath(networkId, []); + return `${firestorePath(networkId, parentSegments)}/children`; + }, [networkId, parentSegments.join("/")]); + + useEffect(() => { + setIsLoading(true); + setError(null); + setChildren([]); + + const unsubscribe = subscribeToParticleChildren( + collectionPath, + (data) => { + setChildren(data); + setIsLoading(false); + }, + (err) => { + setError(err); + setIsLoading(false); + }, + ); + + return unsubscribe; + }, [collectionPath]); + + return { children, isLoading, error }; +} diff --git a/js/src/hooks/use-particle.ts b/js/src/hooks/use-particle.ts new file mode 100644 index 0000000..6a04a1d --- /dev/null +++ b/js/src/hooks/use-particle.ts @@ -0,0 +1,46 @@ +import { useState, useEffect, useMemo } from "react"; +import { subscribeToParticle } from "@/lib/firestore-particles"; +import { firestorePath } from "@/lib/firestore-paths"; +import type { Particle } from "@/api/types"; + +interface UseParticleResult { + particle: Particle | null; + isLoading: boolean; + error: Error | null; +} + +export function useParticle( + networkId: string, + segments: string[], +): UseParticleResult { + const [particle, setParticle] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const path = useMemo( + () => firestorePath(networkId, segments), + [networkId, segments.join("/")], + ); + + useEffect(() => { + setIsLoading(true); + setError(null); + setParticle(null); + + const unsubscribe = subscribeToParticle( + path, + (data) => { + setParticle(data); + setIsLoading(false); + }, + (err) => { + setError(err); + setIsLoading(false); + }, + ); + + return unsubscribe; + }, [path]); + + return { particle, isLoading, error }; +} diff --git a/js/src/lib/firestore-particles.ts b/js/src/lib/firestore-particles.ts new file mode 100644 index 0000000..961860b --- /dev/null +++ b/js/src/lib/firestore-particles.ts @@ -0,0 +1,128 @@ +import { + collection, + doc, + onSnapshot, + addDoc, + updateDoc, + query, + orderBy, + serverTimestamp, + Timestamp, + type DocumentData, + type FirestoreDataConverter, + type QueryDocumentSnapshot, + type SnapshotOptions, + type Unsubscribe, +} from "firebase/firestore"; +import { firestoreDb } from "@/firebase"; +import { ParticleSchema } from "@/api/types"; +import type { Particle, ParticleType, ParticlePropertiesMap } from "@/api/types"; + +// --- Converter --- + +const particleConverter: FirestoreDataConverter = { + toFirestore(particle: Particle): DocumentData { + const { id: _id, created_at, updated_at, ...rest } = particle; + return { + ...rest, + created_at: Timestamp.fromDate(created_at), + ...(updated_at && { updated_at: Timestamp.fromDate(updated_at) }), + }; + }, + fromFirestore( + snap: QueryDocumentSnapshot, + options?: SnapshotOptions, + ): Particle { + const raw = snap.data(options); + return ParticleSchema.parse({ + id: snap.id, + type: raw.type, + properties: raw.properties, + created_at: (raw.created_at as Timestamp).toDate(), + created_by_email: raw.created_by_email, + updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined, + visible_to: raw.visible_to, + }); + }, +}; + +// --- Typed reference helpers --- + +function typedDoc(path: string) { + return doc(firestoreDb, path).withConverter(particleConverter); +} + +function typedCollection(path: string) { + return collection(firestoreDb, path).withConverter(particleConverter); +} + +// --- Exported operations --- + +export function subscribeToParticle( + docPath: string, + onData: (particle: Particle | null) => void, + onError: (error: Error) => void, +): Unsubscribe { + return onSnapshot( + typedDoc(docPath), + (snap) => { + onData(snap.exists() ? snap.data() : null); + }, + onError, + ); +} + +export function subscribeToParticleChildren( + collectionPath: string, + onData: (children: Particle[]) => void, + onError: (error: Error) => void, +): Unsubscribe { + const q = query(typedCollection(collectionPath), orderBy("created_at")); + return onSnapshot( + q, + (snap) => { + onData(snap.docs.map((d) => d.data())); + }, + onError, + ); +} + +export async function createParticle( + collectionPath: string, + type: T, + properties: ParticlePropertiesMap[T], + createdByEmail: string, + visibleTo: string[], +): Promise { + const particle: Particle = ParticleSchema.parse({ + id: "", // ignored by toFirestore, but needed to satisfy the type + type, + properties, + created_at: new Date(), + created_by_email: createdByEmail, + updated_at: null, + visible_to: visibleTo, + }); + const ref = await addDoc(typedCollection(collectionPath), particle); + return ref.id; +} + +// This allows updating properties without overwriting the entire properties object +export async function updateParticle( + docPath: string, + properties: Partial, + visibleTo?: string[], +): Promise { + const particleRef = typedDoc(docPath); + // Take the partial and create a new object with dot notation + // e.g. { title: "New Title" } becomes { "properties.title": "New Title" } + const updatedProperties: Record = {}; + for (const key in properties) { + updatedProperties[`properties.${key}`] = properties[key]; + } + await updateDoc(particleRef, { + ...updatedProperties, + updated_at: serverTimestamp(), + ...(visibleTo ? { visible_to: visibleTo } : {}), + }); +} diff --git a/js/src/lib/firestore-paths.ts b/js/src/lib/firestore-paths.ts new file mode 100644 index 0000000..12e245c --- /dev/null +++ b/js/src/lib/firestore-paths.ts @@ -0,0 +1,23 @@ +/** + * Map URL segments to Firestore paths. + * + * Firestore structure: + * networks/{networkId}/particles/{particleId} + * networks/{networkId}/particles/{particleId}/children/{childId} + * ...and so on for arbitrary depth. + * + * Examples: + * segments = [] → "networks/{nid}/particles" + * segments = ["p1"] → "networks/{nid}/particles/p1" + * segments = ["p1", "p2"] → "networks/{nid}/particles/p1/children/p2" + */ +export function firestorePath(networkId: string, segments: string[]): string { + const base = `networks/${networkId}/particles`; + if (segments.length === 0) return base; + + const parts: string[] = [base, segments[0]]; + for (let i = 1; i < segments.length; i++) { + parts.push("children", segments[i]); + } + return parts.join("/"); +} diff --git a/js/src/lib/stream-utils.ts b/js/src/lib/stream-utils.ts deleted file mode 100644 index fb95abf..0000000 --- a/js/src/lib/stream-utils.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { NetworkWithStreams, Stream } from "@/api/types"; - -export interface FlatStream extends Stream { - networkId: string; - networkName: string; -} - -export function flattenStreams( - networks: NetworkWithStreams[], - selectedNetworkId: string | null, -): FlatStream[] { - const filtered = selectedNetworkId - ? networks.filter((n) => n.id === selectedNetworkId) - : networks; - - const streams: FlatStream[] = filtered.flatMap((n) => - n.streams.map((s) => ({ - ...s, - networkId: n.id, - networkName: n.name, - })), - ); - - return streams.sort((a, b) => { - const aTime = getLatestParticleTime(a); - const bTime = getLatestParticleTime(b); - return bTime - aTime; - }); -} - -function getLatestParticleTime(stream: Stream): number { - if (stream.particles.length === 0) { - return new Date(stream.updated_at).getTime() || 0; - } - const last = stream.particles[stream.particles.length - 1]; - return new Date(last.created_at).getTime(); -} diff --git a/js/src/lib/utils.ts b/js/src/lib/utils.ts index b178ee7..fd4498c 100644 --- a/js/src/lib/utils.ts +++ b/js/src/lib/utils.ts @@ -4,3 +4,8 @@ import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +export function getInitials(email: string): string { + const prefix = email.split("@")[0] ?? ""; + return prefix.slice(0, 2).toUpperCase(); +} diff --git a/js/src/pages/path-resolver.tsx b/js/src/pages/path-resolver.tsx new file mode 100644 index 0000000..718a72e --- /dev/null +++ b/js/src/pages/path-resolver.tsx @@ -0,0 +1,33 @@ +import { useLocation } from "react-router-dom"; +import { NetworkSelector } from "@/features/network-selector"; +import { ParticleListView } from "@/features/particles/particle-list-view"; +import { ParticleViewResolver } from "@/features/particles/particle-view-resolver"; + +function parsePathSegments(path: string): string[] { + return path.split("/").filter(Boolean); +} + +/** + * URL structure: + * / → network selector + * /:networkId → root particles for that network + * /:networkId/:p1/:p2/... → nested particle view (renders the parent which will use it's children) + */ +export default function PathResolver() { + const segments = parsePathSegments(useLocation().pathname); + + // No segments → show network selector + if (segments.length === 0) { + return ; + } + + const [networkId, ...particleSegments] = segments; + + // /:networkId with no particle segments → root particle list + if (particleSegments.length === 0) { + return ; + } + + // /:networkId/:p1/:p2/... → resolve and render the container particle + return ; +} diff --git a/js/src/pages/streams-page.tsx b/js/src/pages/streams-page.tsx deleted file mode 100644 index 6b5dd23..0000000 --- a/js/src/pages/streams-page.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { Plus, LogOut } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Muted } from "@/components/ui/typography"; -import { Progress } from "@/components/ui/progress"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { useAppStore } from "@/stores/app-store"; -import { useAuthStore } from "@/stores/auth-store"; -import { StreamList } from "@/features/streams/stream-list"; -import { CreateStreamDialog } from "@/features/streams/create-stream-dialog"; -import { WindowControls } from "@/components/window-controls"; - -export function StreamsPage() { - const networks = useAppStore((s) => s.networks); - const selectedNetworkId = useAppStore((s) => s.selectedNetworkId); - const setSelectedNetwork = useAppStore((s) => s.setSelectedNetwork); - const signOut = useAuthStore((s) => s.signOut); - const user = useAuthStore((s) => s.user); - - const selectedNetwork = selectedNetworkId - ? networks.find((n) => n.id === selectedNetworkId) - : null; - - return ( -
- {/* Top bar — draggable for frameless window */} -
- - - - -
- - {selectedNetworkId && ( - - - - )} - - {user && ( - {user.email_prefix} - )} - - -
- - {/* Stream list */} - - - -
- ); -} diff --git a/js/src/stores/app-store.ts b/js/src/stores/app-store.ts index f0b3034..1c7e56a 100644 --- a/js/src/stores/app-store.ts +++ b/js/src/stores/app-store.ts @@ -1,107 +1,15 @@ import { create } from "zustand"; -import { apiClient } from "@/api/client"; -import type { - AckInfo, - NetworkWithStreams, - Stream, - StreamParticle, -} from "@/api/types"; +/** + * Minimal app-level store. Navigation state is now URL-driven via PathResolver. + * Stream/particle state will move to Firestore hooks. + */ interface AppState { - networks: NetworkWithStreams[]; selectedNetworkId: string | null; - isLoading: boolean; - - fetchStartup: () => Promise; setSelectedNetwork: (id: string | null) => void; - addStream: (networkId: string, stream: Stream) => void; - addParticleToStream: (streamId: string, particle: StreamParticle) => void; - markParticlesSeen: (particleIds: string[]) => void; - ackParticle: (particleId: string, email: string) => void; } -export const useAppStore = create((set, get) => ({ - networks: [], +export const useAppStore = create((set) => ({ selectedNetworkId: null, - isLoading: false, - - fetchStartup: async () => { - set({ isLoading: true }); - try { - const data = await apiClient.startup(); - const state = get(); - const shouldAutoSelect = - !state.selectedNetworkId && data.networks.length > 0; - set({ - networks: data.networks, - ...(shouldAutoSelect - ? { selectedNetworkId: data.networks[0].id } - : {}), - }); - } finally { - set({ isLoading: false }); - } - }, - - setSelectedNetwork: (id) => { - set({ selectedNetworkId: id }); - }, - - addStream: (networkId, stream) => { - set({ - networks: get().networks.map((n) => - n.id === networkId ? { ...n, streams: [stream, ...n.streams] } : n, - ), - }); - }, - - addParticleToStream: (streamId, particle) => { - set({ - networks: get().networks.map((n) => ({ - ...n, - streams: n.streams.map((s) => - s.id === streamId - ? { ...s, particles: [...s.particles, particle] } - : s, - ), - })), - }); - }, - - ackParticle: (particleId, email) => { - const ack: AckInfo = { email, acked_at: new Date().toISOString() }; - set({ - networks: get().networks.map((n) => ({ - ...n, - streams: n.streams.map((s) => ({ - ...s, - particles: s.particles.map((p) => - p.id === particleId ? { ...p, acks: [...p.acks, ack] } : p, - ), - })), - })), - }); - }, - - markParticlesSeen: (particleIds) => { - const idSet = new Set(particleIds); - set({ - networks: get().networks.map((n) => ({ - ...n, - streams: n.streams.map((s) => { - const unseenMarked = s.particles.filter( - (p) => !p.seen && idSet.has(p.id), - ).length; - if (unseenMarked === 0) return s; - return { - ...s, - unseen_count: Math.max(0, s.unseen_count - unseenMarked), - particles: s.particles.map((p) => - idSet.has(p.id) ? { ...p, seen: true } : p, - ), - }; - }), - })), - }); - }, + setSelectedNetwork: (id) => set({ selectedNetworkId: id }), })); diff --git a/js/yarn.lock b/js/yarn.lock index 219f197..40f0e72 100644 --- a/js/yarn.lock +++ b/js/yarn.lock @@ -814,7 +814,7 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c" integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw== -"@eslint-community/eslint-utils@^4.2.0": +"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.9.1": version "4.9.1" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz#4e90af67bc51ddee6cdef5284edf572ec376b595" integrity sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ== @@ -846,6 +846,397 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.1.tgz#de633db3ec2ef6a3c89e2f19038063e8a122e2c2" integrity sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q== +"@firebase/ai@2.9.0": + version "2.9.0" + resolved "https://registry.yarnpkg.com/@firebase/ai/-/ai-2.9.0.tgz#9e6f3546eb688e31488f3e081702773300d609f1" + integrity sha512-NPvBBuvdGo9x3esnABAucFYmqbBmXvyTMimBq2PCuLZbdANZoHzGlx7vfzbwNDaEtCBq4RGGNMliLIv6bZ+PtA== + dependencies: + "@firebase/app-check-interop-types" "0.3.3" + "@firebase/component" "0.7.1" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.14.0" + tslib "^2.1.0" + +"@firebase/analytics-compat@0.2.26": + version "0.2.26" + resolved "https://registry.yarnpkg.com/@firebase/analytics-compat/-/analytics-compat-0.2.26.tgz#2ec74dc4d41d075d38fab7670c33464803214f2f" + integrity sha512-0j2ruLOoVSwwcXAF53AMoniJKnkwiTjGVfic5LDzqiRkR13vb5j6TXMeix787zbLeQtN/m1883Yv1TxI0gItbA== + dependencies: + "@firebase/analytics" "0.10.20" + "@firebase/analytics-types" "0.8.3" + "@firebase/component" "0.7.1" + "@firebase/util" "1.14.0" + tslib "^2.1.0" + +"@firebase/analytics-types@0.8.3": + version "0.8.3" + resolved "https://registry.yarnpkg.com/@firebase/analytics-types/-/analytics-types-0.8.3.tgz#d08cd39a6209693ca2039ba7a81570dfa6c1518f" + integrity sha512-VrIp/d8iq2g501qO46uGz3hjbDb8xzYMrbu8Tp0ovzIzrvJZ2fvmj649gTjge/b7cCCcjT0H37g1gVtlNhnkbg== + +"@firebase/analytics@0.10.20": + version "0.10.20" + resolved "https://registry.yarnpkg.com/@firebase/analytics/-/analytics-0.10.20.tgz#ec3aaacaa157b979b6e2c12ac5a30e6484b19ddf" + integrity sha512-adGTNVUWH5q66tI/OQuKLSN6mamPpfYhj0radlH2xt+3eL6NFPtXoOs+ulvs+UsmK27vNFx5FjRDfWk+TyduHg== + dependencies: + "@firebase/component" "0.7.1" + "@firebase/installations" "0.6.20" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.14.0" + tslib "^2.1.0" + +"@firebase/app-check-compat@0.4.1": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@firebase/app-check-compat/-/app-check-compat-0.4.1.tgz#2ff3f4b28fd4ee136e7ee12b99edac8cdc8cbbb1" + integrity sha512-yjSvSl5B1u4CirnxhzirN1uiTRCRfx+/qtfbyeyI+8Cx8Cw1RWAIO/OqytPSVwLYbJJ1vEC3EHfxazRaMoWKaA== + dependencies: + "@firebase/app-check" "0.11.1" + "@firebase/app-check-types" "0.5.3" + "@firebase/component" "0.7.1" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.14.0" + tslib "^2.1.0" + +"@firebase/app-check-interop-types@0.3.3": + version "0.3.3" + resolved "https://registry.yarnpkg.com/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz#ed9c4a4f48d1395ef378f007476db3940aa5351a" + integrity sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A== + +"@firebase/app-check-types@0.5.3": + version "0.5.3" + resolved "https://registry.yarnpkg.com/@firebase/app-check-types/-/app-check-types-0.5.3.tgz#38ba954acf4bffe451581a32fffa20337f11d8e5" + integrity sha512-hyl5rKSj0QmwPdsAxrI5x1otDlByQ7bvNvVt8G/XPO2CSwE++rmSVf3VEhaeOR4J8ZFaF0Z0NDSmLejPweZ3ng== + +"@firebase/app-check@0.11.1": + version "0.11.1" + resolved "https://registry.yarnpkg.com/@firebase/app-check/-/app-check-0.11.1.tgz#f327a2190b405eb566a93cd5c7eb8ebe7556032b" + integrity sha512-gmKfwQ2k8aUQlOyRshc+fOQLq0OwUmibIZvpuY1RDNu2ho0aTMlwxOuEiJeYOs7AxzhSx7gnXPFNsXCFbnvXUQ== + dependencies: + "@firebase/component" "0.7.1" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.14.0" + tslib "^2.1.0" + +"@firebase/app-compat@0.5.9": + version "0.5.9" + resolved "https://registry.yarnpkg.com/@firebase/app-compat/-/app-compat-0.5.9.tgz#464efce323951283c6812893d251dddee15d61da" + integrity sha512-e5LzqjO69/N2z7XcJeuMzIp4wWnW696dQeaHAUpQvGk89gIWHAIvG6W+mA3UotGW6jBoqdppEJ9DnuwbcBByug== + dependencies: + "@firebase/app" "0.14.9" + "@firebase/component" "0.7.1" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.14.0" + tslib "^2.1.0" + +"@firebase/app-types@0.9.3": + version "0.9.3" + resolved "https://registry.yarnpkg.com/@firebase/app-types/-/app-types-0.9.3.tgz#8408219eae9b1fb74f86c24e7150a148460414ad" + integrity sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw== + +"@firebase/app@0.14.9": + version "0.14.9" + resolved "https://registry.yarnpkg.com/@firebase/app/-/app-0.14.9.tgz#b7f740904deee2889a3d6115736b16fdbdc853c7" + integrity sha512-3gtUX0e584MYkKBQMgSECMvE1Dwzg+eONefDQ0wxVSe5YMBsZwdN5pL7UapwWBlV8+i8QCztF9TP947tEjZAGA== + dependencies: + "@firebase/component" "0.7.1" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.14.0" + idb "7.1.1" + tslib "^2.1.0" + +"@firebase/auth-compat@0.6.3": + version "0.6.3" + resolved "https://registry.yarnpkg.com/@firebase/auth-compat/-/auth-compat-0.6.3.tgz#8e085d98bd133081e7e7d37b7fb421b876694847" + integrity sha512-nHOkupcYuGVxI1AJJ/OBhLPaRokbP14Gq4nkkoVvf1yvuREEWqdnrYB/CdsSnPxHMAnn5wJIKngxBF9jNX7s/Q== + dependencies: + "@firebase/auth" "1.12.1" + "@firebase/auth-types" "0.13.0" + "@firebase/component" "0.7.1" + "@firebase/util" "1.14.0" + tslib "^2.1.0" + +"@firebase/auth-interop-types@0.2.4": + version "0.2.4" + resolved "https://registry.yarnpkg.com/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz#176a08686b0685596ff03d7879b7e4115af53de0" + integrity sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA== + +"@firebase/auth-types@0.13.0": + version "0.13.0" + resolved "https://registry.yarnpkg.com/@firebase/auth-types/-/auth-types-0.13.0.tgz#ae6e0015e3bd4bfe18edd0942b48a0a118a098d9" + integrity sha512-S/PuIjni0AQRLF+l9ck0YpsMOdE8GO2KU6ubmBB7P+7TJUCQDa3R1dlgYm9UzGbbePMZsp0xzB93f2b/CgxMOg== + +"@firebase/auth@1.12.1": + version "1.12.1" + resolved "https://registry.yarnpkg.com/@firebase/auth/-/auth-1.12.1.tgz#5eb1c3bf99dfbe7025578a5f1439cc073a4183f0" + integrity sha512-nXKj7d5bMBlnq6XpcQQpmnSVwEeHBkoVbY/+Wk0P1ebLSICoH4XPtvKOFlXKfIHmcS84mLQ99fk3njlDGKSDtw== + dependencies: + "@firebase/component" "0.7.1" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.14.0" + tslib "^2.1.0" + +"@firebase/component@0.7.1": + version "0.7.1" + resolved "https://registry.yarnpkg.com/@firebase/component/-/component-0.7.1.tgz#f16376146d77034ac5055834de25405e6c011491" + integrity sha512-mFzsm7CLHR60o08S23iLUY8m/i6kLpOK87wdEFPLhdlCahaxKmWOwSVGiWoENYSmFJJoDhrR3gKSCxz7ENdIww== + dependencies: + "@firebase/util" "1.14.0" + tslib "^2.1.0" + +"@firebase/data-connect@0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@firebase/data-connect/-/data-connect-0.4.0.tgz#957d2e0ee602d7120b4c5dbcb8494f911b8a2e47" + integrity sha512-vLXM6WHNIR3VtEeYNUb/5GTsUOyl3Of4iWNZHBe1i9f88sYFnxybJNWVBjvJ7flhCyF8UdxGpzWcUnv6F5vGfg== + dependencies: + "@firebase/auth-interop-types" "0.2.4" + "@firebase/component" "0.7.1" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.14.0" + tslib "^2.1.0" + +"@firebase/database-compat@2.1.1": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@firebase/database-compat/-/database-compat-2.1.1.tgz#8ab656d2f6b53d1645b86fa846295db4734b9ac5" + integrity sha512-heAEVZ9Z8c8PnBUcmGh91JHX0cXcVa1yESW/xkLuwaX7idRFyLiN8sl73KXpR8ZArGoPXVQDanBnk6SQiekRCQ== + dependencies: + "@firebase/component" "0.7.1" + "@firebase/database" "1.1.1" + "@firebase/database-types" "1.0.17" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.14.0" + tslib "^2.1.0" + +"@firebase/database-types@1.0.17": + version "1.0.17" + resolved "https://registry.yarnpkg.com/@firebase/database-types/-/database-types-1.0.17.tgz#6b7a14d81655e9ee5e87c26dc853c24d9737e4fe" + integrity sha512-4eWaM5fW3qEIHjGzfi3cf0Jpqi1xQsAdT6rSDE1RZPrWu8oGjgrq6ybMjobtyHQFgwGCykBm4YM89qDzc+uG/w== + dependencies: + "@firebase/app-types" "0.9.3" + "@firebase/util" "1.14.0" + +"@firebase/database@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@firebase/database/-/database-1.1.1.tgz#591610b5087ffc25cc56486ad03749b09c887759" + integrity sha512-LwIXe8+mVHY5LBPulWECOOIEXDiatyECp/BOlu0gOhe+WOcKjWHROaCbLlkFTgHMY7RHr5MOxkLP/tltWAH3dA== + dependencies: + "@firebase/app-check-interop-types" "0.3.3" + "@firebase/auth-interop-types" "0.2.4" + "@firebase/component" "0.7.1" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.14.0" + faye-websocket "0.11.4" + tslib "^2.1.0" + +"@firebase/firestore-compat@0.4.6": + version "0.4.6" + resolved "https://registry.yarnpkg.com/@firebase/firestore-compat/-/firestore-compat-0.4.6.tgz#30a20be30a72e80b0cfa32d5d693564daff6911a" + integrity sha512-NgVyR4hHHN2FvSNQOtbgBOuVsEdD/in30d9FKbEvvITiAChrBN2nBstmhfjI4EOTnHaP8zigwvkNYFI9yKGAkQ== + dependencies: + "@firebase/component" "0.7.1" + "@firebase/firestore" "4.12.0" + "@firebase/firestore-types" "3.0.3" + "@firebase/util" "1.14.0" + tslib "^2.1.0" + +"@firebase/firestore-types@3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@firebase/firestore-types/-/firestore-types-3.0.3.tgz#7d0c3dd8850c0193d8f5ee0cc8f11961407742c1" + integrity sha512-hD2jGdiWRxB/eZWF89xcK9gF8wvENDJkzpVFb4aGkzfEaKxVRD1kjz1t1Wj8VZEp2LCB53Yx1zD8mrhQu87R6Q== + +"@firebase/firestore@4.12.0": + version "4.12.0" + resolved "https://registry.yarnpkg.com/@firebase/firestore/-/firestore-4.12.0.tgz#3321155f66d70c749924c635bb1f0deb92254df3" + integrity sha512-PM47OyiiAAoAMB8kkq4Je14mTciaRoAPDd3ng3Ckqz9i2TX9D9LfxIRcNzP/OxzNV4uBKRq6lXoOggkJBQR3Gw== + dependencies: + "@firebase/component" "0.7.1" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.14.0" + "@firebase/webchannel-wrapper" "1.0.5" + "@grpc/grpc-js" "~1.9.0" + "@grpc/proto-loader" "^0.7.8" + tslib "^2.1.0" + +"@firebase/functions-compat@0.4.2": + version "0.4.2" + resolved "https://registry.yarnpkg.com/@firebase/functions-compat/-/functions-compat-0.4.2.tgz#5788b9d33a700164eefd0b4e455de87cd62d635c" + integrity sha512-YNxgnezvZDkqxqXa6cT7/oTeD4WXbxgIP7qZp4LFnathQv5o2omM6EoIhXiT9Ie5AoQDcIhG9Y3/dj+DFJGaGQ== + dependencies: + "@firebase/component" "0.7.1" + "@firebase/functions" "0.13.2" + "@firebase/functions-types" "0.6.3" + "@firebase/util" "1.14.0" + tslib "^2.1.0" + +"@firebase/functions-types@0.6.3": + version "0.6.3" + resolved "https://registry.yarnpkg.com/@firebase/functions-types/-/functions-types-0.6.3.tgz#f5faf770248b13f45d256f614230da6a11bfb654" + integrity sha512-EZoDKQLUHFKNx6VLipQwrSMh01A1SaL3Wg6Hpi//x6/fJ6Ee4hrAeswK99I5Ht8roiniKHw4iO0B1Oxj5I4plg== + +"@firebase/functions@0.13.2": + version "0.13.2" + resolved "https://registry.yarnpkg.com/@firebase/functions/-/functions-0.13.2.tgz#2e7936898afcdfa391e564e39049e0e908282420" + integrity sha512-tHduUD+DeokM3NB1QbHCvEMoL16e8Z8JSkmuVA4ROoJKPxHn8ibnecHPO2e3nVCJR1D9OjuKvxz4gksfq92/ZQ== + dependencies: + "@firebase/app-check-interop-types" "0.3.3" + "@firebase/auth-interop-types" "0.2.4" + "@firebase/component" "0.7.1" + "@firebase/messaging-interop-types" "0.2.3" + "@firebase/util" "1.14.0" + tslib "^2.1.0" + +"@firebase/installations-compat@0.2.20": + version "0.2.20" + resolved "https://registry.yarnpkg.com/@firebase/installations-compat/-/installations-compat-0.2.20.tgz#f17bcd7623f1283937ac3192c3293dd68037fcdc" + integrity sha512-9C9pL/DIEGucmoPj8PlZTnztbX3nhNj5RTYVpUM7wQq/UlHywaYv99969JU/WHLvi9ptzIogXYS9d1eZ6XFe9g== + dependencies: + "@firebase/component" "0.7.1" + "@firebase/installations" "0.6.20" + "@firebase/installations-types" "0.5.3" + "@firebase/util" "1.14.0" + tslib "^2.1.0" + +"@firebase/installations-types@0.5.3": + version "0.5.3" + resolved "https://registry.yarnpkg.com/@firebase/installations-types/-/installations-types-0.5.3.tgz#cac8a14dd49f09174da9df8ae453f9b359c3ef2f" + integrity sha512-2FJI7gkLqIE0iYsNQ1P751lO3hER+Umykel+TkLwHj6plzWVxqvfclPUZhcKFVQObqloEBTmpi2Ozn7EkCABAA== + +"@firebase/installations@0.6.20": + version "0.6.20" + resolved "https://registry.yarnpkg.com/@firebase/installations/-/installations-0.6.20.tgz#a019da0e71d5a0bb59b58e43a8edef0153368b94" + integrity sha512-LOzvR7XHPbhS0YB5ANXhqXB5qZlntPpwU/4KFwhSNpXNsGk/sBQ9g5hepi0y0/MfenJLe2v7t644iGOOElQaHQ== + dependencies: + "@firebase/component" "0.7.1" + "@firebase/util" "1.14.0" + idb "7.1.1" + tslib "^2.1.0" + +"@firebase/logger@0.5.0": + version "0.5.0" + resolved "https://registry.yarnpkg.com/@firebase/logger/-/logger-0.5.0.tgz#a9e55b1c669a0983dc67127fa4a5964ce8ed5e1b" + integrity sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g== + dependencies: + tslib "^2.1.0" + +"@firebase/messaging-compat@0.2.24": + version "0.2.24" + resolved "https://registry.yarnpkg.com/@firebase/messaging-compat/-/messaging-compat-0.2.24.tgz#9ea9bf0d88d605c382dd416e231203310da7b867" + integrity sha512-wXH8FrKbJvFuFe6v98TBhAtvgknxKIZtGM/wCVsfpOGmaAE80bD8tBxztl+uochjnFb9plihkd6mC4y7sZXSpA== + dependencies: + "@firebase/component" "0.7.1" + "@firebase/messaging" "0.12.24" + "@firebase/util" "1.14.0" + tslib "^2.1.0" + +"@firebase/messaging-interop-types@0.2.3": + version "0.2.3" + resolved "https://registry.yarnpkg.com/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.3.tgz#e647c9cd1beecfe6a6e82018a6eec37555e4da3e" + integrity sha512-xfzFaJpzcmtDjycpDeCUj0Ge10ATFi/VHVIvEEjDNc3hodVBQADZ7BWQU7CuFpjSHE+eLuBI13z5F/9xOoGX8Q== + +"@firebase/messaging@0.12.24": + version "0.12.24" + resolved "https://registry.yarnpkg.com/@firebase/messaging/-/messaging-0.12.24.tgz#ac586f68a038d8595ee8cbaea2a4b60e1886029a" + integrity sha512-UtKoubegAhHyehcB7iQjvQ8OVITThPbbWk3g2/2ze42PrQr6oe6OmCElYQkBrE5RDCeMTNucXejbdulrQ2XwVg== + dependencies: + "@firebase/component" "0.7.1" + "@firebase/installations" "0.6.20" + "@firebase/messaging-interop-types" "0.2.3" + "@firebase/util" "1.14.0" + idb "7.1.1" + tslib "^2.1.0" + +"@firebase/performance-compat@0.2.23": + version "0.2.23" + resolved "https://registry.yarnpkg.com/@firebase/performance-compat/-/performance-compat-0.2.23.tgz#e4e440878c5be1e11e01d5fe28e5e1fe73d36857" + integrity sha512-c7qOAGBUAOpIuUlHu1axWcrCVtIYKPMhH0lMnoCDWnPwn1HcPuPUBVTWETbC7UWw71RMJF8DpirfWXzMWJQfgA== + dependencies: + "@firebase/component" "0.7.1" + "@firebase/logger" "0.5.0" + "@firebase/performance" "0.7.10" + "@firebase/performance-types" "0.2.3" + "@firebase/util" "1.14.0" + tslib "^2.1.0" + +"@firebase/performance-types@0.2.3": + version "0.2.3" + resolved "https://registry.yarnpkg.com/@firebase/performance-types/-/performance-types-0.2.3.tgz#5ce64e90fa20ab5561f8b62a305010cf9fab86fb" + integrity sha512-IgkyTz6QZVPAq8GSkLYJvwSLr3LS9+V6vNPQr0x4YozZJiLF5jYixj0amDtATf1X0EtYHqoPO48a9ija8GocxQ== + +"@firebase/performance@0.7.10": + version "0.7.10" + resolved "https://registry.yarnpkg.com/@firebase/performance/-/performance-0.7.10.tgz#a282de63f064477a62cf0379c3374f3cc693ffa4" + integrity sha512-8nRFld+Ntzp5cLKzZuG9g+kBaSn8Ks9dmn87UQGNFDygbmR6ebd8WawauEXiJjMj1n70ypkvAOdE+lzeyfXtGA== + dependencies: + "@firebase/component" "0.7.1" + "@firebase/installations" "0.6.20" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.14.0" + tslib "^2.1.0" + web-vitals "^4.2.4" + +"@firebase/remote-config-compat@0.2.22": + version "0.2.22" + resolved "https://registry.yarnpkg.com/@firebase/remote-config-compat/-/remote-config-compat-0.2.22.tgz#5d34d4e856c8a9010e77be5fc2dc183657ade58c" + integrity sha512-uW/eNKKtRBot2gnCC5mnoy5Voo2wMzZuQ7dwqqGHU176fO9zFgMwKiRzk+aaC99NLrFk1KOmr0ZVheD+zdJmjQ== + dependencies: + "@firebase/component" "0.7.1" + "@firebase/logger" "0.5.0" + "@firebase/remote-config" "0.8.1" + "@firebase/remote-config-types" "0.5.0" + "@firebase/util" "1.14.0" + tslib "^2.1.0" + +"@firebase/remote-config-types@0.5.0": + version "0.5.0" + resolved "https://registry.yarnpkg.com/@firebase/remote-config-types/-/remote-config-types-0.5.0.tgz#f0f503b32edda3384f5252f9900cd9613adbb99c" + integrity sha512-vI3bqLoF14L/GchtgayMiFpZJF+Ao3uR8WCde0XpYNkSokDpAKca2DxvcfeZv7lZUqkUwQPL2wD83d3vQ4vvrg== + +"@firebase/remote-config@0.8.1": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@firebase/remote-config/-/remote-config-0.8.1.tgz#47309f3e623d358652878935ac90c880b97ef118" + integrity sha512-L86TReBnPiiJOWd7k9iaiE9f7rHtMpjAoYN0fH2ey2ZRzsOChHV0s5sYf1+IIUYzplzsE46pjlmAUNkRRKwHSQ== + dependencies: + "@firebase/component" "0.7.1" + "@firebase/installations" "0.6.20" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.14.0" + tslib "^2.1.0" + +"@firebase/storage-compat@0.4.1": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@firebase/storage-compat/-/storage-compat-0.4.1.tgz#94c105a416f949fd1552ced075d2df613e761faa" + integrity sha512-bgl3FHHfXAmBgzIK/Fps6Xyv2HiAQlSTov07CBL+RGGhrC5YIk4lruS8JVIC+UkujRdYvnf8cpQFGn2RCilJ/A== + dependencies: + "@firebase/component" "0.7.1" + "@firebase/storage" "0.14.1" + "@firebase/storage-types" "0.8.3" + "@firebase/util" "1.14.0" + tslib "^2.1.0" + +"@firebase/storage-types@0.8.3": + version "0.8.3" + resolved "https://registry.yarnpkg.com/@firebase/storage-types/-/storage-types-0.8.3.tgz#2531ef593a3452fc12c59117195d6485c6632d3d" + integrity sha512-+Muk7g9uwngTpd8xn9OdF/D48uiQ7I1Fae7ULsWPuKoCH3HU7bfFPhxtJYzyhjdniowhuDpQcfPmuNRAqZEfvg== + +"@firebase/storage@0.14.1": + version "0.14.1" + resolved "https://registry.yarnpkg.com/@firebase/storage/-/storage-0.14.1.tgz#2cdc6523bac9fd85bdd369c77e02a785866d4c02" + integrity sha512-uIpYgBBsv1vIET+5xV20XT7wwqV+H4GFp6PBzfmLUcEgguS4SWNFof56Z3uOC2lNDh0KDda1UflYq2VwD9Nefw== + dependencies: + "@firebase/component" "0.7.1" + "@firebase/util" "1.14.0" + tslib "^2.1.0" + +"@firebase/util@1.14.0": + version "1.14.0" + resolved "https://registry.yarnpkg.com/@firebase/util/-/util-1.14.0.tgz#e0a5998fc30a065fe5cba8bd7546ae8f095f3d3e" + integrity sha512-/gnejm7MKkVIXnSJGpc9L2CvvvzJvtDPeAEq5jAwgVlf/PeNxot+THx/bpD20wQ8uL5sz0xqgXy1nisOYMU+mw== + dependencies: + tslib "^2.1.0" + +"@firebase/webchannel-wrapper@1.0.5": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.5.tgz#39cf5a600450cb42f1f0b507cc385459bf103b27" + integrity sha512-+uGNN7rkfn41HLO0vekTFhTxk61eKa8mTpRGLO0QSqlQdKvIoGAvLp3ppdVIWbTGYJWM6Kp0iN+PjMIOcnVqTw== + "@floating-ui/core@^1.7.4": version "1.7.4" resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.7.4.tgz#4a006a6e01565c0f87ba222c317b056a2cffd2f4" @@ -878,6 +1269,24 @@ resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6" integrity sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw== +"@grpc/grpc-js@~1.9.0": + version "1.9.15" + resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.9.15.tgz#433d7ac19b1754af690ea650ab72190bd700739b" + integrity sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ== + dependencies: + "@grpc/proto-loader" "^0.7.8" + "@types/node" ">=12.12.47" + +"@grpc/proto-loader@^0.7.8": + version "0.7.15" + resolved "https://registry.yarnpkg.com/@grpc/proto-loader/-/proto-loader-0.7.15.tgz#4cdfbf35a35461fc843abe8b9e2c0770b5095e60" + integrity sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ== + dependencies: + lodash.camelcase "^4.3.0" + long "^5.0.0" + protobufjs "^7.2.5" + yargs "^17.7.2" + "@hono/node-server@^1.19.9": version "1.19.9" resolved "https://registry.yarnpkg.com/@hono/node-server/-/node-server-1.19.9.tgz#8f37119b1acf283fd3f6035f3d1356fdb97a09ac" @@ -1258,6 +1667,59 @@ resolved "https://registry.yarnpkg.com/@open-draft/until/-/until-2.1.0.tgz#0acf32f470af2ceaf47f095cdecd40d68666efda" integrity sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg== +"@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf" + integrity sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ== + +"@protobufjs/base64@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/base64/-/base64-1.1.2.tgz#4c85730e59b9a1f1f349047dbf24296034bb2735" + integrity sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg== + +"@protobufjs/codegen@^2.0.4": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@protobufjs/codegen/-/codegen-2.0.4.tgz#7ef37f0d010fb028ad1ad59722e506d9262815cb" + integrity sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg== + +"@protobufjs/eventemitter@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz#355cbc98bafad5978f9ed095f397621f1d066b70" + integrity sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q== + +"@protobufjs/fetch@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/fetch/-/fetch-1.1.0.tgz#ba99fb598614af65700c1619ff06d454b0d84c45" + integrity sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ== + dependencies: + "@protobufjs/aspromise" "^1.1.1" + "@protobufjs/inquire" "^1.1.0" + +"@protobufjs/float@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@protobufjs/float/-/float-1.0.2.tgz#5e9e1abdcb73fc0a7cb8b291df78c8cbd97b87d1" + integrity sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ== + +"@protobufjs/inquire@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/inquire/-/inquire-1.1.0.tgz#ff200e3e7cf2429e2dcafc1140828e8cc638f089" + integrity sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q== + +"@protobufjs/path@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/path/-/path-1.1.2.tgz#6cc2b20c5c9ad6ad0dccfd21ca7673d8d7fbf68d" + integrity sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA== + +"@protobufjs/pool@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/pool/-/pool-1.1.0.tgz#09fd15f2d6d3abfa9b65bc366506d6ad7846ff54" + integrity sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw== + +"@protobufjs/utf8@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" + integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== + "@radix-ui/number@1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@radix-ui/number/-/number-1.1.1.tgz#7b2c9225fbf1b126539551f5985769d0048d9090" @@ -2187,6 +2649,25 @@ "@tailwindcss/oxide" "4.2.0" tailwindcss "4.2.0" +"@tanstack/eslint-plugin-query@^5.91.4": + version "5.91.4" + resolved "https://registry.yarnpkg.com/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.91.4.tgz#b12f35280379aef0787074932ad698fd9bc621cc" + integrity sha512-8a+GAeR7oxJ5laNyYBQ6miPK09Hi18o5Oie/jx8zioXODv/AUFLZQecKabPdpQSLmuDXEBPKFh+W5DKbWlahjQ== + dependencies: + "@typescript-eslint/utils" "^8.48.0" + +"@tanstack/query-core@5.90.20": + version "5.90.20" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.90.20.tgz#e12128e39210715d4ce4fb299c33498ac297771e" + integrity sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg== + +"@tanstack/react-query@^5.90.21": + version "5.90.21" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.90.21.tgz#e0eb40831a76510be438109435b8807ef63ab1b9" + integrity sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg== + dependencies: + "@tanstack/query-core" "5.90.20" + "@tootallnate/once@2": version "2.0.0" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" @@ -2320,6 +2801,13 @@ dependencies: undici-types "~7.18.0" +"@types/node@>=12.12.47", "@types/node@>=13.7.0": + version "25.5.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-25.5.0.tgz#5c99f37c443d9ccc4985866913f1ed364217da31" + integrity sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw== + dependencies: + undici-types "~7.18.0" + "@types/node@^22.5.5": version "22.19.11" resolved "https://registry.yarnpkg.com/@types/node/-/node-22.19.11.tgz#7e1feaad24e4e36c52fa5558d5864bb4b272603e" @@ -2406,6 +2894,15 @@ "@typescript-eslint/typescript-estree" "5.62.0" debug "^4.3.4" +"@typescript-eslint/project-service@8.57.1": + version "8.57.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.57.1.tgz#16af9fe16eedbd7085e4fdc29baa73715c0c55c5" + integrity sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg== + dependencies: + "@typescript-eslint/tsconfig-utils" "^8.57.1" + "@typescript-eslint/types" "^8.57.1" + debug "^4.4.3" + "@typescript-eslint/scope-manager@5.62.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz#d9457ccc6a0b8d6b37d0eb252a23022478c5460c" @@ -2414,6 +2911,19 @@ "@typescript-eslint/types" "5.62.0" "@typescript-eslint/visitor-keys" "5.62.0" +"@typescript-eslint/scope-manager@8.57.1": + version "8.57.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.57.1.tgz#4524d7e7b420cb501807499684d435ae129aaf35" + integrity sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg== + dependencies: + "@typescript-eslint/types" "8.57.1" + "@typescript-eslint/visitor-keys" "8.57.1" + +"@typescript-eslint/tsconfig-utils@8.57.1", "@typescript-eslint/tsconfig-utils@^8.57.1": + version "8.57.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.1.tgz#9233443ec716882a6f9e240fd900a73f0235f3d7" + integrity sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg== + "@typescript-eslint/type-utils@5.62.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz#286f0389c41681376cdad96b309cedd17d70346a" @@ -2429,6 +2939,11 @@ resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.62.0.tgz#258607e60effa309f067608931c3df6fed41fd2f" integrity sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ== +"@typescript-eslint/types@8.57.1", "@typescript-eslint/types@^8.57.1": + version "8.57.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.57.1.tgz#54b27a8a25a7b45b4f978c3f8e00c4c78f11142c" + integrity sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ== + "@typescript-eslint/typescript-estree@5.62.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz#7d17794b77fabcac615d6a48fb143330d962eb9b" @@ -2442,6 +2957,21 @@ semver "^7.3.7" tsutils "^3.21.0" +"@typescript-eslint/typescript-estree@8.57.1": + version "8.57.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.1.tgz#a9fd28d4a0ec896aa9a9a7e0cead62ea24f99e76" + integrity sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g== + dependencies: + "@typescript-eslint/project-service" "8.57.1" + "@typescript-eslint/tsconfig-utils" "8.57.1" + "@typescript-eslint/types" "8.57.1" + "@typescript-eslint/visitor-keys" "8.57.1" + debug "^4.4.3" + minimatch "^10.2.2" + semver "^7.7.3" + tinyglobby "^0.2.15" + ts-api-utils "^2.4.0" + "@typescript-eslint/utils@5.62.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.62.0.tgz#141e809c71636e4a75daa39faed2fb5f4b10df86" @@ -2456,6 +2986,16 @@ eslint-scope "^5.1.1" semver "^7.3.7" +"@typescript-eslint/utils@^8.48.0": + version "8.57.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.57.1.tgz#e40f5a7fcff02fd24092a7b52bd6ec029fb50465" + integrity sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ== + dependencies: + "@eslint-community/eslint-utils" "^4.9.1" + "@typescript-eslint/scope-manager" "8.57.1" + "@typescript-eslint/types" "8.57.1" + "@typescript-eslint/typescript-estree" "8.57.1" + "@typescript-eslint/visitor-keys@5.62.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz#2174011917ce582875954ffe2f6912d5931e353e" @@ -2464,6 +3004,14 @@ "@typescript-eslint/types" "5.62.0" eslint-visitor-keys "^3.3.0" +"@typescript-eslint/visitor-keys@8.57.1": + version "8.57.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.1.tgz#3af4f88118924d3be983d4b8ae84803f11fe4563" + integrity sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A== + dependencies: + "@typescript-eslint/types" "8.57.1" + eslint-visitor-keys "^5.0.0" + "@ungap/structured-clone@^1.2.0": version "1.3.0" resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8" @@ -3906,6 +4454,11 @@ eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4 resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== +eslint-visitor-keys@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be" + integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA== + eslint@^8.57.1: version "8.57.1" resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.57.1.tgz#7df109654aba7e3bbe5c8eae533c5e461d3c6ca9" @@ -4170,6 +4723,13 @@ fastq@^1.6.0: dependencies: reusify "^1.0.4" +faye-websocket@0.11.4: + version "0.11.4" + resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.11.4.tgz#7f0d9275cfdd86a1c963dc8b65fcc451edcbb1da" + integrity sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g== + dependencies: + websocket-driver ">=0.5.1" + fd-slicer@~1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e" @@ -4177,7 +4737,7 @@ fd-slicer@~1.1.0: dependencies: pend "~1.2.0" -fdir@^6.2.0: +fdir@^6.2.0, fdir@^6.5.0: version "6.5.0" resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== @@ -4252,6 +4812,40 @@ find-up@^5.0.0: locate-path "^6.0.0" path-exists "^4.0.0" +firebase@^12.10.0: + version "12.10.0" + resolved "https://registry.yarnpkg.com/firebase/-/firebase-12.10.0.tgz#2c000e889e8b423ce37399b6a0497cadfba890fe" + integrity sha512-tAjHnEirksqWpa+NKDUSUMjulOnsTcsPC1X1rQ+gwPtjlhJS572na91CwaBXQJHXharIrfj7sw/okDkXOsphjA== + dependencies: + "@firebase/ai" "2.9.0" + "@firebase/analytics" "0.10.20" + "@firebase/analytics-compat" "0.2.26" + "@firebase/app" "0.14.9" + "@firebase/app-check" "0.11.1" + "@firebase/app-check-compat" "0.4.1" + "@firebase/app-compat" "0.5.9" + "@firebase/app-types" "0.9.3" + "@firebase/auth" "1.12.1" + "@firebase/auth-compat" "0.6.3" + "@firebase/data-connect" "0.4.0" + "@firebase/database" "1.1.1" + "@firebase/database-compat" "2.1.1" + "@firebase/firestore" "4.12.0" + "@firebase/firestore-compat" "0.4.6" + "@firebase/functions" "0.13.2" + "@firebase/functions-compat" "0.4.2" + "@firebase/installations" "0.6.20" + "@firebase/installations-compat" "0.2.20" + "@firebase/messaging" "0.12.24" + "@firebase/messaging-compat" "0.2.24" + "@firebase/performance" "0.7.10" + "@firebase/performance-compat" "0.2.23" + "@firebase/remote-config" "0.8.1" + "@firebase/remote-config-compat" "0.2.22" + "@firebase/storage" "0.14.1" + "@firebase/storage-compat" "0.4.1" + "@firebase/util" "1.14.0" + flat-cache@^3.0.4: version "3.2.0" resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.2.0.tgz#2c0c2d5040c99b1632771a9d105725c0115363ee" @@ -4714,6 +5308,11 @@ http-errors@^2.0.0, http-errors@^2.0.1, http-errors@~2.0.1: statuses "~2.0.2" toidentifier "~1.0.1" +http-parser-js@>=0.5.1: + version "0.5.10" + resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.10.tgz#b3277bd6d7ed5588e20ea73bf724fcbe44609075" + integrity sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA== + http-proxy-agent@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz#5129800203520d434f142bc78ff3c170800f2b43" @@ -4785,6 +5384,11 @@ iconv-lite@^0.7.0, iconv-lite@~0.7.0: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" +idb@7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/idb/-/idb-7.1.1.tgz#d910ded866d32c7ced9befc5bfdf36f572ced72b" + integrity sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ== + ieee754@^1.1.13: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" @@ -5432,6 +6036,11 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" +lodash.camelcase@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" + integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA== + lodash.get@^4.0.0: version "4.4.2" resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" @@ -5474,6 +6083,11 @@ log-update@^5.0.1: strip-ansi "^7.0.1" wrap-ansi "^8.0.1" +long@^5.0.0: + version "5.3.2" + resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83" + integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== + lowercase-keys@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479" @@ -5632,6 +6246,13 @@ minimatch@^10.0.1: dependencies: brace-expansion "^5.0.2" +minimatch@^10.2.2: + version "10.2.4" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.4.tgz#465b3accbd0218b8281f5301e27cedc697f96fde" + integrity sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg== + dependencies: + brace-expansion "^5.0.2" + minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" @@ -6242,7 +6863,7 @@ picomatch@^2.3.1: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== -picomatch@^4.0.2: +picomatch@^4.0.2, picomatch@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== @@ -6348,6 +6969,24 @@ prompts@^2.4.2: kleur "^3.0.3" sisteransi "^1.0.5" +protobufjs@^7.2.5: + version "7.5.4" + resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.5.4.tgz#885d31fe9c4b37f25d1bb600da30b1c5b37d286a" + integrity sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg== + dependencies: + "@protobufjs/aspromise" "^1.1.2" + "@protobufjs/base64" "^1.1.2" + "@protobufjs/codegen" "^2.0.4" + "@protobufjs/eventemitter" "^1.1.0" + "@protobufjs/fetch" "^1.1.0" + "@protobufjs/float" "^1.0.2" + "@protobufjs/inquire" "^1.1.0" + "@protobufjs/path" "^1.1.2" + "@protobufjs/pool" "^1.1.0" + "@protobufjs/utf8" "^1.1.0" + "@types/node" ">=13.7.0" + long "^5.0.0" + proxy-addr@^2.0.7: version "2.0.7" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" @@ -6786,7 +7425,7 @@ safe-array-concat@^1.1.3: has-symbols "^1.1.0" isarray "^2.0.5" -safe-buffer@^5.1.0, safe-buffer@~5.2.0: +safe-buffer@>=5.1.0, safe-buffer@^5.1.0, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -6843,7 +7482,7 @@ semver@^6.2.0, semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.1.1, semver@^7.1.3, semver@^7.2.1, semver@^7.3.2, semver@^7.3.5, semver@^7.3.7: +semver@^7.1.1, semver@^7.1.3, semver@^7.2.1, semver@^7.3.2, semver@^7.3.5, semver@^7.3.7, semver@^7.7.3: version "7.7.4" resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== @@ -7392,6 +8031,14 @@ tinyexec@^1.0.1: resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.0.2.tgz#bdd2737fe2ba40bd6f918ae26642f264b99ca251" integrity sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg== +tinyglobby@^0.2.15: + version "0.2.15" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2" + integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.3" + tldts-core@^7.0.23: version "7.0.23" resolved "https://registry.yarnpkg.com/tldts-core/-/tldts-core-7.0.23.tgz#47bf18282a44641304a399d247703413b5d3e309" @@ -7454,6 +8101,11 @@ trim-repeated@^1.0.0: dependencies: escape-string-regexp "^1.0.2" +ts-api-utils@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.4.0.tgz#2690579f96d2790253bdcf1ca35d569ad78f9ad8" + integrity sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA== + ts-morph@^26.0.0: version "26.0.0" resolved "https://registry.yarnpkg.com/ts-morph/-/ts-morph-26.0.0.tgz#d435ccac9421d4615fde8be86fee782f18cd9f73" @@ -7591,10 +8243,10 @@ typed-array-length@^1.0.7: possible-typed-array-names "^1.0.0" reflect.getprototypeof "^1.0.6" -typescript@~4.5.4: - version "4.5.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.5.tgz#d8c953832d28924a9e3d37c73d729c846c5896f3" - integrity sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA== +typescript@^5.9.3: + version "5.9.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== typescript@~5.4.5: version "5.4.5" @@ -7762,6 +8414,11 @@ web-streams-polyfill@^3.0.3: resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b" integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw== +web-vitals@^4.2.4: + version "4.2.4" + resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-4.2.4.tgz#1d20bc8590a37769bd0902b289550936069184b7" + integrity sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw== + webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" @@ -7803,6 +8460,20 @@ webpack@^5.69.1: watchpack "^2.5.1" webpack-sources "^3.3.3" +websocket-driver@>=0.5.1: + version "0.7.4" + resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.4.tgz#89ad5295bbf64b480abcba31e4953aca706f5760" + integrity sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg== + dependencies: + http-parser-js ">=0.5.1" + safe-buffer ">=5.1.0" + websocket-extensions ">=0.1.1" + +websocket-extensions@>=0.1.1: + version "0.1.4" + resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42" + integrity sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg== + whatwg-url@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" @@ -8019,7 +8690,7 @@ zod@^3.24.1: resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== -"zod@^3.25 || ^4.0": +"zod@^3.25 || ^4.0", zod@^4.3.6: version "4.3.6" resolved "https://registry.yarnpkg.com/zod/-/zod-4.3.6.tgz#89c56e0aa7d2b05107d894412227087885ab112a" integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==