From d8ce8d16f8a31b1cdfabcb33faabdf4c20a5f7d3 Mon Sep 17 00:00:00 2001 From: MichaelAndish Date: Thu, 2 Jul 2026 23:10:49 +0200 Subject: [PATCH 01/20] WPD-33-feat: refactored message dispatch and added data retention modes --- cmd/server/init.go | 40 + cmd/server/main.go | 89 +- docs/backend/architecture.md | 70 +- docs/backend/e2e-testing.md | 4 +- docs/backend/portal-inbox.md | 32 +- docs/backend/usage.md | 24 +- frontend/package-lock.json | 1054 +++++++++-------- frontend/package.json | 1 + frontend/src/components/ui/checkbox.tsx | 28 + .../settings/hooks/use-settings.hook.ts | 12 +- .../settings/message-dispatch-mode.test.ts | 49 + .../settings/message-dispatch-mode.ts | 50 + .../features/settings/pages/settings.page.tsx | 114 +- .../src/features/settings/settings.api.ts | 10 +- .../src/features/settings/settings.schema.ts | 33 + .../src/features/settings/settings.types.ts | 18 +- go.mod | 2 +- go.sum | 4 +- internal/app/config.go | 82 +- internal/app/config_test.go | 43 + internal/app/shutdown.go | 29 + internal/core/domain/dispatch_mode.go | 89 +- internal/core/domain/dispatch_mode_test.go | 111 ++ internal/core/domain/integration.go | 34 +- internal/core/domain/integration_test.go | 46 + internal/core/domain/message_request_log.go | 25 +- internal/core/service/gateway_service.go | 264 +++-- internal/core/service/gateway_service_test.go | 240 +++- internal/core/service/portal_service.go | 34 + internal/core/service/portal_service_test.go | 96 ++ internal/infrastructure/logger/logger.go | 12 +- .../repository/postgres/limitoffset.go | 29 + .../repository/postgres/limitoffset_test.go | 33 + .../message_request_log_repository.go | 25 +- .../presentation/handler/portal_handler.go | 4 +- internal/presentation/handler/send_helper.go | 44 +- .../presentation/handler/send_helper_test.go | 57 +- internal/presentation/middleware/auth.go | 5 +- pkg/contracts/message.go | 8 + 39 files changed, 2053 insertions(+), 891 deletions(-) create mode 100644 cmd/server/init.go create mode 100644 frontend/src/components/ui/checkbox.tsx create mode 100644 frontend/src/features/settings/message-dispatch-mode.test.ts create mode 100644 frontend/src/features/settings/message-dispatch-mode.ts create mode 100644 frontend/src/features/settings/settings.schema.ts create mode 100644 internal/app/config_test.go create mode 100644 internal/app/shutdown.go create mode 100644 internal/core/domain/dispatch_mode_test.go create mode 100644 internal/core/domain/integration_test.go create mode 100644 internal/infrastructure/repository/postgres/limitoffset.go create mode 100644 internal/infrastructure/repository/postgres/limitoffset_test.go diff --git a/cmd/server/init.go b/cmd/server/init.go new file mode 100644 index 00000000..6ab1055f --- /dev/null +++ b/cmd/server/init.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + + pkglogger "github.com/weprodev/go-pkg/logger" + + "github.com/weprodev/wpd-message-gateway/internal/app" + applogger "github.com/weprodev/wpd-message-gateway/internal/infrastructure/logger" +) + +// defaultConfigPath can be overridden at build time via -ldflags. +var defaultConfigPath = "configs/local.yml" + +func getDefaultConfigPath() string { + return defaultConfigPath +} + +func loadAppConfig(configPath string) (*app.Config, error) { + return app.LoadAppConfig(configPath) +} + +func validateAppConfig(cfg *app.Config) error { + if err := app.ValidateConfig(cfg); err != nil { + return fmt.Errorf("configuration error: %w (hint: copy configs/local.example.yml to configs/local.yml)", err) + } + return nil +} + +func setLogger(cfg *app.Config) (*pkglogger.Logger, error) { + return applogger.New(cfg.EnvironmentName()) +} + +func wireApplication(cfg *app.Config) (*app.Application, error) { + sysLogger, err := setLogger(cfg) + if err != nil { + return nil, fmt.Errorf("init logger: %w", err) + } + return app.Wire(cfg, sysLogger) +} diff --git a/cmd/server/main.go b/cmd/server/main.go index 8d76101b..90d4b7be 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,74 +1,69 @@ package main import ( + "context" + "flag" "fmt" "log/slog" + "net/http" "os" - - "github.com/weprodev/wpd-message-gateway/internal/app" - "github.com/weprodev/wpd-message-gateway/internal/infrastructure/logger" + "os/signal" + "syscall" + "time" ) func main() { - // Initialise the structured logger first so every subsequent slog call is - // formatted and levelled correctly. Uses APP_ENVIRONMENT to pick text (local) - // vs JSON (production) format. - env := os.Getenv("APP_ENVIRONMENT") - sysLogger, err := logger.New(env) - if err != nil { - // Logger not yet up — fall back to bare stderr before exiting. - fmt.Fprintf(os.Stderr, "FATAL: init logger: %v\n", err) + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "fatal error: %v\n", err) os.Exit(1) } +} + +func run() error { + configPath := flag.String("config", getDefaultConfigPath(), "Path to config file") + flag.Parse() - configPath := os.Getenv("CONFIG_PATH") - cfg, err := app.LoadConfig(configPath) + cfg, err := loadAppConfig(*configPath) if err != nil { - slog.Error("failed to load config", "error", err) - os.Exit(1) + return fmt.Errorf("load config: %w", err) } - if err := app.ValidateConfig(cfg); err != nil { - slog.Error("configuration error", - "error", err, - "hint", "each message type requires a default provider (e.g. memory, mailgun); "+ - "copy configs/local.example.yml to configs/local.yml and configure providers", - ) - os.Exit(1) + if err := validateAppConfig(cfg); err != nil { + return err } - application, err := app.Wire(cfg, sysLogger) + application, err := wireApplication(cfg) if err != nil { - slog.Error("failed to initialise application", "error", err) - os.Exit(1) + return fmt.Errorf("wire application: %w", err) } - logConfiguration(cfg) + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() - port := resolvePort(cfg) - slog.Info("gateway server starting", "port", port, "environment", cfg.Environment) + cfg.LogSummary() + slog.Info("gateway server starting") - if err := application.Echo.Start(":" + port); err != nil { - slog.Error("server stopped", "error", err) - os.Exit(1) + errCh := make(chan error, 1) + go func() { + if err := application.Echo.Start(cfg.ListenAddr()); err != nil && err != http.ErrServerClosed { + errCh <- err + } + }() + + select { + case err := <-errCh: + return fmt.Errorf("server stopped unexpectedly: %w", err) + case <-ctx.Done(): + slog.Info("shutdown signal received") } -} -func logConfiguration(cfg *app.Config) { - slog.Info("loaded configuration", - "email_provider", cfg.DefaultEmailProvider(), - "sms_provider", cfg.DefaultSMSProvider(), - "push_provider", cfg.DefaultPushProvider(), - "chat_provider", cfg.DefaultChatProvider(), - ) -} + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() -func resolvePort(cfg *app.Config) string { - if port := os.Getenv("PORT"); port != "" { - return port - } - if cfg.Server.Port != 0 { - return fmt.Sprintf("%d", cfg.Server.Port) + if err := application.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("graceful shutdown: %w", err) } - return "10101" + + slog.Info("server stopped") + return nil } diff --git a/docs/backend/architecture.md b/docs/backend/architecture.md index c473262b..5e5e444d 100644 --- a/docs/backend/architecture.md +++ b/docs/backend/architecture.md @@ -138,7 +138,7 @@ gw.SendEmail(ctx, &contracts.Email{ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ GatewayService │ │ │ │ SendEmail() · SendSMS() · SendPush() · SendChat() │ │ -│ │ Reads dispatch_mode from workspace_settings │ │ +│ │ Reads message_dispatch_mode from workspace_settings │ │ │ │ Reads provider credentials from integrations table │ │ │ └──────────────────────────────────────────────────────────┘ │ │ ┌──────────────────────────────────────────────────────────┐ │ @@ -231,27 +231,35 @@ Provider credentials are stored AES-encrypted in the `integrations` table. Confi ### Portal UI coverage -The React Portal (`frontend/`) currently implements: auth, workspace list, **integrations**, message **logs**, and **send test**. API keys, templates, members, settings, and memory inbox browsing are REST/Bruno only. +The React Portal (`frontend/`) implements: auth, workspace list, **integrations**, **settings** (general, API keys, message dispatch), message **logs**, and **send test**. Templates, members, and inbox browsing remain REST/Bruno only. --- -## Message Dispatch Modes +## Message Dispatch & Content Storage -Each workspace independently controls how outbound messages are handled: +Each workspace controls outbound routing and inbox content capture with **two orthogonal settings**: -| Mode | Behavior | -| --------------------- | ----------------------------------------------------------------------------------- | -| `memory_only` | Captured in-process RAM only. No external provider called. Default for development. | -| `provider_only` | Sent through the connected integration only. No in-memory copy. | -| `memory_and_provider` | Stored in memory AND sent through the integration. | +| Setting | Values | Purpose | +| ------- | ------ | ------- | +| `message_dispatch_mode` | `memory` (default) \| `provider` | Where the message is routed | +| `store_message_content` | `false` (default) \| `true` | Whether message bodies are captured in the portal inbox (in-process) | -Configured via `PATCH /api/v1/workspaces/:wid/settings` (REST — no Portal UI page yet): +Portal **Settings → Message Dispatch** exposes two controls that map directly to these keys: + +| UI control | Setting key | Values | +| ---------- | ----------- | ------ | +| Dispatch mode (radio) | `message_dispatch_mode` | `memory` \| `provider` | +| Store message content (checkbox) | `store_message_content` | `false` (off) \| `true` (on) | + +Configured via `PATCH /api/v1/workspaces/:wid/settings` (Portal **Settings → Message Dispatch**): ``` PATCH /api/v1/workspaces/:wid/settings -{ "message_dispatch_mode": "provider_only" } +{ "message_dispatch_mode": "provider", "store_message_content": "true" } ``` +**Request logs**: Every API send appends a row to `message_request_logs` (operational tracing with correlation `request_id`). + --- ## Request Flow: Sending a Message @@ -275,19 +283,19 @@ GatewayHandler.HandleSendEmail() │ Reads workspace_id from context ▼ GatewayService.SendEmail(ctx, workspaceID, email) - │ Reads workspace_settings.message_dispatch_mode from DB + │ Reads message_dispatch_mode + store_message_content from DB │ - ├── memory_only → Memory Provider (in-process RAM) - │ │ - │ ▼ - │ Portal Inbox (SSE + REST) + ├── memory → Memory Provider (in-process RAM) + │ │ + │ ▼ + │ Portal Inbox (SSE + REST) │ - ├── provider_only → reads integrations table for workspace+channel - │ Decrypts AES config - │ Instantiates provider via registry - │ → sends to Mailgun/etc + ├── provider → reads integrations table for workspace+channel + │ Decrypts AES config + │ Instantiates provider via registry + │ → sends to Mailgun/etc │ - └── memory_and_provider → both paths above + └── store_message_content=true → also persists content via inbox writer ``` ### SDK Mode — `gateway.New(config).SendEmail(...)` @@ -322,7 +330,7 @@ Workspace "myapp" ├── Integrations (provider credentials per channel) │ ├── email: mailgun { api_key: "...", domain: "..." } │ └── sms: (not configured) -├── Settings { message_dispatch_mode: "provider_only" } +├── Settings { message_dispatch_mode: "provider", store_message_content: "false" } ├── Templates (email HTML templates) └── Message Logs (request audit trail) ``` @@ -438,19 +446,19 @@ Only create new files in pkg/provider// ### 5. Memory Provider & Dispatch Modes -The **memory provider** captures messages in process RAM. It's always available — no external service needed. Combined with `dispatch_mode`, you can: +The **memory provider** captures messages in process RAM. It's always available — no external service needed. Combined with workspace settings, you can: -- Use `memory_only` for local development (captured messages via inbox REST API / Bruno) -- Use `provider_only` in production (messages go to real provider) -- Use `memory_and_provider` to keep a local copy AND send to the real provider +- Use `message_dispatch_mode=memory` for local development (captured messages via inbox REST API / Bruno) +- Use `message_dispatch_mode=provider` in production (messages go to the real provider) +- Set `store_message_content=true` to persist message bodies regardless of routing mode ### 6. Portal (always on) The React Portal runs at `portal.ui_port` (default **10104**) when the server starts. -**Portal UI today:** sign in, list workspaces, manage **integrations**, view message logs, send test messages. +**Portal UI today:** sign in, list workspaces, manage **integrations**, **settings** (general, API keys, message dispatch), view message logs, send test messages. -**REST only (no UI pages yet):** workspace create, API keys, templates, members, settings, memory inbox browser. +**REST only (no UI pages yet):** workspace create, templates, members, memory inbox browser. --- @@ -459,9 +467,9 @@ The React Portal runs at `portal.ui_port` (default **10104**) when the server st To enable robust trace tracking across all layers, the gateway employs an end-to-end correlation ID pipeline: 1. **Correlation Generation**: Echo's `RequestID` middleware generates a unique request UUID for every incoming HTTP request, placing it in the `X-Request-ID` header. -2. **Context Propagation**: A custom correlation middleware extracts this request ID and injects it into the standard Go `context.Context` (accessible via `logger.GetRequestID(ctx)`). -3. **Structured slog Hooking**: An application-wide custom `ContextHandler` intercepts all standard `slog.InfoContext` / `slog.ErrorContext` calls. It transparently extracts the `request_id`, `workspace_id`, `api_key_id`, `channel`, and `provider` attributes from the context and automatically appends them to printed log records without requiring manual logging boilerplate. -4. **Audit Trail Tracking**: The presentation-layer `SendHelper` automatically populates the `request_id` column on the `message_request_logs` PostgreSQL table, linking individual requests directly to database audit trails. +2. **Context Propagation**: Router middleware copies the request ID into `context.Context` via `logger.WithRequestID`. API key auth also calls `logger.WithWorkspace` so `workspace_id` and `api_key_id` appear on all `/v1/*` logs. Send handlers add `channel` and `provider`. +3. **Structured slog**: go-pkg `ContextExtractors` append correlation fields to every `slog.*Context` record (`request_id`, `workspace_id`, `api_key_id`, `channel`, `provider`). +4. **Audit Trail**: `SendHelper` persists `request_id` on every row in `message_request_logs`, linking HTTP requests to the operational audit trail. --- diff --git a/docs/backend/e2e-testing.md b/docs/backend/e2e-testing.md index b7a8b7bf..c0851e95 100644 --- a/docs/backend/e2e-testing.md +++ b/docs/backend/e2e-testing.md @@ -17,7 +17,7 @@ Use the Message Gateway to capture and verify all messages your app sends during ## Architecture ``` -Your App Gateway (memory_only) Test +Your App Gateway (memory dispatch) Test │ │ │ │ POST /v1/email ─────────▶│ captured in RAM │ │ POST /v1/email ─────────▶│ captured in RAM │ @@ -104,7 +104,7 @@ KEY_JSON=$(curl -sS -X POST "$BASE/workspaces/$WORKSPACE_ID/api-keys" \ API_CLIENT_ID=$(echo "$KEY_JSON" | jq -r .client_id) API_CLIENT_SECRET=$(echo "$KEY_JSON" | jq -r .client_secret) -# 4) Dispatch mode defaults to memory_only — nothing to configure +# 4) Dispatch defaults to memory — nothing to configure echo "PORTAL_JWT=$PORTAL_JWT" >> $GITHUB_ENV echo "WORKSPACE_ID=$WORKSPACE_ID" >> $GITHUB_ENV echo "WORKSPACE_UK=$UK" >> $GITHUB_ENV diff --git a/docs/backend/portal-inbox.md b/docs/backend/portal-inbox.md index fb2beef0..d3a03759 100644 --- a/docs/backend/portal-inbox.md +++ b/docs/backend/portal-inbox.md @@ -15,18 +15,19 @@ The Portal is the web UI and REST surface for the WPD Message Gateway when runni | **Integrations** | Connect, activate, deactivate, or remove messaging providers | | **Message logs** | Outbound send **request audit trail** (not memory inbox capture) | | **Send test** | Send a test message per channel from the dashboard | +| **Settings** | General workspace settings, API keys, message dispatch mode | -No Portal pages yet for: workspace create, API keys, templates, members, settings, or memory inbox browsing. +REST-only (no dedicated UI page yet): workspace create, templates, members, memory inbox browser. ### REST API (server) | Feature | Portal UI | Description | |---------|:---------:|-------------| -| **Inbox capture** | | Messages stored in RAM (`memory_only` / `memory_and_provider`) — [Inbox API](#inbox-api-reference) | +| **Inbox capture** | ✓ | Messages captured when `message_dispatch_mode=memory` or `store_message_content=true` — [Inbox API](#inbox-api-reference) | | **Internal ingest** | | Automation writes to inbox (requires Portal auth) | | **Workspace provisioning** | | Create workspace, API keys, settings — curl/Bruno/CI only; see [E2E bootstrap](./e2e-testing.md) | -Provider credentials are stored in PostgreSQL (encrypted at rest) and managed via the Portal **Integrations** page. Dispatch mode is REST-only (`PATCH /api/v1/workspaces/:wid/settings`). +Provider credentials are stored in PostgreSQL (encrypted at rest) and managed via the Portal **Integrations** page. Dispatch settings are configured in **Settings → Message Dispatch** or via REST. --- @@ -44,12 +45,12 @@ Register (email + password), then sign in. ## Message Inbox (Memory Capture) -When a workspace's **dispatch mode** is `memory_only` or `memory_and_provider`, outbound messages are captured in-process RAM and displayed in the Portal inbox. +When a workspace uses `message_dispatch_mode=memory`, outbound messages are captured in-process RAM and displayed in the Portal inbox. ``` Your App │ - │ POST /v1/email (workspace: memory_only) + │ POST /v1/email (workspace: memory dispatch) ▼ Memory Provider └──────────────────▶ Portal Inbox (REST + SSE) @@ -67,26 +68,29 @@ Memory Provider --- -## Dispatch Modes +## Dispatch & Inbox Capture -Each workspace controls how its outbound messages are handled: +Each workspace controls routing and inbox capture with two settings: -| `message_dispatch_mode` | Behavior | -|------------------------|----------| -| `memory_only` | Captured in RAM only, **no** external provider called. **Default.** | -| `provider_only` | Sent through the connected integration, **no** memory copy. | -| `memory_and_provider` | Stored in memory **and** sent through the integration. | +| Setting | Values | Role | +|---------|--------|------| +| `message_dispatch_mode` | `memory` (default) \| `provider` | Routing target | +| `store_message_content` | `false` (default) \| `true` | Capture message body in portal inbox when enabled | -Set via REST (no Portal UI page yet): +Portal **Settings → Message Dispatch**: radio **Memory** / **Provider**, plus a checkbox for **Store message content in inbox** (see [usage](./usage.md)). + +Set via Portal **Settings → Message Dispatch** or REST: ```bash PATCH /api/v1/workspaces/:wid/settings Content-Type: application/json Authorization: Bearer -{ "message_dispatch_mode": "provider_only" } +{ "message_dispatch_mode": "provider", "store_message_content": "false" } ``` +Request logs (`message_request_logs`) are always written for operational tracing. + --- ## Authentication (Inbox & API) diff --git a/docs/backend/usage.md b/docs/backend/usage.md index c17c0ae9..35698c13 100644 --- a/docs/backend/usage.md +++ b/docs/backend/usage.md @@ -410,27 +410,29 @@ MESSAGE_CONFIG_ENCRYPTION_KEY=your-32-byte-key-here --- -## Message Dispatch Modes +## Message Dispatch & Content Storage -Control how messages are handled per workspace. Default is `memory_only`. +Control how messages are routed and whether bodies are captured in the portal inbox. -Set via REST (no Portal UI page yet): +Defaults: `message_dispatch_mode=memory`, `store_message_content=false`. + +Invalid values for dispatch settings return **400 Bad Request** on PATCH. + +Set via Portal **Settings → Message Dispatch** or REST: ```bash PATCH /api/v1/workspaces/:wid/settings Authorization: Bearer Content-Type: application/json -{ "message_dispatch_mode": "provider_only" } +{ "message_dispatch_mode": "provider", "store_message_content": "true" } ``` -See [Portal inbox](./portal-inbox.md) for mode behavior. +Portal UI: select **Memory** or **Provider**, then check **Store message content in inbox** on or off. + +See [Portal inbox](./portal-inbox.md) for routing behavior. -| Mode | Behavior | Use Case | -|------|----------|----------| -| `memory_only` | In-process RAM only, **not sent** | Development, testing | -| `provider_only` | Sent to real provider, no local copy | Production | -| `memory_and_provider` | Both — local copy + real send | Staging, debugging | +Every send also creates a `message_request_logs` row for operational tracing (correlation via `request_id`). --- @@ -445,7 +447,7 @@ Using a Mailgun Sandbox Domain: ### Provider Not Sending -Check workspace `message_dispatch_mode` via `GET /api/v1/workspaces/:wid/settings`. If it is `memory_only`, messages are captured locally — not sent to the provider. +Check workspace settings via `GET /api/v1/workspaces/:wid/settings`. If `message_dispatch_mode` is `memory`, messages are captured locally — not sent to the provider. ### API Key Rejected diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8c463035..a3c0f3e5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@radix-ui/react-checkbox": "^1.3.6", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", @@ -398,9 +399,9 @@ } }, "node_modules/@csstools/color-helpers": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", - "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", "dev": true, "funding": [ { @@ -442,9 +443,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.8.tgz", - "integrity": "sha512-3chWb7PRLijpJpPIKkDxdu6IBeO5MrFACND57On0j8OPpc0wZibcGc3xAHrSEbOx/KDRyMHoIxGn0w1PhXMYHw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", "dev": true, "funding": [ { @@ -458,7 +459,7 @@ ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^6.0.2", + "@csstools/color-helpers": "^6.1.0", "@csstools/css-calc": "^3.2.1" }, "engines": { @@ -493,9 +494,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", - "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", "dev": true, "funding": [ { @@ -1314,14 +1315,14 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", - "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.2" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", @@ -1709,9 +1710,9 @@ } }, "node_modules/@oxc-resolver/binding-android-arm-eabi": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.21.3.tgz", - "integrity": "sha512-eNU11A2WNizh04v3uyaJCootrHIaS0B9aHYXvAvVnPNk4xYSjMUjHnhQ6dewPN2MRYDskV85d1N0Aw0WNWhcyg==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.23.0.tgz", + "integrity": "sha512-8IJyWRLVAyhTfe9/TIEbQqSQnl5rUqYJrUOS6Dkr+Mq9FGHMxDGeiEmwkBqCvDP5KckpPh/GYSgbag66O6JsCw==", "cpu": [ "arm" ], @@ -1723,9 +1724,9 @@ ] }, "node_modules/@oxc-resolver/binding-android-arm64": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.21.3.tgz", - "integrity": "sha512-8Q+ZjTLvn2dIcWsrmhdrEihm7q+ag/k+mkry7Z+t0QbbHaVxXQfvH9AewyVMh/WrpEKhQ3DDgx9fYbqeCpeOEw==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.23.0.tgz", + "integrity": "sha512-pprVojnNhHxupwTT2gdeUlkxll6XEvWWBk3oVicOSNVWQC99OBnDhMQDoirqnzrE1bScQSMS2JgPpqdlrhz/Fg==", "cpu": [ "arm64" ], @@ -1737,9 +1738,9 @@ ] }, "node_modules/@oxc-resolver/binding-darwin-arm64": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.21.3.tgz", - "integrity": "sha512-wkh0qKZGHXVUDxFw3oA1TXnU2BDYY/r775oJflGeIr8uDPPoN2pk8gijQIzYRT6hoql/lg3+Tx/SaTn9e2/aGg==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.23.0.tgz", + "integrity": "sha512-mbIrWIMAJeytyee36OyUP5XH92TP7FaKaQ2m5AjokKy7STgjrhRt7SMXqpqLjhGm6Xn721Xmsg6H3Rtd9YQETw==", "cpu": [ "arm64" ], @@ -1751,9 +1752,9 @@ ] }, "node_modules/@oxc-resolver/binding-darwin-x64": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.21.3.tgz", - "integrity": "sha512-HbNc23FAQYbuyDV2vBWMez4u4mrsm5RAkniGZAWqr6lYZ3N4beeqIb776jzwRl8qL2zRhHVXpUj97X0QgogVzg==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.23.0.tgz", + "integrity": "sha512-UnIphmZ1LazUCr9DXWaKYWtKDefPMbgLsywaoYxRqVCNHhq4MM6d2q1Nz1i9Vzxt5i+cE2nRUYpAUHr/lijNYA==", "cpu": [ "x64" ], @@ -1765,9 +1766,9 @@ ] }, "node_modules/@oxc-resolver/binding-freebsd-x64": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.21.3.tgz", - "integrity": "sha512-K6xNsTUPEUdfrn0+kbMq5nOUB5w1C5pavPQngt4TM2FpN91lP0PBe2srSpamb4d69O7h86oAi/qWX/kZNRSjkw==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.23.0.tgz", + "integrity": "sha512-aaZ/cSEYFkSxgS2hOrobT6RQcsWNviOX8dW6CEkVx2/UYkAf9MeHbjl3W0usWV53rVV//ndBdn2nb1y7jsu4lw==", "cpu": [ "x64" ], @@ -1779,9 +1780,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.21.3.tgz", - "integrity": "sha512-VcFmOpcpWX1zoEy8M58tR2M9YxM+Z9RuQhqAx5q0CTmrruaP7Gveejg75hzd/5sg5nk9G3aLALEa3hE2FsmmTQ==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.23.0.tgz", + "integrity": "sha512-IoJLvO5SjLSVMaq83BNTrPCb1FppvoJc1IhZ5CoUVl3PykUBku7D+LK1j0GSurhJcIc6zfjghsvaZNpq5ev6Mg==", "cpu": [ "arm" ], @@ -1793,9 +1794,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.21.3.tgz", - "integrity": "sha512-quVoxFLBy43hWaQbbDtQNRwAX5vX76mv7n64icAtQcJ3eNgVeblqmkupF/hAneNthdqSlnd1sTjb3aQSaDPaCQ==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.23.0.tgz", + "integrity": "sha512-vskFpwg44T/LFsfjSCnVZ5ygcuqzPC1yUzVEiKa8BgHAQz0+QLQQW3EGWLPVi8EXFghzjR4EtgPBtOhCjU4jdw==", "cpu": [ "arm" ], @@ -1807,9 +1808,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.21.3.tgz", - "integrity": "sha512-X0AqNZgcD07Q4V3RDK18/vYOj/HQT/FnmEFGYS2jTWqY7JO13ryE3TEs3eAIgUJhBnNkpEaiXqz3VK8M7qQhWQ==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.23.0.tgz", + "integrity": "sha512-//TcHVhrChyw5RYtgts6WO7KcWq9387c1Z5Zvhqpk/ktAbyaRYgBZrpSY1GDCFq50ASt6B6jhh+JxB1rB45IAg==", "cpu": [ "arm64" ], @@ -1824,9 +1825,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm64-musl": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.21.3.tgz", - "integrity": "sha512-YkaQnaKYdbuaXvRt5Qd0GpbihzVnyfR6z1SpYfIUC6RTu4NF7lDKPjVkYb+jRI2gedVO2rVpN35Y6akG6ud4Lw==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.23.0.tgz", + "integrity": "sha512-ZFqlwiTf7CXLLSGyAR9tYiO33LiaeIEXW+xm42d8mnUGpDgPltyrCGYtQezyMMEXvjhOgCz1X+i7sbDTJEx+bg==", "cpu": [ "arm64" ], @@ -1841,9 +1842,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.21.3.tgz", - "integrity": "sha512-gB9HwhrPiFqUzDeEq+y/CgAijz1YdI6BnXz5GaH2Pa9cWdutchlkGFAiAuGb/PjVQpiK6NFKzFuztxrweoit7A==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.23.0.tgz", + "integrity": "sha512-oZ5LeN5+H1R19dRjTAxKrxQguH+AsemHcnthEfFxf4OjmBSty2doHLeSmMunKy3zpTHJQ3lh3Af+dNS+W6dYeA==", "cpu": [ "ppc64" ], @@ -1858,9 +1859,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.21.3.tgz", - "integrity": "sha512-zjDWBlYk8QGv0H8dsPUWqkfjYIIjG2TvspGkzXL0eImbgxtZorA/klKeHyolevoT3Kvbi+1iMr9Lhrh7jf54Og==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.23.0.tgz", + "integrity": "sha512-O4ciFDyX5ebQd0qkb1bjAIg8IEfiLT03GbSeylwlwlUMK9KwBWaALwrxSbc0Msaz4U6iPj+T9eRXpD5mxBfmvA==", "cpu": [ "riscv64" ], @@ -1875,9 +1876,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.21.3.tgz", - "integrity": "sha512-4UfsQvacV388y1zpXL7C1x1FNYaV52JtuNRiuzrfQA2z1z6ElVrsidkGsrvQ5EgeSq1Pj7kaKqrgGkvFuxJ/tw==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.23.0.tgz", + "integrity": "sha512-P3o8Y9kISYjcxadmbO+94ThRwLhwGuDAbA7dcdd4+YLpfeF+mmobz8fXf4NmSdfSqjyRSkceJDBRZha9NVYkiQ==", "cpu": [ "riscv64" ], @@ -1892,9 +1893,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.21.3.tgz", - "integrity": "sha512-b5uH+HKH0MP5mNBYaK75SKsJbw52URqrx2LavYdq6wb0l3ExAG5niYRP9DWUNHdKilpaBVM2bXk9HNWrH3ew7Q==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.23.0.tgz", + "integrity": "sha512-oj03m1E3RmTFczKhcKJDzHaEDKJnPIsDcQFVxBJsSdXGSuIPdt5TvcM332FfMQgzI6yDJqyl4InrnFfXrmUTKQ==", "cpu": [ "s390x" ], @@ -1909,9 +1910,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-x64-gnu": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.21.3.tgz", - "integrity": "sha512-PjYlmilBpNRh2ntXNYAK3Am5w/nPfEpnU/96iNx7CI8EzAn12J4JRiec63wHJTH31nLoCNxBg/829pN+3CfG3Q==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.23.0.tgz", + "integrity": "sha512-BqJxbSC8FdP7mSuSpRePTGHm0hXWV+dfz//f7SjsteZncLaBgWTBmi/OZNv7sX6CyG/Pt/eJkPorP+DkMOhMwQ==", "cpu": [ "x64" ], @@ -1926,9 +1927,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-x64-musl": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.21.3.tgz", - "integrity": "sha512-QTBAb7JuHlZ7JUEyM8UiQi2f7m/L4swBhP2TNpYIDc9Wp/wRw1G/8sl6i13aIzQAXH7LKIm294LeOHd0lQR8zA==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.23.0.tgz", + "integrity": "sha512-utmw+VmUrW4K8LI5/6jhg4aGYKJHOIjQ9syYOOA6pF3w7haKu4r4enTe2U0C04/HbUvkq/Zif43xFsKW1Pnq9w==", "cpu": [ "x64" ], @@ -1943,9 +1944,9 @@ ] }, "node_modules/@oxc-resolver/binding-openharmony-arm64": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.21.3.tgz", - "integrity": "sha512-4j1DFwjwv36ec9kds0jU/ucQ5Ha4ERO/H95BxR5JFf0kqUUAJ1kwII7XhTc1vZrkdJkvLGC9Q2MbpObpum8RBg==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.23.0.tgz", + "integrity": "sha512-V6lbRrthHa4TbvsLjPtg+EkXT1tRY+s4I8rYLXUfiHlZzGx3sLv1EH9CEOOevjvUYHLsbe/gqCIc73XnQfPb9A==", "cpu": [ "arm64" ], @@ -1957,9 +1958,9 @@ ] }, "node_modules/@oxc-resolver/binding-wasm32-wasi": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.21.3.tgz", - "integrity": "sha512-i8oluoel5kru/j1WNrjmQSiA3GQ7wvIYVR1IwIoZtKogAhya2iub+ZKIeSIkcJOrnzQ18Tzl/F+kL3fYOxZLvA==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.23.0.tgz", + "integrity": "sha512-gRoOxQPdnAmIAjxcuQNBxfihvx+wjTaQM/9/eP12xwnGNawOG/+Zz9RHN4WNSxT45b5CrscK4NB8aPh+oZQXAQ==", "cpu": [ "wasm32" ], @@ -1967,18 +1968,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.11.0", - "@emnapi/runtime": "1.11.0", - "@napi-rs/wasm-runtime": "^1.1.5" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.0.tgz", - "integrity": "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, @@ -1988,9 +1989,9 @@ } }, "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz", - "integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, "license": "MIT", "optional": true, @@ -2010,9 +2011,9 @@ } }, "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.21.3.tgz", - "integrity": "sha512-M/8dw8dD6aOs+NlPJax401CZB9I7Aut84isQLgALGGwke4Afvw+/7yYhZb94yXf6t2sPLhQLmSmtSV+2FhsOWg==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.23.0.tgz", + "integrity": "sha512-CgTGMYsJVe1eUiCdJTpGw21svXw79ITsemN1h0hcNkiswasDbN5MoibSLY+gRMWP5syfEz5iffrjZnwEP8xeUA==", "cpu": [ "arm64" ], @@ -2024,9 +2025,9 @@ ] }, "node_modules/@oxc-resolver/binding-win32-x64-msvc": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.21.3.tgz", - "integrity": "sha512-H7BCt/VnS9hnmMp42eGhZ99izSCRvlnWwy/N71K1/J8QoExwY4262Z8QiEkMDtduRJrztayDxETTckmUuAVL9Q==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.23.0.tgz", + "integrity": "sha512-gUGJpr+Rn6zMxm5juApV0K3U845i8t47o8k+rbO0BHbi4PoJIfSPeQmrE2dgohQm2g5k6iviNFyXCGqvmaYUpw==", "cpu": [ "x64" ], @@ -2043,15 +2044,45 @@ "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", "license": "MIT" }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.6.tgz", + "integrity": "sha512-eUEUoGMDpfkgHWSE97ZZaUJtzR1M7EKnNIpD1Q16+8JR9NWghcaqMulx9PuCQ720w0UclfYn6FEbCdd5Hx087g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-use-size": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.10.tgz", - "integrity": "sha512-IVVz4EvBcKjrzKgof714qDnz/SzQAkLA2Emh5edlHbgcE6fNd3Un6CJLlaYcnm8N4JmAtzQgse4dOKxcD2yc9g==", + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.11.tgz", + "integrity": "sha512-djW9+zeg137KQdlPtmE8xnaD+K2rcXXMWFrSg0hsmYZ6HRbdTA7tDHFgpaW9+huWVEu0RCabL+985T4TA0BE7g==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { @@ -2100,21 +2131,21 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.17", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.17.tgz", - "integrity": "sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.18.tgz", + "integrity": "sha512-apa28mldjMgORmE6g/w3sCcA0Y9UAVeeDVoozN4i7kOw12mLl9RBchfzK3Nn6qxOWjrZhK1Lfy7f07kyzxtnBw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-dismissable-layer": "1.1.14", "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-focus-scope": "1.1.11", "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", @@ -2151,16 +2182,16 @@ } }, "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.13.tgz", - "integrity": "sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.14.tgz", + "integrity": "sha512-4lUhWTWAjbDIqFrAPWJ3WqBOpO5YchVZ88X3nh6H9Lu5AFi5nCUeTPj3D8FSDmabmFeRe9ME0BDA4MwKTha5GQ==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-escape-keydown": "1.1.2" + "@radix-ui/react-use-effect-event": "0.0.3" }, "peerDependencies": { "@types/react": "*", @@ -2193,13 +2224,13 @@ } }, "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.10.tgz", - "integrity": "sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==", + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.11.tgz", + "integrity": "sha512-Mn88Vg2whaRocGJNOH+DKFqYm6ySFPQaiwHNxZPyjn99B52KAEJWWY9NP83+nWdk2HM3rdov+STu9AG471Rt9w==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { @@ -2236,12 +2267,12 @@ } }, "node_modules/@radix-ui/react-portal": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.12.tgz", - "integrity": "sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.13.tgz", + "integrity": "sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { @@ -2283,9 +2314,9 @@ } }, "node_modules/@radix-ui/react-primitive": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", - "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.7.tgz", + "integrity": "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==", "license": "MIT", "dependencies": { "@radix-ui/react-slot": "1.3.0" @@ -2306,18 +2337,18 @@ } }, "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.13.tgz", - "integrity": "sha512-9gkwneI0guf8JDmrFxPjJF6Ozzgioyw+/lonYNCwefS9ZHA05er0BVHiXr+LbWGHxUfczvMY6G1oiZZi1VzjRw==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.14.tgz", + "integrity": "sha512-8Qcnx9447tx/aCBgw6Jenfqg4Skq+vqab9mCBmuGNipIS5YXvL275wbKEu7+ICYHIlAPgCduUMJH1XOYewKF6Q==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-collection": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3" }, @@ -2337,12 +2368,12 @@ } }, "node_modules/@radix-ui/react-separator": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.10.tgz", - "integrity": "sha512-Y6K6jLQCVfCnTL2MEtGxDLffkhNfEfHsEg3Wa8JU+IWdn3EWbLXd3OuOfQRN7p/W/cUce1WyTk3QeuAoDBzN9g==", + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.11.tgz", + "integrity": "sha512-jRhe86+8PF7VZ1u14eOWVOuh2BuAhALg/FT1VcMC4OHedMTRUazDnDlKTt+yxo5cRNKHMfmvZ4sSQtWDeMV4CQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.6" + "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", @@ -2378,9 +2409,9 @@ } }, "node_modules/@radix-ui/react-tabs": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.15.tgz", - "integrity": "sha512-kxc9gI6/HfcU4nfMMVS3AmQK414kbU1IE6UCJmMmxjhO3cRPXOyYnmvyKD+ODt7q56nRq9l7Wovi6uaGwKgMlg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.16.tgz", + "integrity": "sha512-v3Ab2l7z6U7tRB4xA0IyKdq0OsqaO1o9ZjsIEoKKnSZ/l96mZz8aCTX0NCXw+YVHJXr8Km4d+Mn6/Q8YjXa+gw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", @@ -2388,8 +2419,8 @@ "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.6", - "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.14", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { @@ -2459,14 +2490,26 @@ } } }, - "node_modules/@radix-ui/react-use-escape-keydown": { + "node_modules/@radix-ui/react-use-layout-effect": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.2.tgz", - "integrity": "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", "license": "MIT", - "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.2" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.2.tgz", + "integrity": "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==", + "license": "MIT", "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -2477,11 +2520,14 @@ } } }, - "node_modules/@radix-ui/react-use-layout-effect": { + "node_modules/@radix-ui/react-use-size": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", - "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz", + "integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==", "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -2493,9 +2539,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", "cpu": [ "arm64" ], @@ -2510,9 +2556,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", "cpu": [ "arm64" ], @@ -2527,9 +2573,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", "cpu": [ "x64" ], @@ -2544,9 +2590,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", "cpu": [ "x64" ], @@ -2561,9 +2607,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", "cpu": [ "arm" ], @@ -2578,9 +2624,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", "cpu": [ "arm64" ], @@ -2598,9 +2644,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", "cpu": [ "arm64" ], @@ -2618,9 +2664,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", "cpu": [ "ppc64" ], @@ -2638,9 +2684,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", "cpu": [ "s390x" ], @@ -2658,9 +2704,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", "cpu": [ "x64" ], @@ -2678,9 +2724,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", "cpu": [ "x64" ], @@ -2698,9 +2744,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", "cpu": [ "arm64" ], @@ -2715,9 +2761,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", "cpu": [ "wasm32" ], @@ -2725,30 +2771,41 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -2757,9 +2814,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", "cpu": [ "arm64" ], @@ -2774,9 +2831,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", "cpu": [ "x64" ], @@ -2945,14 +3002,13 @@ "license": "MIT" }, "node_modules/@storybook/icons": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-2.0.2.tgz", - "integrity": "sha512-KZBCpXsshAIjczYNXR/rlxEtCUX/eAbpFNwKi8bcOomrLA4t/SyPz5RF+lVPO2oZBUE4sAkt43mfJUevQDSEEw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-2.1.0.tgz", + "integrity": "sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg==", "dev": true, "license": "MIT", "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/@storybook/react": { @@ -3046,15 +3102,15 @@ } }, "node_modules/@swc/core": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.41.tgz", - "integrity": "sha512-03nQq/082QRJJiOvp3FGbgxTGyyxMxohPTjhk/W9bD2J0tk4ukITI7goOhOO2WbaHn/lsPmo/zf8+DIXhwpgYQ==", + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.43.tgz", + "integrity": "sha512-1CuKjFkPxIgGdeHVuNbkxmBxkcbdc08u0aiI43pFq6yY1tTVKmXT9hFEooyyKs/sJ3xf1GPHyEwTtk9Xl8dvQw==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.26" + "@swc/types": "^0.1.27" }, "engines": { "node": ">=10" @@ -3064,18 +3120,18 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.15.41", - "@swc/core-darwin-x64": "1.15.41", - "@swc/core-linux-arm-gnueabihf": "1.15.41", - "@swc/core-linux-arm64-gnu": "1.15.41", - "@swc/core-linux-arm64-musl": "1.15.41", - "@swc/core-linux-ppc64-gnu": "1.15.41", - "@swc/core-linux-s390x-gnu": "1.15.41", - "@swc/core-linux-x64-gnu": "1.15.41", - "@swc/core-linux-x64-musl": "1.15.41", - "@swc/core-win32-arm64-msvc": "1.15.41", - "@swc/core-win32-ia32-msvc": "1.15.41", - "@swc/core-win32-x64-msvc": "1.15.41" + "@swc/core-darwin-arm64": "1.15.43", + "@swc/core-darwin-x64": "1.15.43", + "@swc/core-linux-arm-gnueabihf": "1.15.43", + "@swc/core-linux-arm64-gnu": "1.15.43", + "@swc/core-linux-arm64-musl": "1.15.43", + "@swc/core-linux-ppc64-gnu": "1.15.43", + "@swc/core-linux-s390x-gnu": "1.15.43", + "@swc/core-linux-x64-gnu": "1.15.43", + "@swc/core-linux-x64-musl": "1.15.43", + "@swc/core-win32-arm64-msvc": "1.15.43", + "@swc/core-win32-ia32-msvc": "1.15.43", + "@swc/core-win32-x64-msvc": "1.15.43" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" @@ -3087,9 +3143,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.41.tgz", - "integrity": "sha512-kREh6J5paQFvP3i7f/4FbqRNOJREutVFVOkder4GVyCBQ39YmER55cW/y1NNjwrchzFqgYswFn0mMDCqbqKzrw==", + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.43.tgz", + "integrity": "sha512-v1aVuvXdo/BHxJzco9V2xpHrvwWmhfS8t6gziY5wJxd+Z2h8AeJRnAwPD8itCDaGXVBwJ/CaKfxEzTkG0Va0OA==", "cpu": [ "arm64" ], @@ -3104,9 +3160,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.41.tgz", - "integrity": "sha512-N8B56ESFazZAWZyIkecADSPCwlLEinW7QLMEeotCpv4J7VXwfH+OLkmRL8o96UZ+1355fwHxDTS6/wK7yucvkA==", + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.43.tgz", + "integrity": "sha512-lp3d4Lamc8dt5huYdGLSR+9hLxmfr1jb0l+4XXG2zPqZwYWRN9R0U2qYoTrggiU2RWW0oV9VbWM3kBnqIc2kdQ==", "cpu": [ "x64" ], @@ -3121,9 +3177,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.41.tgz", - "integrity": "sha512-6XrId2fyle0mS5xxON8rU84mPd2Cq1kDJRj+4BnQKTd7u+2kSA6Ww+JkOP0iTNqOqt9OXhPOEAjBHAuonWcdCg==", + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.43.tgz", + "integrity": "sha512-JWTQQELtsG5GgphDrr/XqqmM2pDN3cZqbMS0Mrg+iTiXL3F74sn/S2IyYE/5u4h2KLkTf9qQ7dXyxsbx7YzkeA==", "cpu": [ "arm" ], @@ -3138,9 +3194,9 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.41.tgz", - "integrity": "sha512-ynLIarxlkVnqHn1D0fKOVht6mNU5ks6lrH+MY3kkS+XFaGGgDxFZVjWKJlkYTKm3RCvBTfA8Ng5fLufXheMRKQ==", + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.43.tgz", + "integrity": "sha512-B4otJRdPWIsmiSBf0uG7Z/+vMWmkufjz5MmYxubwKuZazDW14Zd3symga1N62QR4RT+kEFeHEgsXfZGyn/w0hw==", "cpu": [ "arm64" ], @@ -3158,9 +3214,9 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.41.tgz", - "integrity": "sha512-dXu/5vd4gh8symyhRF+4G7gOPkjmb4pONhh7sl+6GSiW0LOKZlfu5kXmyFbTz9smOT7jgr002qY9b1nujjXt2A==", + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.43.tgz", + "integrity": "sha512-6zB6OnpViBxYy4tgY3v2i6AZY9fwkcHZ032UOwtwUuW1d19sdT07qF0kZe6/3UR1tUaK6jjg2rmVcUIBCEYVjQ==", "cpu": [ "arm64" ], @@ -3178,9 +3234,9 @@ } }, "node_modules/@swc/core-linux-ppc64-gnu": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.41.tgz", - "integrity": "sha512-XGO6zVPXoPE0gf/XnI4jBbafNT13AYgoh6ns0JCSdOetI/kqVf0vhpz7NuNgAzZrMVCsmieqjPoTwViDgh4mOQ==", + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.43.tgz", + "integrity": "sha512-coxE1ZWdB3uSDVNoEtYNrRi/1epvckZx9cTJ8ICUxTMTxGk+yvQ/Twacp3ruZSaMPGCriUjP86C37VhaT6nyRg==", "cpu": [ "ppc64" ], @@ -3198,9 +3254,9 @@ } }, "node_modules/@swc/core-linux-s390x-gnu": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.41.tgz", - "integrity": "sha512-0WUglRwyZtW+iMi7J3iFdrCxreZZIKf4egTwEQfIYRsqFax69A0OrFj+NIoFSE03xBT/IFRrg+S8K6f9Ky+4hA==", + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.43.tgz", + "integrity": "sha512-lXfLhs+LpBsD5inuYx+YDH5WsPPBQ95KPUiy8P5wq9ob9xKDZFqwNfU2QW6bGO8NqRO/H9JQomTSt5Yyh+FGfA==", "cpu": [ "s390x" ], @@ -3218,9 +3274,9 @@ } }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.41.tgz", - "integrity": "sha512-VxkuQK59c0tHm6uJZCUrS3cyA2JhGGfdU6e41SZz0x/JS+4Sm7C1mIc97In14vkZJopEt7yXA2TouCqZDSygEA==", + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.43.tgz", + "integrity": "sha512-07XnKwTmKy8TGOZG3D9fRnLWGynxPjwQnZLVmBFbo6F+7vHYzBIOuwXEhemrChBWb6yDNZsVCcMWCPX6FDD2xg==", "cpu": [ "x64" ], @@ -3238,9 +3294,9 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.41.tgz", - "integrity": "sha512-/0qXIu1ZxggLuovLb22vFfKHq2AA4n6Whw5UwmVCHk4pkw7KWnPIQpMCEqUMPsNkFJig7PPp/TSYFu8ZEb2rtQ==", + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.43.tgz", + "integrity": "sha512-TJc+bsSIaBh+hZvZ5GRtW/K1bw66TJ9vsUwvVIsZdiWxU5ObLwZvfcnZ3UpgVfMnFibRes9uriJrQNBHEEogRQ==", "cpu": [ "x64" ], @@ -3258,9 +3314,9 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.41.tgz", - "integrity": "sha512-Y481sMNZM6rECh9VO4+y26N1lWEDAyxnBZskUf37fl90uHE946VHfmiVQWT0uMFOhyJJFovGTRuF4W82dwewUg==", + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.43.tgz", + "integrity": "sha512-jfd7s2/bUQYkOHLs+LWQNKZdmDa8+sufKLllhpWAhVQ2GDCwsHe3vR/j+OSiItZNtkzFuaawa3+SAKz9y5gYfw==", "cpu": [ "arm64" ], @@ -3275,9 +3331,9 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.41.tgz", - "integrity": "sha512-BAchBD5qeUzy3hiPSLJtaaoSm4blCLyYffOF1bGE4ETcV+OisqjUAwDQMJj++4bTpvMCDzwC+Bj3PmQyBCtscw==", + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.43.tgz", + "integrity": "sha512-rLAE8JvucqEW1ZGohxPQrQWPBQeJG4+ypKbWfdlU/qmKScvCkxf9/Jxnzki1dkUQCQ7P5Enp13RlvqOlvx/32g==", "cpu": [ "ia32" ], @@ -3292,9 +3348,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.41.tgz", - "integrity": "sha512-WOkA+fJ/ViVBQDsSV9JC52NACTe5PhlurA6viASDZGb7HR3KS01ZG7RZ+Bg6SVQFIoq3gSbTsskQVe6EbHFAYw==", + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.43.tgz", + "integrity": "sha512-h8MLDHZcfIukwQWj03rIJZx1I0E81AYj2X7J/nGErG4nz+QAv6G1Z+peotvinL3lqpbo32tLYSMFo32/ySzxKg==", "cpu": [ "x64" ], @@ -3326,9 +3382,9 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", - "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", "dev": true, "license": "MIT", "dependencies": { @@ -3338,37 +3394,37 @@ "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.3.1" + "tailwindcss": "4.3.2" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", - "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.1", - "@tailwindcss/oxide-darwin-arm64": "4.3.1", - "@tailwindcss/oxide-darwin-x64": "4.3.1", - "@tailwindcss/oxide-freebsd-x64": "4.3.1", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", - "@tailwindcss/oxide-linux-x64-musl": "4.3.1", - "@tailwindcss/oxide-wasm32-wasi": "4.3.1", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", - "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", "cpu": [ "arm64" ], @@ -3383,9 +3439,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", - "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", "cpu": [ "arm64" ], @@ -3400,9 +3456,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", - "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", "cpu": [ "x64" ], @@ -3417,9 +3473,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", - "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", "cpu": [ "x64" ], @@ -3434,9 +3490,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", - "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", "cpu": [ "arm" ], @@ -3451,9 +3507,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", - "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", "cpu": [ "arm64" ], @@ -3471,9 +3527,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", - "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", "cpu": [ "arm64" ], @@ -3491,9 +3547,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", - "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", "cpu": [ "x64" ], @@ -3511,9 +3567,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", - "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", "cpu": [ "x64" ], @@ -3531,9 +3587,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", - "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -3549,9 +3605,9 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.10.0", - "@emnapi/runtime": "^1.10.0", - "@emnapi/wasi-threads": "^1.2.1", + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" @@ -3561,9 +3617,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", - "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", "cpu": [ "arm64" ], @@ -3578,9 +3634,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", - "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", "cpu": [ "x64" ], @@ -3595,23 +3651,23 @@ } }, "node_modules/@tailwindcss/postcss": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.1.tgz", - "integrity": "sha512-dNJuNbdEJT/SWRuXTYP1WSamelsz3ztkUsdtWQPjrexysrTpaEPM40P/71knXiXLYEojqPOEGitVLLpPMS5T6A==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.2.tgz", + "integrity": "sha512-rjVWYCa7Ngbi5AarT6k8TkxUG3Wl1QKzHdIZVsjZSzf36Jmo2IKZt/NHRAwly8oDkbBOH0YTu+CHuf9jPxMc+g==", "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.3.1", - "@tailwindcss/oxide": "4.3.1", - "postcss": "8.5.15", - "tailwindcss": "4.3.1" + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "postcss": "^8.5.15", + "tailwindcss": "4.3.2" } }, "node_modules/@tanstack/query-core": { - "version": "5.101.0", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz", - "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==", + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz", + "integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==", "license": "MIT", "funding": { "type": "github", @@ -3619,12 +3675,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.101.0", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz", - "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==", + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz", + "integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.101.0" + "@tanstack/query-core": "5.101.2" }, "funding": { "type": "github", @@ -3724,9 +3780,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -3877,17 +3933,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz", - "integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz", + "integrity": "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.61.1", - "@typescript-eslint/type-utils": "8.61.1", - "@typescript-eslint/utils": "8.61.1", - "@typescript-eslint/visitor-keys": "8.61.1", + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/type-utils": "8.62.1", + "@typescript-eslint/utils": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -3900,7 +3956,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.61.1", + "@typescript-eslint/parser": "^8.62.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -3916,16 +3972,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.1.tgz", - "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.1.tgz", + "integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.61.1", - "@typescript-eslint/types": "8.61.1", - "@typescript-eslint/typescript-estree": "8.61.1", - "@typescript-eslint/visitor-keys": "8.61.1", + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", "debug": "^4.4.3" }, "engines": { @@ -3941,14 +3997,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz", - "integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz", + "integrity": "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.61.1", - "@typescript-eslint/types": "^8.61.1", + "@typescript-eslint/tsconfig-utils": "^8.62.1", + "@typescript-eslint/types": "^8.62.1", "debug": "^4.4.3" }, "engines": { @@ -3963,14 +4019,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz", - "integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz", + "integrity": "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.61.1", - "@typescript-eslint/visitor-keys": "8.61.1" + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3981,9 +4037,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz", - "integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz", + "integrity": "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==", "dev": true, "license": "MIT", "engines": { @@ -3998,15 +4054,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz", - "integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz", + "integrity": "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.61.1", - "@typescript-eslint/typescript-estree": "8.61.1", - "@typescript-eslint/utils": "8.61.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/utils": "8.62.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -4023,9 +4079,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz", - "integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz", + "integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==", "dev": true, "license": "MIT", "engines": { @@ -4037,16 +4093,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz", - "integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz", + "integrity": "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.61.1", - "@typescript-eslint/tsconfig-utils": "8.61.1", - "@typescript-eslint/types": "8.61.1", - "@typescript-eslint/visitor-keys": "8.61.1", + "@typescript-eslint/project-service": "8.62.1", + "@typescript-eslint/tsconfig-utils": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -4078,16 +4134,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz", - "integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.1.tgz", + "integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.61.1", - "@typescript-eslint/types": "8.61.1", - "@typescript-eslint/typescript-estree": "8.61.1" + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4102,13 +4158,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz", - "integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz", + "integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/types": "8.62.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -4120,13 +4176,13 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", - "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "^1.0.0" + "@rolldown/pluginutils": "^1.0.1" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -4511,9 +4567,9 @@ "license": "MIT" }, "node_modules/autoprefixer": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", - "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "version": "10.5.2", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.2.tgz", + "integrity": "sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==", "dev": true, "funding": [ { @@ -4531,8 +4587,8 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.28.2", - "caniuse-lite": "^1.0.30001787", + "browserslist": "^4.28.4", + "caniuse-lite": "^1.0.30001799", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -4558,9 +4614,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.38", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", - "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", + "version": "2.10.41", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.41.tgz", + "integrity": "sha512-WwS7MHhqGHHlaVsqRZnhvCEMS0owDX+SxRlve7JkuH7My1Ara3ZriTmCQupPfYjxMZ8I/tgxtJYr2t7taHaH4A==", "dev": true, "license": "Apache-2.0", "bin": { @@ -4581,9 +4637,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": { @@ -4594,9 +4650,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", "dev": true, "funding": [ { @@ -4614,10 +4670,10 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", "update-browserslist-db": "^1.2.3" }, "bin": { @@ -4644,9 +4700,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001799", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", - "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "version": "1.0.30001800", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", + "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", "dev": true, "funding": [ { @@ -4921,9 +4977,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.376", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.376.tgz", - "integrity": "sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==", + "version": "1.5.384", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.384.tgz", + "integrity": "sha512-g6KAKY1vkYsADvSPWvdJsuYT0ixdcu6lUtD9P/wJKGBEDlZVXh2AX42j1mPqqaQPDluWjara9ziQ7xqAeXCt5A==", "dev": true, "license": "ISC" }, @@ -4975,9 +5031,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", "dev": true, "license": "MIT" }, @@ -5047,9 +5103,9 @@ } }, "node_modules/eslint": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.5.0.tgz", - "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz", + "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", "dev": true, "license": "MIT", "workspaces": [ @@ -5267,9 +5323,9 @@ } }, "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -5456,9 +5512,9 @@ } }, "node_modules/globals": { - "version": "17.6.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", - "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", "dev": true, "license": "MIT", "engines": { @@ -6275,9 +6331,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.13", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz", - "integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "dev": true, "funding": [ { @@ -6301,9 +6357,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.48", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", - "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==", + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", "dev": true, "license": "MIT", "engines": { @@ -6400,34 +6456,34 @@ } }, "node_modules/oxc-resolver": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.21.3.tgz", - "integrity": "sha512-2Mx3fKQz7+xgrBONjsxOgCGtMHOn38/HxMzW1I5efwXB5a4lRN0Vp40gYUJFBWJslcrvwoofTrqoTnLbwTd3pA==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.23.0.tgz", + "integrity": "sha512-f0+l598CJMOLnYPXsXxttJALH0ljtivdRMKtvHhxRuWa5FYmw5+qODARl8oYjMC/brpzKcrpdORsOBrTqhBZ9A==", "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxc-resolver/binding-android-arm-eabi": "11.21.3", - "@oxc-resolver/binding-android-arm64": "11.21.3", - "@oxc-resolver/binding-darwin-arm64": "11.21.3", - "@oxc-resolver/binding-darwin-x64": "11.21.3", - "@oxc-resolver/binding-freebsd-x64": "11.21.3", - "@oxc-resolver/binding-linux-arm-gnueabihf": "11.21.3", - "@oxc-resolver/binding-linux-arm-musleabihf": "11.21.3", - "@oxc-resolver/binding-linux-arm64-gnu": "11.21.3", - "@oxc-resolver/binding-linux-arm64-musl": "11.21.3", - "@oxc-resolver/binding-linux-ppc64-gnu": "11.21.3", - "@oxc-resolver/binding-linux-riscv64-gnu": "11.21.3", - "@oxc-resolver/binding-linux-riscv64-musl": "11.21.3", - "@oxc-resolver/binding-linux-s390x-gnu": "11.21.3", - "@oxc-resolver/binding-linux-x64-gnu": "11.21.3", - "@oxc-resolver/binding-linux-x64-musl": "11.21.3", - "@oxc-resolver/binding-openharmony-arm64": "11.21.3", - "@oxc-resolver/binding-wasm32-wasi": "11.21.3", - "@oxc-resolver/binding-win32-arm64-msvc": "11.21.3", - "@oxc-resolver/binding-win32-x64-msvc": "11.21.3" + "@oxc-resolver/binding-android-arm-eabi": "11.23.0", + "@oxc-resolver/binding-android-arm64": "11.23.0", + "@oxc-resolver/binding-darwin-arm64": "11.23.0", + "@oxc-resolver/binding-darwin-x64": "11.23.0", + "@oxc-resolver/binding-freebsd-x64": "11.23.0", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.23.0", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.23.0", + "@oxc-resolver/binding-linux-arm64-gnu": "11.23.0", + "@oxc-resolver/binding-linux-arm64-musl": "11.23.0", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.23.0", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.23.0", + "@oxc-resolver/binding-linux-riscv64-musl": "11.23.0", + "@oxc-resolver/binding-linux-s390x-gnu": "11.23.0", + "@oxc-resolver/binding-linux-x64-gnu": "11.23.0", + "@oxc-resolver/binding-linux-x64-musl": "11.23.0", + "@oxc-resolver/binding-openharmony-arm64": "11.23.0", + "@oxc-resolver/binding-wasm32-wasi": "11.23.0", + "@oxc-resolver/binding-win32-arm64-msvc": "11.23.0", + "@oxc-resolver/binding-win32-x64-msvc": "11.23.0" } }, "node_modules/p-limit": { @@ -6554,9 +6610,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -6567,9 +6623,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "dev": true, "funding": [ { @@ -6745,9 +6801,9 @@ } }, "node_modules/react-router": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz", - "integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==", + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -6767,12 +6823,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz", - "integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==", + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", "license": "MIT", "dependencies": { - "react-router": "7.18.0" + "react-router": "7.18.1" }, "engines": { "node": ">=20.0.0" @@ -6805,9 +6861,9 @@ } }, "node_modules/recast": { - "version": "0.23.11", - "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", - "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", + "version": "0.23.12", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.12.tgz", + "integrity": "sha512-dEWRjcINDu/F4l2dYx57ugBtD7HV9KXESyxhzw/MqWLeglJrsjJKqACPyUPg+6AF8mIgm+Zi0dZ3ACoIg+QtpA==", "dev": true, "license": "MIT", "dependencies": { @@ -6881,13 +6937,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", + "@oxc-project/types": "=0.138.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -6897,27 +6953,27 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" } }, "node_modules/rolldown/node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", "dev": true, "license": "MIT", "funding": { @@ -7163,9 +7219,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", - "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", "dev": true, "license": "MIT" }, @@ -7254,22 +7310,22 @@ } }, "node_modules/tldts": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.3.tgz", - "integrity": "sha512-A3BDQBeeukYPzB4QdQ1DtdlUmp4x2OCH8n5UVhEWbyANxNep8GavottKzd1xYKFJKjUgMyPT7EzOfnBO55s8Sg==", + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.6.tgz", + "integrity": "sha512-rbP0Gyx8b3Ae9yO//CU2wbSnQNoQ66m1nJdSbSHmnwKwzkkz/u8mERYU8T2rmlmy+bJvRNn84yNCW8gYqox44Q==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.4.3" + "tldts-core": "^7.4.6" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.3.tgz", - "integrity": "sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw==", + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.6.tgz", + "integrity": "sha512-TkQNGJIhlEphpHCjKodMTSe23egUZr/g+flI2qkLgiJ/maAzSgXypSLRTNH3nCmqgayEmtcJBiLcfODSAr1xoA==", "dev": true, "license": "MIT" }, @@ -7371,16 +7427,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.1.tgz", - "integrity": "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.1.tgz", + "integrity": "sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.61.1", - "@typescript-eslint/parser": "8.61.1", - "@typescript-eslint/typescript-estree": "8.61.1", - "@typescript-eslint/utils": "8.61.1" + "@typescript-eslint/eslint-plugin": "8.62.1", + "@typescript-eslint/parser": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/utils": "8.62.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7522,16 +7578,16 @@ } }, "node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", "tinyglobby": "^0.2.17" }, "bin": { @@ -7548,7 +7604,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", diff --git a/frontend/package.json b/frontend/package.json index ceb36e50..25163024 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "storybook": "storybook dev -p 6006" }, "dependencies": { + "@radix-ui/react-checkbox": "^1.3.6", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", diff --git a/frontend/src/components/ui/checkbox.tsx b/frontend/src/components/ui/checkbox.tsx new file mode 100644 index 00000000..1eae53bf --- /dev/null +++ b/frontend/src/components/ui/checkbox.tsx @@ -0,0 +1,28 @@ +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" + +import { Icon } from "@/components/ui/icon" +import { cn } from "@/lib/utils" + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/frontend/src/features/settings/hooks/use-settings.hook.ts b/frontend/src/features/settings/hooks/use-settings.hook.ts index 3dec6462..137140c5 100644 --- a/frontend/src/features/settings/hooks/use-settings.hook.ts +++ b/frontend/src/features/settings/hooks/use-settings.hook.ts @@ -8,7 +8,9 @@ import { patchSettings, regenerateApiKey, } from "../settings.api" -import type { ApiKey, RetentionMode, WorkspaceSettings } from "../settings.types" +import { parseMessageDispatchConfig } from "../message-dispatch-mode" +import { parseWorkspaceSettings } from "../settings.schema" +import type { ApiKey, WorkspaceSettings, WorkspaceSettingsPatch } from "../settings.types" export function useSettings(workspaceId: string) { const [settings, setSettings] = useState({}) @@ -46,9 +48,9 @@ export function useSettings(workspaceId: string) { } }, [workspaceId, trigger]) - async function saveSettings(patch: Record) { + async function saveSettings(patch: WorkspaceSettingsPatch) { await patchSettings(workspaceId, patch) - setSettings((prev) => ({ ...prev, ...patch })) + setSettings((prev) => parseWorkspaceSettings({ ...prev, ...patch })) } async function addApiKey(name: string) { @@ -66,12 +68,12 @@ export function useSettings(workspaceId: string) { return regenerateApiKey(workspaceId, keyId) } - const retentionMode = (settings.data_retention as RetentionMode | undefined) ?? "memory" + const messageDispatchConfig = parseMessageDispatchConfig(settings.message_dispatch_mode, settings.store_message_content) return { settings, apiKeys, - retentionMode, + messageDispatchConfig, isLoading, error, reload, diff --git a/frontend/src/features/settings/message-dispatch-mode.test.ts b/frontend/src/features/settings/message-dispatch-mode.test.ts new file mode 100644 index 00000000..daffd7f0 --- /dev/null +++ b/frontend/src/features/settings/message-dispatch-mode.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest" + +import { + dispatchConfigsEqual, + normalizeDispatchMode, + normalizeStoreMessageContent, + parseMessageDispatchConfig, + toStoreMessageContentSetting, +} from "./message-dispatch-mode" + +describe("parseMessageDispatchConfig", () => { + it("maps valid combinations", () => { + expect(parseMessageDispatchConfig("memory", "false")).toEqual({ mode: "memory", storeMessageContent: false }) + expect(parseMessageDispatchConfig("memory", "true")).toEqual({ mode: "memory", storeMessageContent: true }) + expect(parseMessageDispatchConfig("provider", "false")).toEqual({ mode: "provider", storeMessageContent: false }) + expect(parseMessageDispatchConfig("provider", "true")).toEqual({ mode: "provider", storeMessageContent: true }) + }) + + it("defaults to memory without inbox capture on missing input", () => { + expect(parseMessageDispatchConfig()).toEqual({ mode: "memory", storeMessageContent: false }) + expect(parseMessageDispatchConfig("unknown", "invalid")).toEqual({ mode: "memory", storeMessageContent: false }) + }) +}) + +describe("normalizeDispatchMode", () => { + it("is case-insensitive for provider", () => { + expect(normalizeDispatchMode("PROVIDER")).toBe("provider") + }) +}) + +describe("normalizeStoreMessageContent", () => { + it("treats unknown strings as false", () => { + expect(normalizeStoreMessageContent("yes")).toBe(false) + }) +}) + +describe("toStoreMessageContentSetting", () => { + it("maps boolean to API string values", () => { + expect(toStoreMessageContentSetting(true)).toBe("true") + expect(toStoreMessageContentSetting(false)).toBe("false") + }) +}) + +describe("dispatchConfigsEqual", () => { + it("compares mode and store flag", () => { + expect(dispatchConfigsEqual({ mode: "memory", storeMessageContent: false }, { mode: "memory", storeMessageContent: false })).toBe(true) + expect(dispatchConfigsEqual({ mode: "provider", storeMessageContent: true }, { mode: "provider", storeMessageContent: false })).toBe(false) + }) +}) diff --git a/frontend/src/features/settings/message-dispatch-mode.ts b/frontend/src/features/settings/message-dispatch-mode.ts new file mode 100644 index 00000000..2f280ed3 --- /dev/null +++ b/frontend/src/features/settings/message-dispatch-mode.ts @@ -0,0 +1,50 @@ +import type { + MessageDispatchConfig, + MessageDispatchMode, + StoreMessageContentSetting, +} from "./settings.types" + +export const DISPATCH_MODES = ["memory", "provider"] as const +export const STORE_CONTENT_MODES = ["true", "false"] as const + + +export function normalizeDispatchMode(raw?: string): MessageDispatchMode { + switch (raw?.trim().toLowerCase()) { + case "provider": + return "provider" + case "memory": + default: + return "memory" + } +} + +export function normalizeStoreMessageContent(raw?: string): boolean { + switch (raw?.trim().toLowerCase()) { + case "true": + return true + case "false": + default: + return false + } +} + +export function parseMessageDispatchConfig( + modeRaw?: string, + storeRaw?: string, +): MessageDispatchConfig { + return { + mode: normalizeDispatchMode(modeRaw), + storeMessageContent: normalizeStoreMessageContent(storeRaw), + } +} + +export function toStoreMessageContentSetting(storeMessageContent: boolean): StoreMessageContentSetting { + return storeMessageContent ? "true" : "false" +} + +export function dispatchConfigsEqual( + left: MessageDispatchConfig, + right: MessageDispatchConfig, +): boolean { + return left.mode === right.mode && left.storeMessageContent === right.storeMessageContent +} diff --git a/frontend/src/features/settings/pages/settings.page.tsx b/frontend/src/features/settings/pages/settings.page.tsx index b635d259..1f0e1feb 100644 --- a/frontend/src/features/settings/pages/settings.page.tsx +++ b/frontend/src/features/settings/pages/settings.page.tsx @@ -3,6 +3,7 @@ import { useState } from "react" import { useParams } from "react-router-dom" import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" import { Icon } from "@/components/ui/icon" import { Input } from "@/components/ui/input" import { Spinner } from "@/components/ui/spinner" @@ -12,11 +13,18 @@ import { cn } from "@/lib/utils" import { ApiKeyRow } from "../components/api-key-row" import { RadioOption } from "../components/radio-option" import { useSettings } from "../hooks/use-settings.hook" -import type { RetentionMode, SettingsTab, WorkspaceSettings } from "../settings.types" +import { dispatchConfigsEqual, toStoreMessageContentSetting } from "../message-dispatch-mode" +import type { + MessageDispatchConfig, + MessageDispatchMode, + SettingsTab, + WorkspaceSettings, + WorkspaceSettingsPatch, +} from "../settings.types" interface GeneralSettingsPanelProps { settings: WorkspaceSettings - onSave: (patch: Record) => Promise + onSave: (patch: WorkspaceSettingsPatch) => Promise } function GeneralSettingsPanel({ settings, onSave }: GeneralSettingsPanelProps) { @@ -80,53 +88,75 @@ function GeneralSettingsPanel({ settings, onSave }: GeneralSettingsPanelProps) { ) } -interface RetentionSettingsPanelProps { - initialMode: RetentionMode - onSave: (patch: Record) => Promise +interface DispatchSettingsPanelProps { + initialConfig: MessageDispatchConfig + onSave: (patch: WorkspaceSettingsPatch) => Promise } -function RetentionSettingsPanel({ initialMode, onSave }: RetentionSettingsPanelProps) { - const [retentionMode, setRetentionMode] = useState(initialMode) +function DispatchSettingsPanel({ initialConfig, onSave }: DispatchSettingsPanelProps) { + const [mode, setMode] = useState(initialConfig.mode) + const [storeMessageContent, setStoreMessageContent] = useState(initialConfig.storeMessageContent) const [isSaving, setIsSaving] = useState(false) + const currentConfig: MessageDispatchConfig = { mode, storeMessageContent } + const isDirty = !dispatchConfigsEqual(currentConfig, initialConfig) + async function handleSave() { setIsSaving(true) try { - await onSave({ data_retention: retentionMode }) + await onSave({ + message_dispatch_mode: mode, + store_message_content: toStoreMessageContentSetting(storeMessageContent), + }) } finally { setIsSaving(false) } } return ( -
- setRetentionMode("memory")} - /> - setRetentionMode("both")} - /> - setRetentionMode("providers")} - /> +
+
+

Dispatch mode

+

Choose where outbound messages are routed.

+
+ setMode("memory")} + /> + setMode("provider")} + /> +
+
-
) @@ -137,7 +167,7 @@ export function SettingsPage() { const [activeTab, setActiveTab] = useState("general") const [busyKeyId, setBusyKeyId] = useState(null) - const { settings, apiKeys, retentionMode, isLoading, error, saveSettings, addApiKey, removeApiKey, rotateApiKey } = + const { settings, apiKeys, messageDispatchConfig, isLoading, error, saveSettings, addApiKey, removeApiKey, rotateApiKey } = useSettings(wid) async function handleCreateApiKey() { @@ -194,7 +224,7 @@ export function SettingsPage() { ["general", "General"], ["developer", "Developer"], ["team", "Team Management"], - ["retention", "Data Retention"], + ["dispatch", "Message Dispatch"], ] as const ).map(([value, label]) => (
@@ -256,8 +286,12 @@ export function SettingsPage() { - - + + diff --git a/frontend/src/features/settings/settings.api.ts b/frontend/src/features/settings/settings.api.ts index e008419a..79b003bb 100644 --- a/frontend/src/features/settings/settings.api.ts +++ b/frontend/src/features/settings/settings.api.ts @@ -1,19 +1,17 @@ import { apiFetch } from "@/core/api/client" -import type { ApiKey, WorkspaceSettings } from "./settings.types" +import { parseWorkspaceSettings } from "./settings.schema" +import type { ApiKey, WorkspaceSettings, WorkspaceSettingsPatch } from "./settings.types" export async function getSettings(workspaceId: string): Promise { const res = await apiFetch(`/api/v1/workspaces/${workspaceId}/settings`) if (!res.ok) { throw new Error("Failed to load settings") } - return (await res.json()) as WorkspaceSettings + return parseWorkspaceSettings(await res.json()) } -export async function patchSettings( - workspaceId: string, - body: Record, -): Promise { +export async function patchSettings(workspaceId: string, body: WorkspaceSettingsPatch): Promise { const res = await apiFetch(`/api/v1/workspaces/${workspaceId}/settings`, { method: "PATCH", headers: { "Content-Type": "application/json" }, diff --git a/frontend/src/features/settings/settings.schema.ts b/frontend/src/features/settings/settings.schema.ts new file mode 100644 index 00000000..8648219c --- /dev/null +++ b/frontend/src/features/settings/settings.schema.ts @@ -0,0 +1,33 @@ +import { z } from "zod" + +import { DISPATCH_MODES, STORE_CONTENT_MODES } from "./message-dispatch-mode" +import type { MessageDispatchMode, StoreMessageContentSetting, WorkspaceSettings } from "./settings.types" + +const messageDispatchModeSchema = z.enum(DISPATCH_MODES) + +const storeMessageContentSchema = z.enum(STORE_CONTENT_MODES) + +export const workspaceSettingsSchema = z.object({ + owner_email: z.string().optional(), + pin_code: z.string().optional(), + message_dispatch_mode: messageDispatchModeSchema.optional(), + store_message_content: storeMessageContentSchema.optional(), +}) + +export function parseWorkspaceSettings(raw: unknown): WorkspaceSettings { + const parsed = workspaceSettingsSchema.safeParse(raw) + if (!parsed.success) { + throw new Error("Invalid settings response from server") + } + return parsed.data satisfies WorkspaceSettings +} + +export function parseMessageDispatchMode(raw: unknown): MessageDispatchMode { + const parsed = messageDispatchModeSchema.safeParse(raw) + return parsed.success ? parsed.data : "memory" +} + +export function parseStoreMessageContentSetting(raw: unknown): StoreMessageContentSetting { + const parsed = storeMessageContentSchema.safeParse(raw) + return parsed.success ? parsed.data : "false" +} diff --git a/frontend/src/features/settings/settings.types.ts b/frontend/src/features/settings/settings.types.ts index 36c905e2..a03bbbf6 100644 --- a/frontend/src/features/settings/settings.types.ts +++ b/frontend/src/features/settings/settings.types.ts @@ -1,10 +1,16 @@ +export type MessageDispatchMode = "memory" | "provider" + +export type StoreMessageContentSetting = "true" | "false" + export interface WorkspaceSettings { owner_email?: string pin_code?: string - data_retention?: "memory" | "both" | "providers" - [key: string]: string | undefined + message_dispatch_mode?: MessageDispatchMode + store_message_content?: StoreMessageContentSetting } +export type WorkspaceSettingsPatch = Partial> + export interface ApiKey { id: string workspace_id: string @@ -16,5 +22,9 @@ export interface ApiKey { expires_at?: string | null } -export type SettingsTab = "general" | "developer" | "team" | "retention" -export type RetentionMode = "memory" | "both" | "providers" +export type SettingsTab = "general" | "developer" | "team" | "dispatch" + +export interface MessageDispatchConfig { + mode: MessageDispatchMode + storeMessageContent: boolean +} diff --git a/go.mod b/go.mod index 7308a108..7a71afa8 100644 --- a/go.mod +++ b/go.mod @@ -36,7 +36,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect - github.com/weprodev/go-pkg v1.1.2 + github.com/weprodev/go-pkg v1.1.3 golang.org/x/net v0.56.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.38.0 // indirect diff --git a/go.sum b/go.sum index 51e3986c..7a5516e2 100644 --- a/go.sum +++ b/go.sum @@ -62,8 +62,8 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= -github.com/weprodev/go-pkg v1.1.2 h1:hJeur+RdOF2V3J/I4UVgU1MYzvS2yLrv5LwtgSFEVQ0= -github.com/weprodev/go-pkg v1.1.2/go.mod h1:Ss7+njNjzZfuXmqyxDtdP2UDMPSGKSWhcDwj4Ff5Ku4= +github.com/weprodev/go-pkg v1.1.3 h1:umSU1kOJHVwhA26ZZ2JTyzE8A1zLCgmhIP3KqwwD2h8= +github.com/weprodev/go-pkg v1.1.3/go.mod h1:9mI7PkAE/ZiDq16Mw9pc/xL3xmtcSUcbEKe727q2pGc= github.com/weprodev/wpd-gogate v1.2.1 h1:ZVXi3BkNuh/tUlcPzJGI+XpuolloD2f7E8RPsnIMHc0= github.com/weprodev/wpd-gogate v1.2.1/go.mod h1:Qj0XfD795g1ZW7X9pSaspBujiyhHrBA9ok41YhjrCdA= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= diff --git a/internal/app/config.go b/internal/app/config.go index b0c155ac..513651a1 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -2,6 +2,7 @@ package app import ( "fmt" + "log/slog" "os" "strings" @@ -67,15 +68,51 @@ func (c *Config) DefaultSMSProvider() string { return c.Providers.Defaults.SMS func (c *Config) DefaultPushProvider() string { return c.Providers.Defaults.Push } func (c *Config) DefaultChatProvider() string { return c.Providers.Defaults.Chat } -// LoadConfig loads configuration from a YAML file. -func LoadConfig(path string) (*Config, error) { - if path == "" { - path = "configs/local.yml" +const defaultServerPort = 10101 + +// EnvironmentName returns the runtime environment (config file, then APP_ENVIRONMENT, then local). +func (c *Config) EnvironmentName() string { + if c != nil && c.Environment != "" { + return c.Environment + } + if v := os.Getenv("APP_ENVIRONMENT"); v != "" { + return v + } + return "local" +} + +// ListenAddr returns the HTTP listen address for the gateway server. +func (c *Config) ListenAddr() string { + port := defaultServerPort + if c != nil && c.Server.Port > 0 { + port = c.Server.Port } + return fmt.Sprintf(":%d", port) +} - data, err := os.ReadFile(path) +// LogSummary logs a non-secret snapshot of loaded configuration at startup. +func (c *Config) LogSummary() { + if c == nil { + return + } + slog.Info("loaded configuration", + "environment", c.EnvironmentName(), + "listen_addr", c.ListenAddr(), + "email_provider", c.DefaultEmailProvider(), + "sms_provider", c.DefaultSMSProvider(), + "push_provider", c.DefaultPushProvider(), + "chat_provider", c.DefaultChatProvider(), + ) +} + +// LoadAppConfig loads configuration from YAML, applying env overrides. +// When configPath is empty, resolves via CONFIG_PATH, container paths, or configs/local.yml. +func LoadAppConfig(configPath string) (*Config, error) { + configPath = resolveConfigPath(configPath) + + data, err := os.ReadFile(configPath) if err != nil { - return nil, fmt.Errorf("failed to read config file %s: %w", path, err) + return nil, fmt.Errorf("error reading config file %s: %w", configPath, err) } cfg := &Config{ @@ -86,7 +123,7 @@ func LoadConfig(path string) (*Config, error) { } if err := yaml.Unmarshal(data, cfg); err != nil { - return nil, fmt.Errorf("failed to parse config file %s: %w", path, err) + return nil, fmt.Errorf("error parsing config file %s: %w", configPath, err) } cfg.applyEnvOverrides() @@ -95,6 +132,37 @@ func LoadConfig(path string) (*Config, error) { return cfg, nil } +// resolveConfigPath returns an explicit path or discovers one from env/container defaults. +func resolveConfigPath(configPath string) string { + if configPath != "" { + return configPath + } + if v := os.Getenv("CONFIG_PATH"); v != "" { + return v + } + + env := os.Getenv("APP_ENVIRONMENT") + if env == "" { + env = os.Getenv("ENVIRONMENT") + } + if env == "" { + env = "local" + } + + for _, candidate := range []string{ + fmt.Sprintf("/configs/%s.yml", env), + "/configs/local.yml", + "/configs/config.yml", + "configs/local.yml", + } { + if _, err := os.Stat(candidate); err == nil { + return candidate + } + } + + return "configs/local.yml" +} + // applyEnvOverrides applies environment variable overrides. func (c *Config) applyEnvOverrides() { if port := os.Getenv("PORT"); port != "" { diff --git a/internal/app/config_test.go b/internal/app/config_test.go new file mode 100644 index 00000000..a1e54590 --- /dev/null +++ b/internal/app/config_test.go @@ -0,0 +1,43 @@ +package app + +import "testing" + +func TestResolveConfigPath(t *testing.T) { + t.Setenv("CONFIG_PATH", "") + + if got := resolveConfigPath("configs/custom.yml"); got != "configs/custom.yml" { + t.Fatalf("explicit path: got %q", got) + } + + t.Setenv("CONFIG_PATH", "configs/from-env.yml") + if got := resolveConfigPath(""); got != "configs/from-env.yml" { + t.Fatalf("CONFIG_PATH: got %q", got) + } +} + +func TestConfig_ListenAddr(t *testing.T) { + cfg := &Config{Server: ServerConfig{Port: 8080}} + if got := cfg.ListenAddr(); got != ":8080" { + t.Fatalf("got %q", got) + } + + cfg.Server.Port = 0 + if got := cfg.ListenAddr(); got != ":10101" { + t.Fatalf("default port: got %q", got) + } +} + +func TestConfig_EnvironmentName(t *testing.T) { + t.Setenv("APP_ENVIRONMENT", "") + + cfg := &Config{Environment: "staging"} + if got := cfg.EnvironmentName(); got != "staging" { + t.Fatalf("from config: got %q", got) + } + + cfg.Environment = "" + t.Setenv("APP_ENVIRONMENT", "test") + if got := cfg.EnvironmentName(); got != "test" { + t.Fatalf("from env: got %q", got) + } +} diff --git a/internal/app/shutdown.go b/internal/app/shutdown.go new file mode 100644 index 00000000..8f7a7281 --- /dev/null +++ b/internal/app/shutdown.go @@ -0,0 +1,29 @@ +package app + +import ( + "context" + "fmt" +) + +// Shutdown stops the HTTP server and closes the database connection pool. +func (a *Application) Shutdown(ctx context.Context) error { + if a == nil { + return nil + } + + var shutdownErr error + if a.Echo != nil { + if err := a.Echo.Shutdown(ctx); err != nil { + shutdownErr = fmt.Errorf("echo shutdown: %w", err) + } + } + if a.PgClient != nil { + if err := a.PgClient.Close(); err != nil { + if shutdownErr != nil { + return fmt.Errorf("%v; pg close: %w", shutdownErr, err) + } + return fmt.Errorf("pg close: %w", err) + } + } + return shutdownErr +} diff --git a/internal/core/domain/dispatch_mode.go b/internal/core/domain/dispatch_mode.go index 01c4c627..7287c82c 100644 --- a/internal/core/domain/dispatch_mode.go +++ b/internal/core/domain/dispatch_mode.go @@ -1,32 +1,89 @@ package domain -// Workspace setting key for how outbound messages are handled relative to memory vs providers. -const SettingKeyMessageDispatchMode = "message_dispatch_mode" +import ( + "errors" + "fmt" + "strings" +) + +var ErrInvalidSettingValue = errors.New("invalid setting value") + +const ( + // SettingKeyMessageDispatchMode controls memory capture vs integration dispatch. + SettingKeyMessageDispatchMode = "message_dispatch_mode" + // SettingKeyStoreMessageContent controls whether message content is captured in the inbox. + SettingKeyStoreMessageContent = "store_message_content" +) -// MessageDispatchMode controls memory capture vs integration dispatch for each workspace. -// Provider configs live in DB (integrations); memory is always available as a capture path. +// MessageDispatchMode represents where the outbound message is routed. type MessageDispatchMode string const ( - // DispatchProviderOnly sends only through the connected integration; nothing is kept in process memory. - DispatchProviderOnly MessageDispatchMode = "provider_only" - // DispatchMemoryAndProvider stores in memory and sends through the integration. - DispatchMemoryAndProvider MessageDispatchMode = "memory_and_provider" - // DispatchMemoryOnly keeps messages in memory only; external providers are not called. - DispatchMemoryOnly MessageDispatchMode = "memory_only" + // DispatchProvider sends through the connected integration. + DispatchProvider MessageDispatchMode = "provider" + // DispatchMemory keeps messages in memory only; external providers are not called. + DispatchMemory MessageDispatchMode = "memory" ) -// DefaultMessageDispatchMode is used when workspace_settings has no value (matches portal “safe dev” default). +// DefaultMessageDispatchMode is used when workspace_settings has no value. func DefaultMessageDispatchMode() MessageDispatchMode { - return DispatchMemoryOnly + return DispatchMemory } -// ParseMessageDispatchMode returns the mode if s is valid. +// ParseMessageDispatchMode returns the mode if s is a valid dispatch mode string. func ParseMessageDispatchMode(s string) (MessageDispatchMode, bool) { - switch MessageDispatchMode(s) { - case DispatchProviderOnly, DispatchMemoryAndProvider, DispatchMemoryOnly: - return MessageDispatchMode(s), true + switch MessageDispatchMode(strings.TrimSpace(s)) { + case DispatchProvider, DispatchMemory: + return MessageDispatchMode(strings.TrimSpace(s)), true default: return "", false } } + +// MessageDispatchConfig holds dispatch routing and inbox content capture settings. +type MessageDispatchConfig struct { + Mode MessageDispatchMode + StoreMessageContent bool +} + +// RoutesViaProvider reports whether outbound traffic should use an integration. +func (c MessageDispatchConfig) RoutesViaProvider() bool { + return c.Mode == DispatchProvider +} + +// ShouldCaptureToInbox reports whether message content should be written to the inbox writer. +func (c MessageDispatchConfig) ShouldCaptureToInbox(effectiveMode MessageDispatchMode) bool { + return c.StoreMessageContent || effectiveMode == DispatchMemory +} + +// ResolveMessageDispatchConfig reads canonical workspace setting values. +func ResolveMessageDispatchConfig(modeVal, storeVal string) MessageDispatchConfig { + config := MessageDispatchConfig{ + Mode: DefaultMessageDispatchMode(), + StoreMessageContent: false, + } + if mode, ok := ParseMessageDispatchMode(modeVal); ok { + config.Mode = mode + } + if strings.TrimSpace(storeVal) == "true" { + config.StoreMessageContent = true + } + return config +} + +// ValidateWorkspaceSettingValue validates known workspace setting keys. +func ValidateWorkspaceSettingValue(key, value string) error { + switch key { + case SettingKeyMessageDispatchMode: + if _, ok := ParseMessageDispatchMode(value); !ok { + return fmt.Errorf("%w: message_dispatch_mode must be memory or provider", ErrInvalidSettingValue) + } + case SettingKeyStoreMessageContent: + switch strings.TrimSpace(value) { + case "true", "false": + default: + return fmt.Errorf("%w: store_message_content must be true or false", ErrInvalidSettingValue) + } + } + return nil +} diff --git a/internal/core/domain/dispatch_mode_test.go b/internal/core/domain/dispatch_mode_test.go new file mode 100644 index 00000000..3e0e8723 --- /dev/null +++ b/internal/core/domain/dispatch_mode_test.go @@ -0,0 +1,111 @@ +package domain + +import ( + "errors" + "testing" +) + +func TestDefaultMessageDispatchMode(t *testing.T) { + if got := DefaultMessageDispatchMode(); got != DispatchMemory { + t.Fatalf("got %q, want %q", got, DispatchMemory) + } +} + +func TestParseMessageDispatchMode(t *testing.T) { + tests := []struct { + value string + want MessageDispatchMode + ok bool + }{ + {"memory", DispatchMemory, true}, + {"provider", DispatchProvider, true}, + {" memory ", DispatchMemory, true}, + {"unknown", "", false}, + {"", "", false}, + } + + for _, tt := range tests { + t.Run(tt.value, func(t *testing.T) { + got, ok := ParseMessageDispatchMode(tt.value) + if ok != tt.ok { + t.Fatalf("ok = %v, want %v", ok, tt.ok) + } + if got != tt.want { + t.Fatalf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestResolveMessageDispatchConfig(t *testing.T) { + tests := []struct { + name string + modeVal string + storeVal string + want MessageDispatchConfig + }{ + {"defaults", "", "", MessageDispatchConfig{Mode: DispatchMemory, StoreMessageContent: false}}, + {"memory without store", "memory", "false", MessageDispatchConfig{Mode: DispatchMemory, StoreMessageContent: false}}, + {"provider with store", "provider", "true", MessageDispatchConfig{Mode: DispatchProvider, StoreMessageContent: true}}, + {"invalid mode uses default", "unknown", "true", MessageDispatchConfig{Mode: DispatchMemory, StoreMessageContent: true}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ResolveMessageDispatchConfig(tt.modeVal, tt.storeVal) + if got != tt.want { + t.Fatalf("got %+v, want %+v", got, tt.want) + } + }) + } +} + +func TestMessageDispatchConfig_ShouldCaptureToInbox(t *testing.T) { + tests := []struct { + name string + config MessageDispatchConfig + effectiveMode MessageDispatchMode + want bool + }{ + {"memory always captures", MessageDispatchConfig{Mode: DispatchMemory}, DispatchMemory, true}, + {"provider without store", MessageDispatchConfig{Mode: DispatchProvider}, DispatchProvider, false}, + {"provider with store", MessageDispatchConfig{Mode: DispatchProvider, StoreMessageContent: true}, DispatchProvider, true}, + {"provider fallback to memory", MessageDispatchConfig{Mode: DispatchProvider}, DispatchMemory, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.config.ShouldCaptureToInbox(tt.effectiveMode); got != tt.want { + t.Fatalf("ShouldCaptureToInbox() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestValidateWorkspaceSettingValue(t *testing.T) { + tests := []struct { + key string + value string + ok bool + }{ + {SettingKeyMessageDispatchMode, "memory", true}, + {SettingKeyMessageDispatchMode, "provider", true}, + {SettingKeyMessageDispatchMode, "invalid", false}, + {SettingKeyStoreMessageContent, "true", true}, + {SettingKeyStoreMessageContent, "false", true}, + {SettingKeyStoreMessageContent, "yes", false}, + {"owner_email", "any@example.com", true}, + } + + for _, tt := range tests { + t.Run(tt.key+"="+tt.value, func(t *testing.T) { + err := ValidateWorkspaceSettingValue(tt.key, tt.value) + if tt.ok && err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !tt.ok && !errors.Is(err, ErrInvalidSettingValue) { + t.Fatalf("expected ErrInvalidSettingValue, got %v", err) + } + }) + } +} diff --git a/internal/core/domain/integration.go b/internal/core/domain/integration.go index 2a0cc671..0e1dcc07 100644 --- a/internal/core/domain/integration.go +++ b/internal/core/domain/integration.go @@ -1,6 +1,12 @@ package domain -import "time" +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "time" +) // Integration lifecycle statuses (integrations.status column). const ( @@ -8,6 +14,12 @@ const ( IntegrationStatusDisconnected = "disconnected" ) +// ProviderNameMemory is the in-process capture provider; not valid for provider dispatch mode. +const ProviderNameMemory = "memory" + +// ErrProviderNotReady indicates the workspace integration cannot send via an external provider. +var ErrProviderNotReady = errors.New("provider not ready") + // Integration holds provider credentials for a workspace channel. type Integration struct { ID string `json:"id"` @@ -20,3 +32,23 @@ type Integration struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } + +// ValidateProviderIntegration checks that an integration can be used for provider dispatch. +func ValidateProviderIntegration(intg Integration) error { + if intg.ProviderName == ProviderNameMemory { + return fmt.Errorf("%w: memory provider is not allowed in provider dispatch mode", ErrProviderNotReady) + } + if intg.Status != IntegrationStatusConnected { + return fmt.Errorf("%w: integration status is %q", ErrProviderNotReady, intg.Status) + } + if len(strings.TrimSpace(intg.ProviderName)) == 0 { + return fmt.Errorf("%w: provider name is empty", ErrProviderNotReady) + } + if len(strings.TrimSpace(string(intg.Config))) == 0 { + return fmt.Errorf("%w: provider config is empty", ErrProviderNotReady) + } + if !json.Valid(intg.Config) { + return fmt.Errorf("%w: provider config is invalid JSON", ErrProviderNotReady) + } + return nil +} diff --git a/internal/core/domain/integration_test.go b/internal/core/domain/integration_test.go new file mode 100644 index 00000000..dc236815 --- /dev/null +++ b/internal/core/domain/integration_test.go @@ -0,0 +1,46 @@ +package domain + +import ( + "errors" + "testing" +) + +func TestValidateProviderIntegration(t *testing.T) { + valid := Integration{ + ID: "int-1", + ProviderName: "mailgun", + Config: []byte(`{"api_key":"key"}`), + Status: IntegrationStatusConnected, + } + + tests := []struct { + name string + intg Integration + wantErr bool + }{ + {"valid integration", valid, false}, + {"empty integration", Integration{}, true}, + {"memory provider", Integration{ProviderName: ProviderNameMemory, Config: []byte(`{}`), Status: IntegrationStatusConnected}, true}, + {"disconnected", Integration{ProviderName: "mailgun", Config: []byte(`{}`), Status: IntegrationStatusDisconnected}, true}, + {"empty config", Integration{ProviderName: "mailgun", Config: []byte(` `), Status: IntegrationStatusConnected}, true}, + {"invalid json", Integration{ProviderName: "mailgun", Config: []byte(`{`), Status: IntegrationStatusConnected}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateProviderIntegration(tt.intg) + if tt.wantErr { + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, ErrProviderNotReady) { + t.Fatalf("expected ErrProviderNotReady, got %v", err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} diff --git a/internal/core/domain/message_request_log.go b/internal/core/domain/message_request_log.go index 81709024..c156ba9f 100644 --- a/internal/core/domain/message_request_log.go +++ b/internal/core/domain/message_request_log.go @@ -4,16 +4,17 @@ import "time" // MessageRequestLog records a gateway API call for monitoring. type MessageRequestLog struct { - ID string `json:"id"` - WorkspaceID string `json:"workspace_id"` - APIKeyID string `json:"api_key_id,omitempty"` - ChannelType string `json:"channel_type"` - HTTPMethod string `json:"http_method"` - StatusCode int `json:"status_code"` - Endpoint string `json:"endpoint"` - ProviderName string `json:"provider_name,omitempty"` - RequestID string `json:"request_id,omitempty"` - DurationMs int `json:"duration_ms,omitempty"` - ErrorMessage string `json:"error_message,omitempty"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + APIKeyID string `json:"api_key_id,omitempty"` + ChannelType string `json:"channel_type"` + HTTPMethod string `json:"http_method"` + StatusCode int `json:"status_code"` + Endpoint string `json:"endpoint"` + ProviderName string `json:"provider_name,omitempty"` + RequestID string `json:"request_id,omitempty"` + DurationMs int `json:"duration_ms,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + + CreatedAt time.Time `json:"created_at"` } diff --git a/internal/core/service/gateway_service.go b/internal/core/service/gateway_service.go index 55c8afc7..670ae56b 100644 --- a/internal/core/service/gateway_service.go +++ b/internal/core/service/gateway_service.go @@ -4,6 +4,7 @@ package service import ( "context" + "errors" "fmt" "log/slog" @@ -18,10 +19,6 @@ const ( channelSMS = "sms" channelPush = "push" channelChat = "chat" - - // memoryProviderName is the sentinel name stored in the integrations table - // when a workspace uses memory dispatch. Checked here to skip DB lookup. - memoryProviderName = "memory" ) // GatewayService orchestrates message dispatch for a single workspace. @@ -48,7 +45,7 @@ type GatewayService struct { // NewGatewayService constructs a GatewayService. // // inbox is the in-process capture store (implements port.InboxWriter). -// Pass nil to disable memory capture — memory_only dispatch will then error. +// Pass nil to disable memory capture — memory dispatch will then error when inbox is required. func NewGatewayService( integrations port.IntegrationRepository, templates port.TemplateRepository, @@ -71,11 +68,14 @@ func NewGatewayService( // SendEmail dispatches an email for workspaceID according to its dispatch mode. func (s *GatewayService) SendEmail(ctx context.Context, workspaceID string, email contracts.Email) (*contracts.SendResult, error) { - mode := s.resolveDispatchMode(ctx, workspaceID) - return s.dispatch(ctx, workspaceID, mode, channelEmail, + config, err := s.resolveDispatchConfig(ctx, workspaceID) + if err != nil { + return nil, err + } + return s.dispatch(ctx, workspaceID, config, channelEmail, func() (*contracts.SendResult, error) { return s.writeEmailToInbox(ctx, workspaceID, email) }, - func(sendCtx context.Context, intg *domain.Integration) (*contracts.SendResult, error) { - sender, err := resolveEmailSender(s.emailCache, intg) + func(sendCtx context.Context, intg domain.Integration) (*contracts.SendResult, error) { + sender, err := resolveEmailSender(s.emailCache, &intg) if err != nil { return nil, err } @@ -86,11 +86,14 @@ func (s *GatewayService) SendEmail(ctx context.Context, workspaceID string, emai // SendSMS dispatches an SMS for workspaceID according to its dispatch mode. func (s *GatewayService) SendSMS(ctx context.Context, workspaceID string, sms contracts.SMS) (*contracts.SendResult, error) { - mode := s.resolveDispatchMode(ctx, workspaceID) - return s.dispatch(ctx, workspaceID, mode, channelSMS, + config, err := s.resolveDispatchConfig(ctx, workspaceID) + if err != nil { + return nil, err + } + return s.dispatch(ctx, workspaceID, config, channelSMS, func() (*contracts.SendResult, error) { return s.writeSMSToInbox(ctx, workspaceID, sms) }, - func(sendCtx context.Context, intg *domain.Integration) (*contracts.SendResult, error) { - sender, err := resolveSMSSender(s.smsCache, intg) + func(sendCtx context.Context, intg domain.Integration) (*contracts.SendResult, error) { + sender, err := resolveSMSSender(s.smsCache, &intg) if err != nil { return nil, err } @@ -101,11 +104,14 @@ func (s *GatewayService) SendSMS(ctx context.Context, workspaceID string, sms co // SendPush dispatches a push notification for workspaceID according to its dispatch mode. func (s *GatewayService) SendPush(ctx context.Context, workspaceID string, push contracts.PushNotification) (*contracts.SendResult, error) { - mode := s.resolveDispatchMode(ctx, workspaceID) - return s.dispatch(ctx, workspaceID, mode, channelPush, + config, err := s.resolveDispatchConfig(ctx, workspaceID) + if err != nil { + return nil, err + } + return s.dispatch(ctx, workspaceID, config, channelPush, func() (*contracts.SendResult, error) { return s.writePushToInbox(ctx, workspaceID, push) }, - func(sendCtx context.Context, intg *domain.Integration) (*contracts.SendResult, error) { - sender, err := resolvePushSender(s.pushCache, intg) + func(sendCtx context.Context, intg domain.Integration) (*contracts.SendResult, error) { + sender, err := resolvePushSender(s.pushCache, &intg) if err != nil { return nil, err } @@ -116,11 +122,14 @@ func (s *GatewayService) SendPush(ctx context.Context, workspaceID string, push // SendChat dispatches a chat message for workspaceID according to its dispatch mode. func (s *GatewayService) SendChat(ctx context.Context, workspaceID string, chat contracts.ChatMessage) (*contracts.SendResult, error) { - mode := s.resolveDispatchMode(ctx, workspaceID) - return s.dispatch(ctx, workspaceID, mode, channelChat, + config, err := s.resolveDispatchConfig(ctx, workspaceID) + if err != nil { + return nil, err + } + return s.dispatch(ctx, workspaceID, config, channelChat, func() (*contracts.SendResult, error) { return s.writeChatToInbox(ctx, workspaceID, chat) }, - func(sendCtx context.Context, intg *domain.Integration) (*contracts.SendResult, error) { - sender, err := resolveChatSender(s.chatCache, intg) + func(sendCtx context.Context, intg domain.Integration) (*contracts.SendResult, error) { + sender, err := resolveChatSender(s.chatCache, &intg) if err != nil { return nil, err } @@ -133,126 +142,135 @@ func (s *GatewayService) SendChat(ctx context.Context, workspaceID string, chat // It applies the workspace dispatch mode and calls the appropriate fn(s). // // - writeToInbox: captures to in-process RAM (always available) -// - sendViaProvider: instantiates provider from DB integration + sends +// - sendViaProvider: resolves provider from integration and sends func (s *GatewayService) dispatch( ctx context.Context, workspaceID string, - mode domain.MessageDispatchMode, + config domain.MessageDispatchConfig, channel string, writeToInbox func() (*contracts.SendResult, error), - sendViaProvider func(context.Context, *domain.Integration) (*contracts.SendResult, error), + sendViaProvider func(context.Context, domain.Integration) (*contracts.SendResult, error), ) (*contracts.SendResult, error) { - slog.InfoContext(ctx, "dispatching message", "workspace_id", workspaceID, "dispatch_mode", mode, "channel", channel) - switch mode { - case domain.DispatchMemoryOnly: - r, err := writeToInbox() - if err != nil { - slog.ErrorContext(ctx, "inbox write failed", "error", err, "workspace_id", workspaceID, "channel", channel) - return nil, err - } - attachMeta(r, mode, channel, "", memoryProviderName) - slog.InfoContext(ctx, "message dispatched via memory only", "workspace_id", workspaceID, "channel", channel, "message_id", r.ID) - return r, nil + slog.InfoContext(ctx, "dispatching message", "dispatch_mode", config.Mode, "store_content", config.StoreMessageContent) - case domain.DispatchProviderOnly: - intg, err := s.activeIntegration(ctx, workspaceID, channel) - if err != nil { - slog.ErrorContext(ctx, "provider lookup failed", "error", err, "workspace_id", workspaceID, "channel", channel) - return nil, err - } - // If the stored integration IS the memory provider, fall through to inbox. - if intg.ProviderName == memoryProviderName { - r, err := writeToInbox() - if err != nil { - slog.ErrorContext(ctx, "inbox write failed (fallback)", "error", err, "workspace_id", workspaceID, "channel", channel) - return nil, err - } - attachMeta(r, mode, channel, intg.ID, intg.ProviderName) - slog.InfoContext(ctx, "message dispatched via memory fallback", "workspace_id", workspaceID, "channel", channel, "message_id", r.ID) - return r, nil - } - providerCtx := applogger.WithProvider(ctx, intg.ProviderName) - r, err := sendViaProvider(providerCtx, intg) - if err != nil { - slog.ErrorContext(ctx, "provider send failed", "error", err, "workspace_id", workspaceID, "channel", channel, "provider", intg.ProviderName) - return nil, err - } - attachMeta(r, mode, channel, intg.ID, intg.ProviderName) - slog.InfoContext(ctx, "message dispatched via provider only", "workspace_id", workspaceID, "channel", channel, "provider", intg.ProviderName, "message_id", r.ID) - return r, nil + var inboxResult *contracts.SendResult + var provResult *contracts.SendResult + var err error + var integrationID, providerName string + effectiveMode := config.Mode + providerName = domain.ProviderNameMemory - case domain.DispatchMemoryAndProvider: - intg, err := s.activeIntegration(ctx, workspaceID, channel) - if err != nil { - slog.ErrorContext(ctx, "provider lookup failed", "error", err, "workspace_id", workspaceID, "channel", channel) - return nil, err - } - // If the integration is already memory, a single write is enough. - if intg.ProviderName == memoryProviderName { - r, err := writeToInbox() - if err != nil { - slog.ErrorContext(ctx, "inbox write failed (fallback)", "error", err, "workspace_id", workspaceID, "channel", channel) - return nil, err - } - attachMeta(r, mode, channel, intg.ID, intg.ProviderName) - slog.InfoContext(ctx, "message dispatched via memory fallback", "workspace_id", workspaceID, "channel", channel, "message_id", r.ID) - return r, nil + if config.RoutesViaProvider() { + intg, errLookup := s.requireProviderIntegration(ctx, workspaceID, channel) + if errLookup != nil { + return attachMeta(nil, effectiveMode, channel, integrationID, providerName), errLookup } - // Both paths: capture to inbox first (non-fatal), then send via provider. - inboxResult, err := writeToInbox() + + integrationID = intg.ID + providerName = intg.ProviderName + + providerCtx := applogger.WithProvider(ctx, providerName) + provResult, err = sendViaProvider(providerCtx, *intg) if err != nil { - slog.WarnContext(ctx, "inbox write failed (non-fatal)", "error", err, "workspace_id", workspaceID, "channel", channel) + slog.WarnContext(providerCtx, "provider dispatch failed", "error", err, "integration_id", integrationID) + return attachMeta(nil, effectiveMode, channel, integrationID, providerName), err } - providerCtx := applogger.WithProvider(ctx, intg.ProviderName) - provResult, err := sendViaProvider(providerCtx, intg) + slog.InfoContext(providerCtx, "message dispatched via provider", "message_id", provResult.ID) + } + + if config.ShouldCaptureToInbox(effectiveMode) { + inboxResult, err = writeToInbox() if err != nil { - slog.ErrorContext(ctx, "provider send failed", "error", err, "workspace_id", workspaceID, "channel", channel, "provider", intg.ProviderName) - return nil, err - } - if inboxResult != nil { - if provResult.Meta == nil { - provResult.Meta = make(map[string]string) + slog.ErrorContext(ctx, "inbox write failed", "error", err, "dispatch_mode", effectiveMode, "store_content", config.StoreMessageContent) + if effectiveMode == domain.DispatchMemory { + return attachMeta(nil, effectiveMode, channel, integrationID, providerName), err } - provResult.Meta["inbox_message_id"] = inboxResult.ID + slog.WarnContext(ctx, "inbox capture failed after provider dispatch", "dispatch_mode", effectiveMode) + } else { + slog.InfoContext(ctx, "message captured in inbox", "message_id", inboxResult.ID, "store_content", config.StoreMessageContent) } - attachMeta(provResult, mode, channel, intg.ID, intg.ProviderName) - slog.InfoContext(ctx, "message dispatched via memory and provider", "workspace_id", workspaceID, "channel", channel, "provider", intg.ProviderName, "message_id", provResult.ID) - return provResult, nil - - default: - // Undefined modes fall back to memory_only (safe default). - slog.WarnContext(ctx, "unknown dispatch mode, falling back to memory only", "workspace_id", workspaceID, "mode", mode) - r, err := writeToInbox() - if err != nil { - slog.ErrorContext(ctx, "inbox write failed (fallback)", "error", err, "workspace_id", workspaceID, "channel", channel) - return nil, err + } + + finalResult := provResult + if finalResult == nil { + finalResult = inboxResult + } else if inboxResult != nil { + if finalResult.Meta == nil { + finalResult.Meta = make(map[string]string) } - attachMeta(r, domain.DispatchMemoryOnly, channel, "", memoryProviderName) - return r, nil + finalResult.Meta["inbox_message_id"] = inboxResult.ID } + + return attachMeta(finalResult, effectiveMode, channel, integrationID, providerName), nil } -// resolveDispatchMode reads the workspace setting, defaulting gracefully. -func (s *GatewayService) resolveDispatchMode(ctx context.Context, workspaceID string) domain.MessageDispatchMode { +// ResolveDispatchConfig reads the workspace setting for outbound dispatch behavior. +func (s *GatewayService) ResolveDispatchConfig(ctx context.Context, workspaceID string) (domain.MessageDispatchConfig, error) { + return s.resolveDispatchConfig(ctx, workspaceID) +} + +// resolveDispatchConfig reads message_dispatch_mode and store_message_content from workspace settings. +func (s *GatewayService) resolveDispatchConfig(ctx context.Context, workspaceID string) (domain.MessageDispatchConfig, error) { if s.settings == nil { - return domain.DefaultMessageDispatchMode() + return domain.MessageDispatchConfig{ + Mode: domain.DefaultMessageDispatchMode(), + StoreMessageContent: false, + }, nil } - v, err := s.settings.Get(ctx, workspaceID, domain.SettingKeyMessageDispatchMode) - if err != nil || v == "" { - return domain.DefaultMessageDispatchMode() + + modeVal, err := s.settings.Get(ctx, workspaceID, domain.SettingKeyMessageDispatchMode) + if err != nil && !errors.Is(err, port.ErrNotFound) { + return domain.MessageDispatchConfig{}, fmt.Errorf("failed to lookup message dispatch mode: %w", err) } - if m, ok := domain.ParseMessageDispatchMode(v); ok { - return m + + storeVal, err := s.settings.Get(ctx, workspaceID, domain.SettingKeyStoreMessageContent) + if err != nil && !errors.Is(err, port.ErrNotFound) { + return domain.MessageDispatchConfig{}, fmt.Errorf("failed to lookup store message content setting: %w", err) } - return domain.DefaultMessageDispatchMode() + + return domain.ResolveMessageDispatchConfig(modeVal, storeVal), nil } -// activeIntegration fetches the active (connected) integration for a workspace+channel. -func (s *GatewayService) activeIntegration(ctx context.Context, workspaceID, channel string) (*domain.Integration, error) { +// requireProviderIntegration loads and validates the workspace integration for provider dispatch. +func (s *GatewayService) requireProviderIntegration(ctx context.Context, workspaceID, channel string) (*domain.Integration, error) { + if s.integrations == nil { + err := fmt.Errorf("integration repository not configured: %w", port.ErrInternal) + slog.ErrorContext(ctx, "provider dispatch blocked", + "reason", "repository_unavailable", + "channel", channel, + "error", err, + ) + return nil, err + } + intg, err := s.integrations.GetActiveByWorkspaceAndChannel(ctx, workspaceID, channel) if err != nil { - return nil, fmt.Errorf("no active %s integration for workspace %s: %w", channel, workspaceID, err) + reason := "lookup_failed" + log := slog.ErrorContext + if errors.Is(err, port.ErrNotFound) { + reason = "no_active_integration" + log = slog.WarnContext + } + log(ctx, "provider dispatch blocked", + "reason", reason, + "channel", channel, + "error", err, + ) + return nil, fmt.Errorf("provider integration for %s channel: %w", channel, err) } + + if err := domain.ValidateProviderIntegration(*intg); err != nil { + slog.WarnContext(ctx, "provider dispatch blocked", + "reason", "integration_not_ready", + "channel", channel, + "integration_id", intg.ID, + "provider", intg.ProviderName, + "status", intg.Status, + "error", err, + ) + return nil, err + } + return intg, nil } @@ -302,16 +320,17 @@ func (s *GatewayService) writeChatToInbox(ctx context.Context, workspaceID strin return &contracts.SendResult{ID: id, StatusCode: 200, Message: "captured in memory"}, nil } -// attachMeta stamps standard dispatch metadata onto a result without allocating -// if Meta is already populated. -func attachMeta(r *contracts.SendResult, mode domain.MessageDispatchMode, channel, integrationID, providerName string) { +// attachMeta stamps standard dispatch metadata onto r. When r is nil (error paths +// with no provider payload), it allocates a minimal SendResult so callers can +// still read provider_name via contracts.ProviderNameFromResult. +func attachMeta(r *contracts.SendResult, effectiveMode domain.MessageDispatchMode, channel, integrationID, providerName string) *contracts.SendResult { if r == nil { - return + r = &contracts.SendResult{} } if r.Meta == nil { r.Meta = make(map[string]string, 4) } - r.Meta["dispatch_mode"] = string(mode) + r.Meta["dispatch_mode"] = string(effectiveMode) r.Meta["channel"] = channel if integrationID != "" { r.Meta["integration_id"] = integrationID @@ -319,12 +338,13 @@ func attachMeta(r *contracts.SendResult, mode domain.MessageDispatchMode, channe if providerName != "" { r.Meta["provider_name"] = providerName } + return r } -// RecordLog persists a MessageRequestLog entry. Failures are logged with slog. -func (s *GatewayService) RecordLog(ctx context.Context, entry *domain.MessageRequestLog) error { +// RecordLog persists a MessageRequestLog entry. Repository errors are logged at the infra layer. +func (s *GatewayService) RecordLog(ctx context.Context, entry domain.MessageRequestLog) error { if s.logs == nil || entry.WorkspaceID == "" { return nil } - return s.logs.Create(ctx, entry) + return s.logs.Create(ctx, &entry) } diff --git a/internal/core/service/gateway_service_test.go b/internal/core/service/gateway_service_test.go index b41bc8a7..24dac19e 100644 --- a/internal/core/service/gateway_service_test.go +++ b/internal/core/service/gateway_service_test.go @@ -2,14 +2,31 @@ package service import ( "context" + "errors" "testing" "time" "github.com/weprodev/wpd-message-gateway/internal/core/domain" "github.com/weprodev/wpd-message-gateway/internal/core/port" "github.com/weprodev/wpd-message-gateway/pkg/contracts" + "github.com/weprodev/wpd-message-gateway/pkg/provider/mailgun" + "github.com/weprodev/wpd-message-gateway/pkg/registry" ) +type stubMailgunSender struct{} + +func (s *stubMailgunSender) Send(ctx context.Context, email contracts.Email) (*contracts.SendResult, error) { + return &contracts.SendResult{ID: "mg-1", StatusCode: 200, Message: "sent"}, nil +} + +func (s *stubMailgunSender) Name() string { return mailgun.ProviderName } + +func init() { + registry.RegisterEmailProvider(mailgun.ProviderName, func(cfg registry.EmailConfig) (contracts.EmailSender, error) { + return &stubMailgunSender{}, nil + }) +} + type stubSettingsRepo struct { values map[string]string } @@ -92,6 +109,66 @@ func (s *stubInbox) WriteChat(ctx context.Context, workspaceID string, chat cont return "inbox-chat-1", nil } +func TestGatewayService_ResolveDispatchConfig_defaults(t *testing.T) { + svc := NewGatewayService(nil, nil, &stubSettingsRepo{}, nil, nil) + + config, _ := svc.ResolveDispatchConfig(context.Background(), "ws-1") + if config.Mode != domain.DispatchMemory { + t.Fatalf("mode: %v", config.Mode) + } + if config.StoreMessageContent { + t.Fatal("expected store_message_content false by default") + } +} + +func TestGatewayService_ResolveDispatchConfig_fromSettings(t *testing.T) { + settings := &stubSettingsRepo{values: map[string]string{ + domain.SettingKeyMessageDispatchMode: string(domain.DispatchProvider), + domain.SettingKeyStoreMessageContent: "true", + }} + svc := NewGatewayService(nil, nil, settings, nil, nil) + + config, _ := svc.ResolveDispatchConfig(context.Background(), "ws-1") + if config.Mode != domain.DispatchProvider { + t.Fatalf("mode: %v", config.Mode) + } + if !config.StoreMessageContent { + t.Fatal("expected store_message_content true") + } +} + +func TestGatewayService_SendEmail_memoryWithStoreContent(t *testing.T) { + settings := &stubSettingsRepo{values: map[string]string{ + domain.SettingKeyStoreMessageContent: "true", + }} + inbox := &stubInbox{emailID: "stored-1"} + svc := NewGatewayService(&stubIntegrationRepo{}, nil, settings, inbox, nil) + + res, err := svc.SendEmail(context.Background(), "ws-1", contracts.Email{ + To: []string{"a@b.com"}, Subject: "s", HTML: "h", + }) + if err != nil { + t.Fatalf("SendEmail: %v", err) + } + if res.ID != "stored-1" { + t.Fatalf("got ID %q", res.ID) + } +} + +func TestGatewayService_SendEmail_providerMissingIntegration(t *testing.T) { + settings := &stubSettingsRepo{values: map[string]string{ + domain.SettingKeyMessageDispatchMode: string(domain.DispatchProvider), + }} + svc := NewGatewayService(&stubIntegrationRepo{err: errors.New("not found")}, nil, settings, nil, nil) + + _, err := svc.SendEmail(context.Background(), "ws-1", contracts.Email{ + To: []string{"a@b.com"}, Subject: "s", HTML: "h", + }) + if err == nil { + t.Fatal("expected provider lookup error") + } +} + func TestGatewayService_SendEmail_memoryOnly(t *testing.T) { inbox := &stubInbox{emailID: "mem-1"} svc := NewGatewayService(&stubIntegrationRepo{}, nil, nil, inbox, nil) @@ -107,13 +184,13 @@ func TestGatewayService_SendEmail_memoryOnly(t *testing.T) { if res.ID != "mem-1" { t.Fatalf("got ID %q", res.ID) } - if res.Meta["dispatch_mode"] != string(domain.DispatchMemoryOnly) { + if res.Meta["dispatch_mode"] != string(domain.DispatchMemory) { t.Fatalf("dispatch_mode: %v", res.Meta["dispatch_mode"]) } if res.Meta["channel"] != "email" { t.Fatalf("channel: %v", res.Meta["channel"]) } - if res.Meta["provider_name"] != memoryProviderName { + if res.Meta["provider_name"] != domain.ProviderNameMemory { t.Fatalf("provider_name: %v", res.Meta["provider_name"]) } } @@ -133,17 +210,114 @@ func TestGatewayService_SendEmail_providerOnly_memoryIntegration(t *testing.T) { ID: "int-1", WorkspaceID: "ws-1", ChannelType: "email", - ProviderName: memoryProviderName, + ProviderName: domain.ProviderNameMemory, Config: []byte(`{}`), Status: domain.IntegrationStatusConnected, CreatedAt: ts, UpdatedAt: ts, } settings := &stubSettingsRepo{values: map[string]string{ - domain.SettingKeyMessageDispatchMode: string(domain.DispatchProviderOnly), + domain.SettingKeyMessageDispatchMode: string(domain.DispatchProvider), }} - inbox := &stubInbox{emailID: "cap-1"} - svc := NewGatewayService(&stubIntegrationRepo{active: intg}, nil, settings, inbox, nil) + svc := NewGatewayService(&stubIntegrationRepo{active: intg}, nil, settings, nil, nil) + + _, err := svc.SendEmail(context.Background(), "ws-1", contracts.Email{ + To: []string{"a@b.com"}, Subject: "s", HTML: "h", + }) + if err == nil { + t.Fatal("expected error when provider mode uses memory integration") + } + if !errors.Is(err, domain.ErrProviderNotReady) { + t.Fatalf("expected ErrProviderNotReady, got %v", err) + } +} + +func TestGatewayService_SendEmail_providerOnly_missingIntegration(t *testing.T) { + settings := &stubSettingsRepo{values: map[string]string{ + domain.SettingKeyMessageDispatchMode: string(domain.DispatchProvider), + }} + svc := NewGatewayService(&stubIntegrationRepo{err: port.ErrNotFound}, nil, settings, nil, nil) + + _, err := svc.SendEmail(context.Background(), "ws-1", contracts.Email{ + To: []string{"a@b.com"}, Subject: "s", HTML: "h", + }) + if err == nil { + t.Fatal("expected error when no active integration") + } + if !errors.Is(err, port.ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} + +func TestGatewayService_SendEmail_providerOnly_emptyConfig(t *testing.T) { + ts := time.Now() + intg := &domain.Integration{ + ID: "int-1", + WorkspaceID: "ws-1", + ChannelType: "email", + ProviderName: mailgun.ProviderName, + Config: []byte(` `), + Status: domain.IntegrationStatusConnected, + CreatedAt: ts, + UpdatedAt: ts, + } + settings := &stubSettingsRepo{values: map[string]string{ + domain.SettingKeyMessageDispatchMode: string(domain.DispatchProvider), + }} + svc := NewGatewayService(&stubIntegrationRepo{active: intg}, nil, settings, nil, nil) + + _, err := svc.SendEmail(context.Background(), "ws-1", contracts.Email{ + To: []string{"a@b.com"}, Subject: "s", HTML: "h", + }) + if err == nil { + t.Fatal("expected error when provider config is empty") + } + if !errors.Is(err, domain.ErrProviderNotReady) { + t.Fatalf("expected ErrProviderNotReady, got %v", err) + } +} + +func TestGatewayService_SendEmail_providerOnly_unknownProvider(t *testing.T) { + ts := time.Now() + intg := &domain.Integration{ + ID: "int-1", + WorkspaceID: "ws-1", + ChannelType: "email", + ProviderName: "unknown-provider", + Config: []byte(`{"api_key":"key"}`), + Status: domain.IntegrationStatusConnected, + CreatedAt: ts, + UpdatedAt: ts, + } + settings := &stubSettingsRepo{values: map[string]string{ + domain.SettingKeyMessageDispatchMode: string(domain.DispatchProvider), + }} + svc := NewGatewayService(&stubIntegrationRepo{active: intg}, nil, settings, nil, nil) + + _, err := svc.SendEmail(context.Background(), "ws-1", contracts.Email{ + To: []string{"a@b.com"}, Subject: "s", HTML: "h", + }) + if err == nil { + t.Fatal("expected error when provider factory is not registered") + } +} + +func TestGatewayService_SendEmail_providerOnly_mailgunIntegration(t *testing.T) { + ts := time.Now() + intg := &domain.Integration{ + ID: "int-mg", + WorkspaceID: "ws-1", + ChannelType: "email", + ProviderName: mailgun.ProviderName, + Config: []byte(`{"api_key":"key","domain":"mg.example.com","from_email":"noreply@mg.example.com"}`), + Status: domain.IntegrationStatusConnected, + CreatedAt: ts, + UpdatedAt: ts, + } + settings := &stubSettingsRepo{values: map[string]string{ + domain.SettingKeyMessageDispatchMode: string(domain.DispatchProvider), + }} + svc := NewGatewayService(&stubIntegrationRepo{active: intg}, nil, settings, nil, nil) res, err := svc.SendEmail(context.Background(), "ws-1", contracts.Email{ To: []string{"a@b.com"}, Subject: "s", HTML: "h", @@ -151,13 +325,57 @@ func TestGatewayService_SendEmail_providerOnly_memoryIntegration(t *testing.T) { if err != nil { t.Fatalf("SendEmail: %v", err) } - if res.ID != "cap-1" { + if res.ID != "mg-1" { t.Fatalf("got ID %q", res.ID) } - if res.Meta["dispatch_mode"] != string(domain.DispatchProviderOnly) { - t.Fatalf("dispatch_mode: %v", res.Meta["dispatch_mode"]) + if res.Meta["provider_name"] != mailgun.ProviderName { + t.Fatalf("provider_name: %v", res.Meta["provider_name"]) + } +} + +func TestGatewayService_SendEmail_providerDatabase_mailgunIntegration(t *testing.T) { + ts := time.Now() + intg := &domain.Integration{ + ID: "int-mg", + WorkspaceID: "ws-1", + ChannelType: "email", + ProviderName: mailgun.ProviderName, + Config: []byte(`{"api_key":"key","domain":"mg.example.com","from_email":"noreply@mg.example.com"}`), + Status: domain.IntegrationStatusConnected, + CreatedAt: ts, + UpdatedAt: ts, + } + settings := &stubSettingsRepo{values: map[string]string{ + domain.SettingKeyMessageDispatchMode: string(domain.DispatchProvider), + domain.SettingKeyStoreMessageContent: "true", + }} + inbox := &stubInbox{emailID: "inbox-1"} + svc := NewGatewayService(&stubIntegrationRepo{active: intg}, nil, settings, inbox, nil) + + res, err := svc.SendEmail(context.Background(), "ws-1", contracts.Email{ + To: []string{"a@b.com"}, Subject: "s", HTML: "h", + }) + if err != nil { + t.Fatalf("SendEmail: %v", err) + } + if res.Meta["provider_name"] != mailgun.ProviderName { + t.Fatalf("provider_name: %v", res.Meta["provider_name"]) + } + if res.Meta["inbox_message_id"] != "inbox-1" { + t.Fatalf("inbox_message_id: %v", res.Meta["inbox_message_id"]) + } +} + +func TestGatewayService_dispatch_errorResult_stampsProviderMeta(t *testing.T) { + svc := NewGatewayService(nil, nil, nil, nil, nil) + + res, err := svc.SendEmail(context.Background(), "ws-1", contracts.Email{ + To: []string{"a@b.com"}, Subject: "s", HTML: "h", + }) + if err == nil { + t.Fatal("expected inbox error") } - if res.Meta["integration_id"] != "int-1" { - t.Fatalf("integration_id: %v", res.Meta["integration_id"]) + if contracts.ProviderNameFromResult(res) != domain.ProviderNameMemory { + t.Fatalf("provider_name: %q", contracts.ProviderNameFromResult(res)) } } diff --git a/internal/core/service/portal_service.go b/internal/core/service/portal_service.go index 24403d0e..e7584701 100644 --- a/internal/core/service/portal_service.go +++ b/internal/core/service/portal_service.go @@ -510,11 +510,45 @@ func (s *PortalService) GetSettings(ctx context.Context, workspaceID string) (ma } func (s *PortalService) PatchSettings(ctx context.Context, workspaceID string, kv map[string]string) error { + keys := make([]string, 0, len(kv)) + for k := range kv { + keys = append(keys, k) + } + slog.InfoContext(ctx, "patching workspace settings", "workspace_id", workspaceID, "keys", keys) + for k, v := range kv { + if err := domain.ValidateWorkspaceSettingValue(k, v); err != nil { + slog.WarnContext(ctx, "settings patch rejected: invalid value", + "workspace_id", workspaceID, + "key", k, + "error", err, + ) + return fmt.Errorf("%w: %w", port.ErrInvalidInput, err) + } + + if k == domain.SettingKeyMessageDispatchMode && v == string(domain.DispatchProvider) { + list, err := s.integrations.ListByWorkspace(ctx, workspaceID) + if err != nil { + return fmt.Errorf("failed to check integrations for provider enablement: %w", err) + } + hasConnected := false + for _, intg := range list { + if intg.ProviderName != domain.ProviderNameMemory && intg.Status == domain.IntegrationStatusConnected { + hasConnected = true + break + } + } + if !hasConnected { + return fmt.Errorf("%w: first configure a provider then you can enable it", port.ErrInvalidInput) + } + } + if err := s.settings.Set(ctx, workspaceID, k, v); err != nil { return err } } + + slog.InfoContext(ctx, "workspace settings patched successfully", "workspace_id", workspaceID, "keys", keys) return nil } diff --git a/internal/core/service/portal_service_test.go b/internal/core/service/portal_service_test.go index f7df2f58..01841862 100644 --- a/internal/core/service/portal_service_test.go +++ b/internal/core/service/portal_service_test.go @@ -110,3 +110,99 @@ func TestAuthService_Login_masksInfrastructureErrors(t *testing.T) { t.Fatalf("expected invalid email or password error, got %v", err) } } + +type fakeSettingsRepo struct { + values map[string]string +} + +func (f *fakeSettingsRepo) Get(ctx context.Context, workspaceID, key string) (string, error) { + return f.values[key], nil +} + +func (f *fakeSettingsRepo) Set(ctx context.Context, workspaceID, key, value string) error { + if f.values == nil { + f.values = make(map[string]string) + } + f.values[key] = value + return nil +} + +func (f *fakeSettingsRepo) GetAll(ctx context.Context, workspaceID string) (map[string]string, error) { + return f.values, nil +} + +type fakeIntegrationRepo struct { + integrations []domain.Integration +} + +func (f *fakeIntegrationRepo) Create(ctx context.Context, intg *domain.Integration) error { return nil } +func (f *fakeIntegrationRepo) ListByWorkspace(ctx context.Context, workspaceID string) ([]domain.Integration, error) { + return f.integrations, nil +} +func (f *fakeIntegrationRepo) Upsert(ctx context.Context, intg *domain.Integration) error { return nil } +func (f *fakeIntegrationRepo) GetByID(ctx context.Context, id string) (*domain.Integration, error) { + return nil, port.ErrNotFound +} +func (f *fakeIntegrationRepo) Delete(ctx context.Context, id string) error { return nil } +func (f *fakeIntegrationRepo) ListProviders(ctx context.Context) ([]domain.Provider, error) { + return nil, nil +} +func (f *fakeIntegrationRepo) GetProviderFields(ctx context.Context, name string) ([]domain.ProviderConfigField, error) { + return nil, nil +} +func (f *fakeIntegrationRepo) GetActiveByWorkspaceAndChannel(ctx context.Context, workspaceID, channel string) (*domain.Integration, error) { + return nil, nil +} + +func TestPortalService_PatchSettings_rejectsInvalidDispatchMode(t *testing.T) { + svc := NewPortalService(PortalDeps{Settings: &fakeSettingsRepo{}}) + + err := svc.PatchSettings(context.Background(), "ws-1", map[string]string{ + domain.SettingKeyMessageDispatchMode: "invalid", + }) + if !errors.Is(err, port.ErrInvalidInput) { + t.Fatalf("expected ErrInvalidInput, got %v", err) + } +} + +func TestPortalService_PatchSettings_rejectsProviderWithoutIntegration(t *testing.T) { + repo := &fakeSettingsRepo{} + intgRepo := &fakeIntegrationRepo{ + integrations: []domain.Integration{}, + } + svc := NewPortalService(PortalDeps{ + Settings: repo, + Integrations: intgRepo, + }) + + err := svc.PatchSettings(context.Background(), "ws-1", map[string]string{ + domain.SettingKeyMessageDispatchMode: string(domain.DispatchProvider), + }) + if !errors.Is(err, port.ErrInvalidInput) { + t.Fatalf("expected ErrInvalidInput, got %v", err) + } +} + +func TestPortalService_PatchSettings_persistsValidDispatchSettings(t *testing.T) { + repo := &fakeSettingsRepo{} + intgRepo := &fakeIntegrationRepo{ + integrations: []domain.Integration{ + {ProviderName: "mailgun", Status: domain.IntegrationStatusConnected}, + }, + } + svc := NewPortalService(PortalDeps{ + Settings: repo, + Integrations: intgRepo, + }) + + err := svc.PatchSettings(context.Background(), "ws-1", map[string]string{ + domain.SettingKeyMessageDispatchMode: string(domain.DispatchProvider), + domain.SettingKeyStoreMessageContent: "true", + }) + if err != nil { + t.Fatalf("PatchSettings: %v", err) + } + if repo.values[domain.SettingKeyMessageDispatchMode] != string(domain.DispatchProvider) { + t.Fatalf("mode not saved: %+v", repo.values) + } +} diff --git a/internal/infrastructure/logger/logger.go b/internal/infrastructure/logger/logger.go index 9e5996db..5be26074 100644 --- a/internal/infrastructure/logger/logger.go +++ b/internal/infrastructure/logger/logger.go @@ -114,12 +114,8 @@ func New(env string, extraHandlers ...slog.Handler) (*pkglogger.Logger, error) { return nil, err } - // Wrap handler with ContextHandler to automatically capture request details - wrappedLogger := slog.New(NewContextHandler(log.Handler())) - - // All slog.X() calls throughout the process now use this logger. - slog.SetDefault(wrappedLogger) - log.Logger = wrappedLogger + // ContextExtractors append correlation fields; avoid also wrapping ContextHandler (duplicate attrs). + slog.SetDefault(log.Logger) return log, nil } @@ -142,9 +138,7 @@ func WithProvider(ctx context.Context, provider string) context.Context { return context.WithValue(ctx, keyProvider, provider) } -// Attrs extracts all gateway-specific attributes from ctx into slog key-value pairs. -// Note: Handlers wrapping standard slog logging now use ContextHandler to extract -// these fields automatically; Attrs remains for manual or backwards-compatible usage. +// Attrs extracts gateway correlation fields from ctx for go-pkg ContextExtractors. func Attrs(ctx context.Context) []any { attrs := make([]any, 0, 10) if v, _ := ctx.Value(keyWorkspaceID).(string); v != "" { diff --git a/internal/infrastructure/repository/postgres/limitoffset.go b/internal/infrastructure/repository/postgres/limitoffset.go new file mode 100644 index 00000000..497ba1a7 --- /dev/null +++ b/internal/infrastructure/repository/postgres/limitoffset.go @@ -0,0 +1,29 @@ +package postgres + +import "fmt" + +const ( + defaultListLimit = 50 + maxListLimit = 500 +) + +// clampLimitOffset bounds list query pagination inputs. +func clampLimitOffset(limit, offset int) (int, int) { + if limit <= 0 { + limit = defaultListLimit + } + if limit > maxListLimit { + limit = maxListLimit + } + if offset < 0 { + offset = 0 + } + return limit, offset +} + +// limitOffsetSQL returns a safe LIMIT/OFFSET clause for Postgres list queries. +// Values are clamped in Go — not passed as query parameters (driver limitation). +func limitOffsetSQL(limit, offset int) string { + limit, offset = clampLimitOffset(limit, offset) + return fmt.Sprintf("LIMIT %d OFFSET %d", limit, offset) +} diff --git a/internal/infrastructure/repository/postgres/limitoffset_test.go b/internal/infrastructure/repository/postgres/limitoffset_test.go new file mode 100644 index 00000000..30e37904 --- /dev/null +++ b/internal/infrastructure/repository/postgres/limitoffset_test.go @@ -0,0 +1,33 @@ +package postgres + +import "testing" + +func TestClampLimitOffset(t *testing.T) { + tests := []struct { + name string + limit int + offset int + wantLimit int + wantOffset int + }{ + {"defaults", 0, 0, defaultListLimit, 0}, + {"caps max", 1000, 0, maxListLimit, 0}, + {"negative offset", 10, -5, 10, 0}, + {"valid", 25, 50, 25, 50}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotLimit, gotOffset := clampLimitOffset(tt.limit, tt.offset) + if gotLimit != tt.wantLimit || gotOffset != tt.wantOffset { + t.Fatalf("got (%d,%d), want (%d,%d)", gotLimit, gotOffset, tt.wantLimit, tt.wantOffset) + } + }) + } +} + +func TestLimitOffsetSQL(t *testing.T) { + if got := limitOffsetSQL(25, 10); got != "LIMIT 25 OFFSET 10" { + t.Fatalf("got %q", got) + } +} diff --git a/internal/infrastructure/repository/postgres/message_request_log_repository.go b/internal/infrastructure/repository/postgres/message_request_log_repository.go index f29a46cd..082cde33 100644 --- a/internal/infrastructure/repository/postgres/message_request_log_repository.go +++ b/internal/infrastructure/repository/postgres/message_request_log_repository.go @@ -51,22 +51,12 @@ func (r *MessageRequestLogRepository) Create(ctx context.Context, log *domain.Me provider, reqID, dur, errMsg, ).Scan(&log.ID, &log.CreatedAt) if err != nil { - slog.ErrorContext(ctx, "database error: failed to create message request log", "error", err, "workspace_id", log.WorkspaceID, "endpoint", log.Endpoint) + slog.ErrorContext(ctx, "database error: failed to create message request log", "error", err, "endpoint", log.Endpoint) } return err } func (r *MessageRequestLogRepository) ListWithSource(ctx context.Context, q port.MessageLogQuery) ([]domain.MessageRequestLogWithSource, int, error) { - if q.Limit <= 0 { - q.Limit = 50 - } - if q.Limit > 500 { - q.Limit = 500 - } - if q.Offset < 0 { - q.Offset = 0 - } - db := r.client.GetDB(ctx) args := []any{q.WorkspaceID} where := "l.workspace_id = $1" @@ -89,13 +79,10 @@ func (r *MessageRequestLogRepository) ListWithSource(ctx context.Context, q port countQuery := "SELECT COUNT(*) FROM message_request_logs l WHERE " + where var total int if err := db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil { - slog.ErrorContext(ctx, "database error: failed to count message request logs", "error", err, "workspace_id", q.WorkspaceID) + slog.ErrorContext(ctx, "database error: failed to count message request logs", "error", err) return nil, 0, err } - // LIMIT and OFFSET come from bounded Go int values clamped above — not from user input. - // database/sql does not support parameterised LIMIT/OFFSET in standard Postgres; using - // fmt.Sprintf with validated integers is the correct approach here. listQuery := fmt.Sprintf(` SELECT l.id, l.workspace_id, l.api_key_id, l.channel_type, l.http_method, l.status_code, l.endpoint, l.provider_name, l.request_id, l.duration_ms, l.error_message, l.created_at, @@ -104,11 +91,11 @@ func (r *MessageRequestLogRepository) ListWithSource(ctx context.Context, q port LEFT JOIN api_keys k ON k.id = l.api_key_id WHERE %s ORDER BY l.created_at DESC - LIMIT %d OFFSET %d`, where, q.Limit, q.Offset) + %s`, where, limitOffsetSQL(q.Limit, q.Offset)) rows, err := db.QueryContext(ctx, listQuery, args...) if err != nil { - slog.ErrorContext(ctx, "database error: failed to query message request logs", "error", err, "workspace_id", q.WorkspaceID) + slog.ErrorContext(ctx, "database error: failed to query message request logs", "error", err) return nil, 0, err } defer rows.Close() //nolint:errcheck @@ -126,7 +113,7 @@ func (r *MessageRequestLogRepository) ListWithSource(ctx context.Context, q port &prov, &reqID, &dur, &errMsg, &row.CreatedAt, &row.SourceName, &row.ClientID, ); err != nil { - slog.ErrorContext(ctx, "database error: failed to scan message request log", "error", err, "workspace_id", q.WorkspaceID) + slog.ErrorContext(ctx, "database error: failed to scan message request log", "error", err) return nil, 0, err } if apiKeyID.Valid { @@ -147,7 +134,7 @@ func (r *MessageRequestLogRepository) ListWithSource(ctx context.Context, q port out = append(out, row) } if err := rows.Err(); err != nil { - slog.ErrorContext(ctx, "database error: rows iteration failed for message request logs", "error", err, "workspace_id", q.WorkspaceID) + slog.ErrorContext(ctx, "database error: rows iteration failed for message request logs", "error", err) return nil, 0, err } return out, total, nil diff --git a/internal/presentation/handler/portal_handler.go b/internal/presentation/handler/portal_handler.go index a64dabf7..3d6b47cd 100644 --- a/internal/presentation/handler/portal_handler.go +++ b/internal/presentation/handler/portal_handler.go @@ -177,7 +177,9 @@ func (h *PortalHandler) PatchSettings(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, "invalid JSON") } if err := h.svc.PatchSettings(c.Request().Context(), wid, body); err != nil { - slog.ErrorContext(c.Request().Context(), "failed to patch settings", "error", err, "workspace_id", wid) + if !errors.Is(err, port.ErrInvalidInput) { + slog.ErrorContext(c.Request().Context(), "failed to patch settings", "error", err, "workspace_id", wid) + } return safeHTTPError(err, http.StatusBadRequest, "failed to patch settings") } return c.NoContent(http.StatusNoContent) diff --git a/internal/presentation/handler/send_helper.go b/internal/presentation/handler/send_helper.go index 265fe833..3ebec185 100644 --- a/internal/presentation/handler/send_helper.go +++ b/internal/presentation/handler/send_helper.go @@ -38,42 +38,43 @@ func (sh *SendHelper) DispatchAndLog( start := time.Now() ctx := c.Request().Context() - // Enrich context with gateway-domain attributes for logger ctx = logger.WithWorkspace(ctx, workspaceID, apiKeyID) ctx = logger.WithChannel(ctx, channel) if workspaceID == "" { - sh.RecordLog(ctx, workspaceID, apiKeyID, channel, c.Request().Method, - http.StatusUnauthorized, endpoint, start, "missing workspace context", "") + slog.WarnContext(ctx, "send rejected: missing workspace context", "endpoint", endpoint) return c.JSON(http.StatusUnauthorized, map[string]string{"error": "missing workspace context"}) } if err := json.NewDecoder(c.Request().Body).Decode(dst); err != nil { - sh.RecordLog(ctx, workspaceID, apiKeyID, channel, c.Request().Method, + slog.WarnContext(ctx, "send rejected: invalid JSON body", "endpoint", endpoint, "error", err) + sh.recordLog(ctx, workspaceID, apiKeyID, channel, c.Request().Method, http.StatusBadRequest, endpoint, start, "invalid JSON body", "") return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid JSON body"}) } + slog.InfoContext(ctx, "send dispatch starting", "endpoint", endpoint) + result, err := send(ctx) + providerName := contracts.ProviderNameFromResult(result) + if providerName != "" { + ctx = logger.WithProvider(ctx, providerName) + } + if err != nil { - // Log the full error internally; never echo infrastructure details to the caller. - slog.ErrorContext(ctx, "send failed", "error", err) - sh.RecordLog(ctx, workspaceID, apiKeyID, channel, c.Request().Method, - http.StatusInternalServerError, endpoint, start, err.Error(), providerFromMeta(result)) + slog.ErrorContext(ctx, "send failed", "error", err, "endpoint", endpoint, "duration_ms", time.Since(start).Milliseconds()) + sh.recordLog(ctx, workspaceID, apiKeyID, channel, c.Request().Method, + http.StatusInternalServerError, endpoint, start, err.Error(), providerName) return c.JSON(http.StatusInternalServerError, map[string]string{"error": "send failed"}) } - if provider := providerFromMeta(result); provider != "" { - ctx = logger.WithProvider(ctx, provider) - } - slog.InfoContext(ctx, "send ok", "duration_ms", time.Since(start).Milliseconds()) - sh.RecordLog(ctx, workspaceID, apiKeyID, channel, c.Request().Method, - http.StatusOK, endpoint, start, "", providerFromMeta(result)) + slog.InfoContext(ctx, "send ok", "endpoint", endpoint, "duration_ms", time.Since(start).Milliseconds()) + sh.recordLog(ctx, workspaceID, apiKeyID, channel, c.Request().Method, + http.StatusOK, endpoint, start, "", providerName) return c.JSON(http.StatusOK, result) } -// RecordLog persists a MessageRequestLog entry. Failures are logged with slog. -func (sh *SendHelper) RecordLog( +func (sh *SendHelper) recordLog( ctx context.Context, workspaceID, apiKeyID, channelType, method string, statusCode int, @@ -84,7 +85,7 @@ func (sh *SendHelper) RecordLog( if sh.svc == nil || workspaceID == "" { return } - entry := &domain.MessageRequestLog{ + entry := domain.MessageRequestLog{ WorkspaceID: workspaceID, APIKeyID: apiKeyID, ChannelType: channelType, @@ -97,13 +98,6 @@ func (sh *SendHelper) RecordLog( ErrorMessage: errMsg, } if err := sh.svc.RecordLog(ctx, entry); err != nil { - slog.ErrorContext(ctx, "message_request_log insert failed", "error", err) - } -} - -func providerFromMeta(r *contracts.SendResult) string { - if r == nil || r.Meta == nil { - return "" + slog.WarnContext(ctx, "failed to persist message request log", "error", err, "endpoint", endpoint) } - return r.Meta["provider_name"] } diff --git a/internal/presentation/handler/send_helper_test.go b/internal/presentation/handler/send_helper_test.go index 945680d3..9be5911a 100644 --- a/internal/presentation/handler/send_helper_test.go +++ b/internal/presentation/handler/send_helper_test.go @@ -88,12 +88,37 @@ func TestSendHelper_DispatchAndLog(t *testing.T) { } }) - t.Run("repository error logged without failing dispatch", func(t *testing.T) { + t.Run("missing workspace returns unauthorized", func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/v1/email", strings.NewReader(`{"to":["test@example.com"]}`)) rec := httptest.NewRecorder() c := e.NewContext(req, rec) - repo := &mockLogRepo{createErr: errors.New("db error")} + repo := &mockLogRepo{} + svc := service.NewGatewayService(nil, nil, nil, nil, repo) + helper := NewSendHelper(svc) + + var dst contracts.Email + err := helper.DispatchAndLog(c, "email", "", "key-456", "/v1/email", &dst, func(ctx context.Context) (*contracts.SendResult, error) { + return &contracts.SendResult{ID: "msg-111"}, nil + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusUnauthorized { + t.Errorf("expected status 401, got %d", rec.Code) + } + if len(repo.entries) != 0 { + t.Fatalf("expected no log entry without workspace, got %d", len(repo.entries)) + } + }) + + t.Run("invalid JSON returns bad request", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/v1/email", strings.NewReader(`{invalid`)) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + repo := &mockLogRepo{} svc := service.NewGatewayService(nil, nil, nil, nil, repo) helper := NewSendHelper(svc) @@ -105,9 +130,33 @@ func TestSendHelper_DispatchAndLog(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } + if rec.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", rec.Code) + } + }) - if rec.Code != http.StatusOK { - t.Errorf("expected status OK, got %d", rec.Code) + t.Run("send failure returns 500 and logs error", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/v1/email", strings.NewReader(`{"to":["test@example.com"]}`)) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + repo := &mockLogRepo{} + svc := service.NewGatewayService(nil, nil, nil, nil, repo) + helper := NewSendHelper(svc) + + var dst contracts.Email + err := helper.DispatchAndLog(c, "email", "ws-123", "key-456", "/v1/email", &dst, func(ctx context.Context) (*contracts.SendResult, error) { + return nil, errors.New("dispatch failed") + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusInternalServerError { + t.Errorf("expected status 500, got %d", rec.Code) + } + if len(repo.entries) != 1 || repo.entries[0].StatusCode != http.StatusInternalServerError { + t.Fatalf("expected error log entry, got %+v", repo.entries) } }) } diff --git a/internal/presentation/middleware/auth.go b/internal/presentation/middleware/auth.go index bf9ddc0c..6d8f2534 100644 --- a/internal/presentation/middleware/auth.go +++ b/internal/presentation/middleware/auth.go @@ -11,6 +11,7 @@ import ( "github.com/weprodev/go-pkg/crypto" "github.com/weprodev/wpd-message-gateway/internal/core/port" + applogger "github.com/weprodev/wpd-message-gateway/internal/infrastructure/logger" ) type contextKey string @@ -76,14 +77,14 @@ func APIKeyAuthMiddleware(apiKeyRepo port.APIKeyRepository, workspaceRepo port.W } if err := apiKeyRepo.UpdateLastUsedAt(c.Request().Context(), apiKey.ID); err != nil { - // Non-fatal: continue request - _ = err + slog.WarnContext(c.Request().Context(), "failed to update API key last_used_at", "error", err, "api_key_id", apiKey.ID) } ctx := c.Request().Context() ctx = context.WithValue(ctx, WorkspaceIDKey, apiKey.WorkspaceID) ctx = context.WithValue(ctx, APIKeyIDKey, apiKey.ID) ctx = context.WithValue(ctx, APIKeyNameKey, apiKey.Name) + ctx = applogger.WithWorkspace(ctx, apiKey.WorkspaceID, apiKey.ID) c.SetRequest(c.Request().WithContext(ctx)) return next(c) } diff --git a/pkg/contracts/message.go b/pkg/contracts/message.go index b4d1f020..56c6559d 100644 --- a/pkg/contracts/message.go +++ b/pkg/contracts/message.go @@ -15,3 +15,11 @@ type SendResult struct { Message string `json:"message"` Meta map[string]string `json:"meta,omitempty"` } + +// ProviderNameFromResult returns provider_name from dispatch metadata when present. +func ProviderNameFromResult(r *SendResult) string { + if r == nil || r.Meta == nil { + return "" + } + return r.Meta["provider_name"] +} From 2a59c8fd9db972020235ce3de016a04b802cf222 Mon Sep 17 00:00:00 2001 From: MichaelAndish Date: Thu, 2 Jul 2026 23:17:28 +0200 Subject: [PATCH 02/20] WPD-33-feat: added demo credentials output to make dev command --- Makefile | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index bdbc0c02..a554848f 100644 --- a/Makefile +++ b/Makefile @@ -300,11 +300,19 @@ docker-check: dev: docker-check @printf "\n" @printf "$(BOLD)$(CYAN)🐳 Starting Gateway, database, and Portal UI via Docker...$(RESET)\n" - @docker compose up -d --build + @docker compose up -d --build --renew-anon-volumes @printf "$(GREEN)✅ All services started!$(RESET)\n" @printf "\n" - @printf " $(BOLD)Gateway API:$(RESET) http://localhost:10101\n" - @printf " $(BOLD)Portal UI:$(RESET) http://localhost:10104\n" + @printf " ╭───────────────────────────────────────────────────╮\n" + @printf " │ │\n" + @printf " │ $(BOLD)Gateway API:$(RESET) http://localhost:10101 │\n" + @printf " │ $(BOLD)Portal UI:$(RESET) http://localhost:10104 │\n" + @printf " │ │\n" + @printf " │ $(BOLD)Demo Account$(RESET) │\n" + @printf " │ Email: demo@weprodev.com │\n" + @printf " │ Password: secret │\n" + @printf " │ │\n" + @printf " ╰───────────────────────────────────────────────────╯\n" @printf "\n" ## Stop Docker Compose From f900fd5409e98a56d6c2f5116df9698e1f7e7f8a Mon Sep 17 00:00:00 2001 From: MichaelAndish Date: Thu, 2 Jul 2026 23:35:55 +0200 Subject: [PATCH 03/20] WPD-33-fix: corrected footer slogan text and alignment --- frontend/src/shared/components/app-footer.tsx | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/frontend/src/shared/components/app-footer.tsx b/frontend/src/shared/components/app-footer.tsx index 565fc16f..923afa9f 100644 --- a/frontend/src/shared/components/app-footer.tsx +++ b/frontend/src/shared/components/app-footer.tsx @@ -10,13 +10,7 @@ export function AppFooter({ variant = "default" }: AppFooterProps) { return ( ) From fd629165897802259006fe1633fdf28dbfbd9c8d Mon Sep 17 00:00:00 2001 From: MichaelAndish Date: Thu, 2 Jul 2026 23:37:01 +0200 Subject: [PATCH 04/20] WPD-33-fix: moved Danger zone inside General tab content --- .../features/settings/pages/settings.page.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/frontend/src/features/settings/pages/settings.page.tsx b/frontend/src/features/settings/pages/settings.page.tsx index 1f0e1feb..c8b36cdb 100644 --- a/frontend/src/features/settings/pages/settings.page.tsx +++ b/frontend/src/features/settings/pages/settings.page.tsx @@ -243,6 +243,16 @@ export function SettingsPage() { + +
+

Danger zone

+

+ Permanently delete this workspace and all associated data. +

+ +
@@ -294,16 +304,6 @@ export function SettingsPage() { /> - -
-

Danger zone

-

- Permanently delete this workspace and all associated data. -

- -
) } From c4b976fec1f15bb60722b3da588ef8a05b63279b Mon Sep 17 00:00:00 2001 From: MichaelAndish Date: Thu, 2 Jul 2026 23:39:18 +0200 Subject: [PATCH 05/20] WPD-33-fix: unlocked DB storage config across all dispatch modes and fixed Checkbox styles --- frontend/src/components/ui/checkbox.tsx | 6 +++--- frontend/src/features/settings/pages/settings.page.tsx | 9 +++------ 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/ui/checkbox.tsx b/frontend/src/components/ui/checkbox.tsx index 1eae53bf..b9d74bc5 100644 --- a/frontend/src/components/ui/checkbox.tsx +++ b/frontend/src/components/ui/checkbox.tsx @@ -11,15 +11,15 @@ const Checkbox = React.forwardRef< - + )) diff --git a/frontend/src/features/settings/pages/settings.page.tsx b/frontend/src/features/settings/pages/settings.page.tsx index c8b36cdb..17fde658 100644 --- a/frontend/src/features/settings/pages/settings.page.tsx +++ b/frontend/src/features/settings/pages/settings.page.tsx @@ -139,19 +139,16 @@ function DispatchSettingsPanel({ initialConfig, onSave }: DispatchSettingsPanelP
-
From bb14502e5e47129af3d4d971c33244bc372fe1b1 Mon Sep 17 00:00:00 2001 From: MichaelAndish Date: Thu, 2 Jul 2026 23:40:16 +0200 Subject: [PATCH 06/20] WPD-33-fix: replaced deprecated React.ElementRef with React.ComponentRef --- frontend/src/components/ui/checkbox.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/ui/checkbox.tsx b/frontend/src/components/ui/checkbox.tsx index b9d74bc5..9b17e647 100644 --- a/frontend/src/components/ui/checkbox.tsx +++ b/frontend/src/components/ui/checkbox.tsx @@ -5,7 +5,7 @@ import { Icon } from "@/components/ui/icon" import { cn } from "@/lib/utils" const Checkbox = React.forwardRef< - React.ElementRef, + React.ComponentRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( Date: Fri, 3 Jul 2026 20:58:17 +0200 Subject: [PATCH 07/20] chore: update deps --- frontend/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a3c0f3e5..90763c7c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -4977,9 +4977,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.384", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.384.tgz", - "integrity": "sha512-g6KAKY1vkYsADvSPWvdJsuYT0ixdcu6lUtD9P/wJKGBEDlZVXh2AX42j1mPqqaQPDluWjara9ziQ7xqAeXCt5A==", + "version": "1.5.385", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.385.tgz", + "integrity": "sha512-78sa/M08MNAYHQfjoWMvOlKQqZ0ElhSm/L5HNUc96VZ3b+KvDVnngFm8sYQy0XrhTRgAhggHr5abA7yTvRdo4Q==", "dev": true, "license": "ISC" }, From 7809080e43c6fe1925137ee0582491763bb8b00e Mon Sep 17 00:00:00 2001 From: MichaelAndish Date: Fri, 3 Jul 2026 21:45:11 +0200 Subject: [PATCH 08/20] feat: implement message request payload storage and logging enhancements - Added a new `message_request_payloads` table to store request and response bodies separately for improved data privacy and retention policies. - Updated `MessageRequestLog` and related domain models to support payload storage. - Enhanced logging functionality in `SendHelper` to conditionally store request and response payloads based on dispatch metadata. - Updated tests to verify the correct behavior of payload storage and retrieval. This commit enhances the message dispatch system by ensuring that sensitive data is managed appropriately while maintaining operational logging capabilities. --- .agents/skills/golang-pro/SKILL.md | 3 +- .agents/skills/software-architecture/SKILL.md | 1 + .claude/commands/smell.md | 3 +- .../20260318000000_init_gateway.down.sql | 1 + .../20260318000000_init_gateway.up.sql | 11 ++++ docs/backend/architecture.md | 2 +- internal/core/domain/log_row.go | 4 +- internal/core/domain/message_request_log.go | 26 ++++---- .../core/domain/message_request_payload.go | 12 ++++ internal/core/service/gateway_service.go | 15 ++--- .../message_request_log_repository.go | 36 ++++++++--- .../presentation/handler/portal_handler.go | 42 ++++++++++++- internal/presentation/handler/send_helper.go | 31 ++++++++- .../presentation/handler/send_helper_test.go | 63 +++++++++++++++++++ pkg/contracts/message.go | 30 ++++++++- pkg/contracts/message_test.go | 53 ++++++++++++++++ 16 files changed, 296 insertions(+), 37 deletions(-) create mode 100644 internal/core/domain/message_request_payload.go create mode 100644 pkg/contracts/message_test.go diff --git a/.agents/skills/golang-pro/SKILL.md b/.agents/skills/golang-pro/SKILL.md index 378bf7ea..8fd7e8ce 100644 --- a/.agents/skills/golang-pro/SKILL.md +++ b/.agents/skills/golang-pro/SKILL.md @@ -97,9 +97,10 @@ Key properties demonstrated: bounded goroutine lifetime via `ctx`, error propaga - Handle all errors explicitly (no naked returns) - Write table-driven tests with subtests - Document all exported functions, types, and packages -- Use `X | Y` union constraints for generics (Go 1.18+) + - Use `X | Y` union constraints for generics (Go 1.18+) - Propagate errors with fmt.Errorf("%w", err) - Run race detector on tests (-race flag) +- Maintain pure domain entities (strict DDD): NEVER use `json:"..."`, `db:"..."` or other presentation/infrastructure tags in `internal/core/domain`. ALWAYS map domain models to DTOs in the presentation layer before returning them. ### MUST NOT DO - Ignore errors (avoid _ assignment without justification) diff --git a/.agents/skills/software-architecture/SKILL.md b/.agents/skills/software-architecture/SKILL.md index 4aff70f7..8aa6af9f 100644 --- a/.agents/skills/software-architecture/SKILL.md +++ b/.agents/skills/software-architecture/SKILL.md @@ -40,6 +40,7 @@ This skill provides guidance for quality focused software development and archit - **Clean Architecture & DDD Principles:** - Follow domain-driven design and ubiquitous language - Separate domain entities from infrastructure concerns + - **Strict boundary**: Presentation data transfer objects (DTOs) must be completely separate from Domain entities. The Presentation layer is responsible for mapping Domain objects to DTOs. Domain objects must never contain presentation tags (e.g. JSON) and must never be serialized directly. - Keep business logic independent of frameworks - Define use cases clearly and keep them isolated - **Naming Conventions:** diff --git a/.claude/commands/smell.md b/.claude/commands/smell.md index 16020700..8333c987 100644 --- a/.claude/commands/smell.md +++ b/.claude/commands/smell.md @@ -101,7 +101,7 @@ Router → Middleware → Handler → Service → Port ← Repository / Provider | Area | Violation | | ---- | --------- | -| `internal/core/domain/` | Imports Echo, HTTP, infra, or presentation | +| `internal/core/domain/` | Imports Echo, HTTP, infra, presentation, or contains struct tags (`json`, `db`) | | `internal/core/port/` | Contains implementation instead of interfaces | | `internal/core/service/` | Direct SQL/HTTP; missing `ctx`; orchestration left in handler | | `internal/infrastructure/` | Business rules; bypasses port interfaces | @@ -219,6 +219,7 @@ Do not invent issues. If the diff is clean, say so. | -- | ------- | | DDD.LAYER | Business rule or orchestration in the wrong layer (handler/UI/infra) | | DDD.BOUNDARY | Bounded context violated (feature↔feature, `pkg`→`internal`, shared→feature) | +| DDD.DOMAIN-LEAK | Domain object returned directly from handler without DTO mapping, or containing presentation tags (`json`) | | DDD.VALUE-TYPE | Domain concept as raw string/number — use typed constant in domain + API/FE mirror | **General — SOLID** diff --git a/database/migrations/20260318000000_init_gateway.down.sql b/database/migrations/20260318000000_init_gateway.down.sql index 8dec95e1..1601b1c7 100644 --- a/database/migrations/20260318000000_init_gateway.down.sql +++ b/database/migrations/20260318000000_init_gateway.down.sql @@ -6,6 +6,7 @@ DROP TABLE IF EXISTS workspace_access_audits CASCADE; DROP TABLE IF EXISTS workspace_settings CASCADE; DROP TABLE IF EXISTS template_components CASCADE; DROP TABLE IF EXISTS templates CASCADE; +DROP TABLE IF EXISTS message_request_payloads CASCADE; DROP TABLE IF EXISTS message_request_logs CASCADE; DROP TABLE IF EXISTS api_keys CASCADE; DROP TABLE IF EXISTS integrations CASCADE; diff --git a/database/migrations/20260318000000_init_gateway.up.sql b/database/migrations/20260318000000_init_gateway.up.sql index 5b9bc09f..96dcf1d4 100644 --- a/database/migrations/20260318000000_init_gateway.up.sql +++ b/database/migrations/20260318000000_init_gateway.up.sql @@ -272,6 +272,17 @@ CREATE INDEX idx_message_request_logs_workspace_api_created ON message_request_logs (workspace_id, api_key_id, created_at DESC) WHERE api_key_id IS NOT NULL; +-- ============================================================================== +-- 11.5. MESSAGE_REQUEST_PAYLOADS TABLE (Append-only body storage) +-- ============================================================================== + +CREATE TABLE message_request_payloads ( + log_id UUID PRIMARY KEY REFERENCES message_request_logs(id) ON DELETE CASCADE, + request_body TEXT, + response_body TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + -- ============================================================================== -- 12. TEMPLATES TABLE -- ============================================================================== diff --git a/docs/backend/architecture.md b/docs/backend/architecture.md index 5e5e444d..befdcf52 100644 --- a/docs/backend/architecture.md +++ b/docs/backend/architecture.md @@ -258,7 +258,7 @@ PATCH /api/v1/workspaces/:wid/settings { "message_dispatch_mode": "provider", "store_message_content": "true" } ``` -**Request logs**: Every API send appends a row to `message_request_logs` (operational tracing with correlation `request_id`). +**Request logs**: Every API send appends a row to `message_request_logs` (operational tracing with correlation `request_id`). Payload bodies are stored in a separate table (`message_request_payloads`) to prevent table bloat and enforce independent data privacy/retention policies. --- diff --git a/internal/core/domain/log_row.go b/internal/core/domain/log_row.go index eb9d2661..0cf190e3 100644 --- a/internal/core/domain/log_row.go +++ b/internal/core/domain/log_row.go @@ -3,6 +3,6 @@ package domain // MessageRequestLogWithSource is a log row joined with API key metadata for portal lists. type MessageRequestLogWithSource struct { MessageRequestLog - SourceName string `json:"source_name"` - ClientID string `json:"client_id,omitempty"` + SourceName string + ClientID string } diff --git a/internal/core/domain/message_request_log.go b/internal/core/domain/message_request_log.go index c156ba9f..d72ff69c 100644 --- a/internal/core/domain/message_request_log.go +++ b/internal/core/domain/message_request_log.go @@ -4,17 +4,19 @@ import "time" // MessageRequestLog records a gateway API call for monitoring. type MessageRequestLog struct { - ID string `json:"id"` - WorkspaceID string `json:"workspace_id"` - APIKeyID string `json:"api_key_id,omitempty"` - ChannelType string `json:"channel_type"` - HTTPMethod string `json:"http_method"` - StatusCode int `json:"status_code"` - Endpoint string `json:"endpoint"` - ProviderName string `json:"provider_name,omitempty"` - RequestID string `json:"request_id,omitempty"` - DurationMs int `json:"duration_ms,omitempty"` - ErrorMessage string `json:"error_message,omitempty"` + ID string + WorkspaceID string + APIKeyID string + ChannelType string + HTTPMethod string + StatusCode int + Endpoint string + ProviderName string + RequestID string + DurationMs int + ErrorMessage string - CreatedAt time.Time `json:"created_at"` + CreatedAt time.Time + + Payload *MessageRequestPayload } diff --git a/internal/core/domain/message_request_payload.go b/internal/core/domain/message_request_payload.go new file mode 100644 index 00000000..91200ae8 --- /dev/null +++ b/internal/core/domain/message_request_payload.go @@ -0,0 +1,12 @@ +package domain + +import "time" + +// MessageRequestPayload stores the body of a message request separately +// from the main metadata logs for optimization and privacy control. +type MessageRequestPayload struct { + LogID string + RequestBody string + ResponseBody string + CreatedAt time.Time +} diff --git a/internal/core/service/gateway_service.go b/internal/core/service/gateway_service.go index 670ae56b..fca073ae 100644 --- a/internal/core/service/gateway_service.go +++ b/internal/core/service/gateway_service.go @@ -163,7 +163,7 @@ func (s *GatewayService) dispatch( if config.RoutesViaProvider() { intg, errLookup := s.requireProviderIntegration(ctx, workspaceID, channel) if errLookup != nil { - return attachMeta(nil, effectiveMode, channel, integrationID, providerName), errLookup + return attachMeta(nil, effectiveMode, config.StoreMessageContent, channel, integrationID, providerName), errLookup } integrationID = intg.ID @@ -173,7 +173,7 @@ func (s *GatewayService) dispatch( provResult, err = sendViaProvider(providerCtx, *intg) if err != nil { slog.WarnContext(providerCtx, "provider dispatch failed", "error", err, "integration_id", integrationID) - return attachMeta(nil, effectiveMode, channel, integrationID, providerName), err + return attachMeta(nil, effectiveMode, config.StoreMessageContent, channel, integrationID, providerName), err } slog.InfoContext(providerCtx, "message dispatched via provider", "message_id", provResult.ID) } @@ -183,7 +183,7 @@ func (s *GatewayService) dispatch( if err != nil { slog.ErrorContext(ctx, "inbox write failed", "error", err, "dispatch_mode", effectiveMode, "store_content", config.StoreMessageContent) if effectiveMode == domain.DispatchMemory { - return attachMeta(nil, effectiveMode, channel, integrationID, providerName), err + return attachMeta(nil, effectiveMode, config.StoreMessageContent, channel, integrationID, providerName), err } slog.WarnContext(ctx, "inbox capture failed after provider dispatch", "dispatch_mode", effectiveMode) } else { @@ -201,7 +201,7 @@ func (s *GatewayService) dispatch( finalResult.Meta["inbox_message_id"] = inboxResult.ID } - return attachMeta(finalResult, effectiveMode, channel, integrationID, providerName), nil + return attachMeta(finalResult, effectiveMode, config.StoreMessageContent, channel, integrationID, providerName), nil } // ResolveDispatchConfig reads the workspace setting for outbound dispatch behavior. @@ -323,20 +323,21 @@ func (s *GatewayService) writeChatToInbox(ctx context.Context, workspaceID strin // attachMeta stamps standard dispatch metadata onto r. When r is nil (error paths // with no provider payload), it allocates a minimal SendResult so callers can // still read provider_name via contracts.ProviderNameFromResult. -func attachMeta(r *contracts.SendResult, effectiveMode domain.MessageDispatchMode, channel, integrationID, providerName string) *contracts.SendResult { +func attachMeta(r *contracts.SendResult, effectiveMode domain.MessageDispatchMode, storeContent bool, channel, integrationID, providerName string) *contracts.SendResult { if r == nil { r = &contracts.SendResult{} } if r.Meta == nil { - r.Meta = make(map[string]string, 4) + r.Meta = make(map[string]string, 5) } r.Meta["dispatch_mode"] = string(effectiveMode) + contracts.SetStoreContentMeta(r, storeContent) r.Meta["channel"] = channel if integrationID != "" { r.Meta["integration_id"] = integrationID } if providerName != "" { - r.Meta["provider_name"] = providerName + r.Meta[contracts.MetaKeyProviderName] = providerName } return r } diff --git a/internal/infrastructure/repository/postgres/message_request_log_repository.go b/internal/infrastructure/repository/postgres/message_request_log_repository.go index 082cde33..84356981 100644 --- a/internal/infrastructure/repository/postgres/message_request_log_repository.go +++ b/internal/infrastructure/repository/postgres/message_request_log_repository.go @@ -46,14 +46,34 @@ func (r *MessageRequestLogRepository) Create(ctx context.Context, log *domain.Me if log.ProviderName != "" { provider = log.ProviderName } - err := r.client.GetDB(ctx).QueryRowContext(ctx, query, - log.WorkspaceID, apiKeyID, log.ChannelType, log.HTTPMethod, log.StatusCode, log.Endpoint, - provider, reqID, dur, errMsg, - ).Scan(&log.ID, &log.CreatedAt) - if err != nil { - slog.ErrorContext(ctx, "database error: failed to create message request log", "error", err, "endpoint", log.Endpoint) - } - return err + return r.client.RunInTransaction(ctx, func(ctx context.Context) error { + err := r.client.GetDB(ctx).QueryRowContext(ctx, query, + log.WorkspaceID, apiKeyID, log.ChannelType, log.HTTPMethod, log.StatusCode, log.Endpoint, + provider, reqID, dur, errMsg, + ).Scan(&log.ID, &log.CreatedAt) + if err != nil { + slog.ErrorContext(ctx, "database error: failed to create message request log", "error", err, "endpoint", log.Endpoint) + return err + } + + if log.Payload != nil { + log.Payload.LogID = log.ID + payloadQuery := ` + INSERT INTO message_request_payloads (log_id, request_body, response_body) + VALUES ($1, $2, $3) + RETURNING created_at + ` + err = r.client.GetDB(ctx).QueryRowContext(ctx, payloadQuery, + log.Payload.LogID, log.Payload.RequestBody, log.Payload.ResponseBody, + ).Scan(&log.Payload.CreatedAt) + if err != nil { + slog.ErrorContext(ctx, "database error: failed to create message request payload", "error", err, "log_id", log.ID) + return err + } + } + + return nil + }) } func (r *MessageRequestLogRepository) ListWithSource(ctx context.Context, q port.MessageLogQuery) ([]domain.MessageRequestLogWithSource, int, error) { diff --git a/internal/presentation/handler/portal_handler.go b/internal/presentation/handler/portal_handler.go index 3d6b47cd..52b419fc 100644 --- a/internal/presentation/handler/portal_handler.go +++ b/internal/presentation/handler/portal_handler.go @@ -135,6 +135,42 @@ func (h *PortalHandler) RegenerateAPIKey(c echo.Context) error { return c.JSON(http.StatusOK, map[string]string{"client_secret": secret}) } +type messageRequestLogDTO struct { + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + APIKeyID string `json:"api_key_id,omitempty"` + ChannelType string `json:"channel_type"` + HTTPMethod string `json:"http_method"` + StatusCode int `json:"status_code"` + Endpoint string `json:"endpoint"` + ProviderName string `json:"provider_name,omitempty"` + RequestID string `json:"request_id,omitempty"` + DurationMs int `json:"duration_ms,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + CreatedAt time.Time `json:"created_at"` + SourceName string `json:"source_name"` + ClientID string `json:"client_id,omitempty"` +} + +func toMessageRequestLogDTO(l domain.MessageRequestLogWithSource) messageRequestLogDTO { + return messageRequestLogDTO{ + ID: l.ID, + WorkspaceID: l.WorkspaceID, + APIKeyID: l.APIKeyID, + ChannelType: l.ChannelType, + HTTPMethod: l.HTTPMethod, + StatusCode: l.StatusCode, + Endpoint: l.Endpoint, + ProviderName: l.ProviderName, + RequestID: l.RequestID, + DurationMs: l.DurationMs, + ErrorMessage: l.ErrorMessage, + CreatedAt: l.CreatedAt, + SourceName: l.SourceName, + ClientID: l.ClientID, + } +} + func (h *PortalHandler) ListLogs(c echo.Context) error { wid := c.Param("wid") q := port.MessageLogQuery{WorkspaceID: wid} @@ -156,7 +192,11 @@ func (h *PortalHandler) ListLogs(c echo.Context) error { slog.ErrorContext(c.Request().Context(), "failed to list logs", "error", err) return safeHTTPError(err, http.StatusInternalServerError, "failed to list logs") } - return c.JSON(http.StatusOK, map[string]any{"items": rows, "total": total}) + out := make([]messageRequestLogDTO, 0, len(rows)) + for _, r := range rows { + out = append(out, toMessageRequestLogDTO(r)) + } + return c.JSON(http.StatusOK, map[string]any{"items": out, "total": total}) } func (h *PortalHandler) GetSettings(c echo.Context) error { diff --git a/internal/presentation/handler/send_helper.go b/internal/presentation/handler/send_helper.go index 3ebec185..88e44069 100644 --- a/internal/presentation/handler/send_helper.go +++ b/internal/presentation/handler/send_helper.go @@ -49,7 +49,7 @@ func (sh *SendHelper) DispatchAndLog( if err := json.NewDecoder(c.Request().Body).Decode(dst); err != nil { slog.WarnContext(ctx, "send rejected: invalid JSON body", "endpoint", endpoint, "error", err) sh.recordLog(ctx, workspaceID, apiKeyID, channel, c.Request().Method, - http.StatusBadRequest, endpoint, start, "invalid JSON body", "") + http.StatusBadRequest, endpoint, start, "invalid JSON body", "", nil, nil) return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid JSON body"}) } @@ -64,13 +64,13 @@ func (sh *SendHelper) DispatchAndLog( if err != nil { slog.ErrorContext(ctx, "send failed", "error", err, "endpoint", endpoint, "duration_ms", time.Since(start).Milliseconds()) sh.recordLog(ctx, workspaceID, apiKeyID, channel, c.Request().Method, - http.StatusInternalServerError, endpoint, start, err.Error(), providerName) + http.StatusInternalServerError, endpoint, start, err.Error(), providerName, dst, result) return c.JSON(http.StatusInternalServerError, map[string]string{"error": "send failed"}) } slog.InfoContext(ctx, "send ok", "endpoint", endpoint, "duration_ms", time.Since(start).Milliseconds()) sh.recordLog(ctx, workspaceID, apiKeyID, channel, c.Request().Method, - http.StatusOK, endpoint, start, "", providerName) + http.StatusOK, endpoint, start, "", providerName, dst, result) return c.JSON(http.StatusOK, result) } @@ -81,6 +81,8 @@ func (sh *SendHelper) recordLog( endpoint string, start time.Time, errMsg, providerName string, + reqBody any, + result *contracts.SendResult, ) { if sh.svc == nil || workspaceID == "" { return @@ -97,6 +99,29 @@ func (sh *SendHelper) recordLog( DurationMs: int(time.Since(start).Milliseconds()), ErrorMessage: errMsg, } + + if contracts.StoreContentFromResult(result) { + var requestBody, responseBody string + if reqBody != nil { + if b, err := json.Marshal(reqBody); err != nil { + slog.WarnContext(ctx, "failed to marshal request body for log payload", "error", err, "endpoint", endpoint) + } else { + requestBody = string(b) + } + } + if b, err := json.Marshal(result); err != nil { + slog.WarnContext(ctx, "failed to marshal response body for log payload", "error", err, "endpoint", endpoint) + } else { + responseBody = string(b) + } + if requestBody != "" || responseBody != "" { + entry.Payload = &domain.MessageRequestPayload{ + RequestBody: requestBody, + ResponseBody: responseBody, + } + } + } + if err := sh.svc.RecordLog(ctx, entry); err != nil { slog.WarnContext(ctx, "failed to persist message request log", "error", err, "endpoint", endpoint) } diff --git a/internal/presentation/handler/send_helper_test.go b/internal/presentation/handler/send_helper_test.go index 9be5911a..9bdfdb0c 100644 --- a/internal/presentation/handler/send_helper_test.go +++ b/internal/presentation/handler/send_helper_test.go @@ -88,6 +88,69 @@ func TestSendHelper_DispatchAndLog(t *testing.T) { } }) + t.Run("store_content persists request and response payloads", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/v1/email", strings.NewReader(`{"to":["test@example.com"],"subject":"hi"}`)) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + repo := &mockLogRepo{} + svc := service.NewGatewayService(nil, nil, nil, nil, repo) + helper := NewSendHelper(svc) + + var dst contracts.Email + err := helper.DispatchAndLog(c, "email", "ws-123", "key-456", "/v1/email", &dst, func(ctx context.Context) (*contracts.SendResult, error) { + r := &contracts.SendResult{ID: "msg-111", StatusCode: 200, Message: "sent"} + contracts.SetStoreContentMeta(r, true) + r.Meta[contracts.MetaKeyProviderName] = "mailgun" + return r, nil + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(repo.entries) != 1 { + t.Fatalf("expected 1 log entry, got %d", len(repo.entries)) + } + + log := repo.entries[0] + if log.Payload == nil { + t.Fatal("expected payload on log entry") + } + if !strings.Contains(log.Payload.RequestBody, "test@example.com") { + t.Errorf("request body missing recipient: %q", log.Payload.RequestBody) + } + if !strings.Contains(log.Payload.ResponseBody, "msg-111") { + t.Errorf("response body missing message id: %q", log.Payload.ResponseBody) + } + }) + + t.Run("store_content off skips payload", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/v1/email", strings.NewReader(`{"to":["test@example.com"]}`)) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + repo := &mockLogRepo{} + svc := service.NewGatewayService(nil, nil, nil, nil, repo) + helper := NewSendHelper(svc) + + var dst contracts.Email + err := helper.DispatchAndLog(c, "email", "ws-123", "key-456", "/v1/email", &dst, func(ctx context.Context) (*contracts.SendResult, error) { + r := &contracts.SendResult{ID: "msg-111"} + contracts.SetStoreContentMeta(r, false) + return r, nil + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(repo.entries) != 1 { + t.Fatalf("expected 1 log entry, got %d", len(repo.entries)) + } + if repo.entries[0].Payload != nil { + t.Fatalf("expected no payload when store_content is false, got %+v", repo.entries[0].Payload) + } + }) + t.Run("missing workspace returns unauthorized", func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/v1/email", strings.NewReader(`{"to":["test@example.com"]}`)) rec := httptest.NewRecorder() diff --git a/pkg/contracts/message.go b/pkg/contracts/message.go index 56c6559d..c87aa708 100644 --- a/pkg/contracts/message.go +++ b/pkg/contracts/message.go @@ -8,6 +8,14 @@ type Attachment struct { URL string `json:"url,omitempty"` } +const ( + MetaKeyProviderName = "provider_name" + MetaKeyStoreContent = "store_content" + + metaStoreContentTrue = "true" + metaStoreContentFalse = "false" +) + // SendResult represents the result of sending a message. type SendResult struct { ID string `json:"id"` @@ -21,5 +29,25 @@ func ProviderNameFromResult(r *SendResult) string { if r == nil || r.Meta == nil { return "" } - return r.Meta["provider_name"] + return r.Meta[MetaKeyProviderName] +} + +// StoreContentFromResult reports whether dispatch metadata requests persisting request/response bodies. +func StoreContentFromResult(r *SendResult) bool { + if r == nil || r.Meta == nil { + return false + } + return r.Meta[MetaKeyStoreContent] == metaStoreContentTrue +} + +// SetStoreContentMeta stamps store_content on r.Meta for downstream logging. +func SetStoreContentMeta(r *SendResult, storeContent bool) { + if r.Meta == nil { + r.Meta = make(map[string]string) + } + if storeContent { + r.Meta[MetaKeyStoreContent] = metaStoreContentTrue + } else { + r.Meta[MetaKeyStoreContent] = metaStoreContentFalse + } } diff --git a/pkg/contracts/message_test.go b/pkg/contracts/message_test.go new file mode 100644 index 00000000..70b158fa --- /dev/null +++ b/pkg/contracts/message_test.go @@ -0,0 +1,53 @@ +package contracts + +import "testing" + +func TestStoreContentFromResult(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + r *SendResult + want bool + }{ + {"nil result", nil, false}, + {"nil meta", &SendResult{ID: "1"}, false}, + {"false", &SendResult{Meta: map[string]string{MetaKeyStoreContent: metaStoreContentFalse}}, false}, + {"true", &SendResult{Meta: map[string]string{MetaKeyStoreContent: metaStoreContentTrue}}, true}, + {"missing key", &SendResult{Meta: map[string]string{}}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := StoreContentFromResult(tt.r); got != tt.want { + t.Fatalf("StoreContentFromResult() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSetStoreContentMeta(t *testing.T) { + t.Parallel() + + t.Run("true", func(t *testing.T) { + t.Parallel() + r := &SendResult{} + SetStoreContentMeta(r, true) + if r.Meta[MetaKeyStoreContent] != metaStoreContentTrue { + t.Fatalf("meta: %q", r.Meta[MetaKeyStoreContent]) + } + }) + + t.Run("false", func(t *testing.T) { + t.Parallel() + r := &SendResult{Meta: map[string]string{"other": "x"}} + SetStoreContentMeta(r, false) + if r.Meta[MetaKeyStoreContent] != metaStoreContentFalse { + t.Fatalf("meta: %q", r.Meta[MetaKeyStoreContent]) + } + if r.Meta["other"] != "x" { + t.Fatalf("unexpected meta overwrite") + } + }) +} From 64659338d9762d7ca9173fc8b145c4cb0d738bc9 Mon Sep 17 00:00:00 2001 From: MichaelAndish Date: Fri, 3 Jul 2026 22:42:58 +0200 Subject: [PATCH 09/20] refactor: consolidate inbox API, optimize database indices, and implement centralized error handling for settings updates. --- .../20260318000000_init_gateway.up.sql | 9 + .../components/email-list/email-list.tsx | 28 +- .../inbox/hooks/use-inbox-key.hook.ts | 121 --------- frontend/src/features/inbox/inbox.api.ts | 83 ++++-- frontend/src/features/inbox/inbox.types.ts | 7 +- frontend/src/features/inbox/logs.schema.ts | 33 +++ .../features/inbox/pages/email-inbox.page.tsx | 166 +++++++++--- .../features/inbox/pages/overview.page.tsx | 8 +- .../settings/hooks/use-settings.hook.ts | 1 + .../features/settings/pages/settings.page.tsx | 39 ++- .../src/features/settings/settings.api.ts | 12 +- internal/app/wire.go | 11 +- internal/core/port/inbox.go | 8 + internal/core/port/portal.go | 1 + internal/core/service/portal_service.go | 51 ++-- .../infrastructure/authgate/gate_adapter.go | 73 ++++- .../infrastructure/inbox/notifying_writer.go | 66 +++++ internal/infrastructure/inbox/store.go | 256 ++++++++---------- .../repository/postgres/api_key_repository.go | 6 +- .../message_request_log_repository.go | 75 ++--- .../postgres/workspace_member_repository.go | 3 +- .../postgres/workspace_repository.go | 33 ++- .../handler/portal_inbox_handler.go | 17 +- internal/presentation/router.go | 17 +- tests/bruno/Portal/Email/List Emails.bru | 13 +- 25 files changed, 698 insertions(+), 439 deletions(-) delete mode 100644 frontend/src/features/inbox/hooks/use-inbox-key.hook.ts create mode 100644 frontend/src/features/inbox/logs.schema.ts create mode 100644 internal/infrastructure/inbox/notifying_writer.go diff --git a/database/migrations/20260318000000_init_gateway.up.sql b/database/migrations/20260318000000_init_gateway.up.sql index 96dcf1d4..82cbd7f0 100644 --- a/database/migrations/20260318000000_init_gateway.up.sql +++ b/database/migrations/20260318000000_init_gateway.up.sql @@ -62,6 +62,9 @@ CREATE TRIGGER trg_workspaces_set_updated_at FOR EACH ROW EXECUTE FUNCTION set_updated_at(); CREATE INDEX idx_workspaces_owner_id ON workspaces(owner_id); +CREATE INDEX idx_workspaces_active_list + ON workspaces (status, is_private, name) + WHERE status = 'active'; -- ============================================================================== -- 3. ROLES TABLE (RBAC) @@ -217,6 +220,8 @@ CREATE UNIQUE INDEX idx_integrations_default_per_channel CREATE INDEX idx_integrations_provider_id ON integrations(provider_id); CREATE INDEX idx_integrations_workspace_channel ON integrations(workspace_id, channel_type); +CREATE INDEX idx_integrations_workspace_channel_status + ON integrations (workspace_id, channel_type, status, is_default DESC, created_at DESC); CREATE TRIGGER trg_integrations_set_updated_at BEFORE UPDATE ON integrations @@ -241,6 +246,7 @@ CREATE TABLE api_keys ( ); CREATE INDEX idx_api_keys_workspace_id ON api_keys(workspace_id); +CREATE INDEX idx_api_keys_workspace_created ON api_keys (workspace_id, created_at DESC); -- ============================================================================== -- 11. MESSAGE_REQUEST_LOGS TABLE (Append-only) @@ -272,6 +278,9 @@ CREATE INDEX idx_message_request_logs_workspace_api_created ON message_request_logs (workspace_id, api_key_id, created_at DESC) WHERE api_key_id IS NOT NULL; +CREATE INDEX idx_message_request_logs_workspace_channel_created + ON message_request_logs (workspace_id, channel_type, created_at DESC); + -- ============================================================================== -- 11.5. MESSAGE_REQUEST_PAYLOADS TABLE (Append-only body storage) -- ============================================================================== diff --git a/frontend/src/features/inbox/components/email-list/email-list.tsx b/frontend/src/features/inbox/components/email-list/email-list.tsx index ac3beef4..8baf8c38 100644 --- a/frontend/src/features/inbox/components/email-list/email-list.tsx +++ b/frontend/src/features/inbox/components/email-list/email-list.tsx @@ -1,5 +1,6 @@ import { useState } from "react" import type { StoredEmail } from "../../inbox.types" +import { Button } from "@/components/ui/button" import { SearchInput } from "@/components/ui/search-input" import { EmailItem } from "../email-item" @@ -7,9 +8,19 @@ interface EmailListProps { messages: StoredEmail[] selectedMessageId: string | null onSelectMessage: (messageId: string) => void + hasMore?: boolean + isLoadingMore?: boolean + onLoadMore?: () => void } -export function EmailList({ messages, selectedMessageId, onSelectMessage }: EmailListProps) { +export function EmailList({ + messages, + selectedMessageId, + onSelectMessage, + hasMore = false, + isLoadingMore = false, + onLoadMore, +}: EmailListProps) { const [searchQuery, setSearchQuery] = useState("") const filtered = messages.filter((msg) => { @@ -51,6 +62,21 @@ export function EmailList({ messages, selectedMessageId, onSelectMessage }: Emai {searchQuery ? "No messages match your search." : "Your inbox is empty."} )} + + {hasMore && !searchQuery && onLoadMore && ( +
+ +
+ )} ) diff --git a/frontend/src/features/inbox/hooks/use-inbox-key.hook.ts b/frontend/src/features/inbox/hooks/use-inbox-key.hook.ts deleted file mode 100644 index 24c5ffea..00000000 --- a/frontend/src/features/inbox/hooks/use-inbox-key.hook.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { useEffect, useState } from "react" -import { apiFetch } from "@/core/api/client" -import type { InboxCredentials } from "../inbox.types" - -interface APIKeyItem { - id: string - workspace_id: string - client_id: string - name: string - is_active: boolean -} - -export function useInboxKey(workspaceId: string | undefined) { - const [creds, setCreds] = useState(null) - const [isLoading, setIsLoading] = useState(() => !!workspaceId) - const [error, setError] = useState(null) - - useEffect(() => { - let cancelled = false - if (!workspaceId) { - Promise.resolve().then(() => { - if (!cancelled) { - setCreds(null) - setIsLoading(false) - setError(null) - } - }) - return - } - - const cacheKey = `wpd_inbox_creds_${workspaceId}` - - const bootstrap = async () => { - await Promise.resolve() // yield to avoid synchronous state update in effect - setIsLoading(true) - setError(null) - - try { - // 1. Check local storage cache - const cached = localStorage.getItem(cacheKey) - if (cached) { - try { - const parsed = JSON.parse(cached) as InboxCredentials - if (parsed.clientId && parsed.clientSecret) { - if (!cancelled) { - setCreds(parsed) - setIsLoading(false) - } - return - } - } catch { - localStorage.removeItem(cacheKey) - } - } - - // 2. Fetch keys list from backend - const listRes = await apiFetch(`/api/v1/workspaces/${workspaceId}/api-keys`) - if (!listRes.ok) { - throw new Error("Failed to retrieve workspace API keys") - } - const keys = (await listRes.json()) as APIKeyItem[] - - const portalKey = keys.find((k) => k.name === "Portal UI" && k.is_active) - - let finalCreds: InboxCredentials - - if (portalKey) { - // Key exists but secret is hidden; regenerate to get new plaintext secret - const regenRes = await apiFetch( - `/api/v1/workspaces/${workspaceId}/api-keys/${portalKey.id}/regenerate`, - { method: "POST" } - ) - if (!regenRes.ok) { - throw new Error("Failed to regenerate Portal UI API credentials") - } - const data = (await regenRes.json()) as { client_secret: string } - finalCreds = { - clientId: portalKey.client_id, - clientSecret: data.client_secret, - } - } else { - // No Portal UI key exists; create one - const createRes = await apiFetch(`/api/v1/workspaces/${workspaceId}/api-keys`, { - method: "POST", - body: JSON.stringify({ name: "Portal UI" }), - }) - if (!createRes.ok) { - throw new Error("Failed to auto-provision Portal UI API credentials") - } - const data = (await createRes.json()) as { client_id: string; client_secret: string } - finalCreds = { - clientId: data.client_id, - clientSecret: data.client_secret, - } - } - - // 3. Cache and update state - localStorage.setItem(cacheKey, JSON.stringify(finalCreds)) - if (!cancelled) { - setCreds(finalCreds) - } - } catch (err) { - if (!cancelled) { - setError(err instanceof Error ? err.message : "Authentication error") - } - } finally { - if (!cancelled) { - setIsLoading(false) - } - } - } - - void bootstrap() - - return () => { - cancelled = true - } - }, [workspaceId]) - - return { creds, isLoading, error } -} diff --git a/frontend/src/features/inbox/inbox.api.ts b/frontend/src/features/inbox/inbox.api.ts index d2d8942c..4e82c5f2 100644 --- a/frontend/src/features/inbox/inbox.api.ts +++ b/frontend/src/features/inbox/inbox.api.ts @@ -1,12 +1,6 @@ import { apiFetch, getToken } from "@/core/api/client" -import type { EmailTemplate, InboxCredentials, LogRow, StoredEmail } from "./inbox.types" - -function inboxAuthHeaders(creds: InboxCredentials): HeadersInit { - return { - "X-Api-Client-Id": creds.clientId, - "X-Api-Client-Secret": creds.clientSecret, - } -} +import type { EmailTemplate, InboxEmailPage, LogRow, StoredEmail } from "./inbox.types" +import { parseLogsResponse } from "./logs.schema" export async function fetchLogs( workspaceId: string, @@ -29,8 +23,8 @@ export async function fetchLogs( const err = (await res.json().catch(() => ({}))) as { message?: string } return { ok: false, message: err.message ?? "Failed to fetch logs" } } - const data = (await res.json()) as { items: LogRow[]; total: number } - return { ok: true, items: data.items || [], total: data.total || 0 } + const data = parseLogsResponse(await res.json()) + return { ok: true, items: data.items, total: data.total } } catch (err) { return { ok: false, message: err instanceof Error ? err.message : "Failed to fetch logs" } } @@ -57,36 +51,70 @@ export async function sendTestRequest( } } +async function parseApiError(res: Response, fallback: string): Promise { + try { + const err = (await res.json()) as { message?: string; error?: string } + return err.message ?? err.error ?? fallback + } catch { + return fallback + } +} + export async function fetchInboxEmails( workspaceId: string, - creds: InboxCredentials -): Promise<{ ok: true; items: StoredEmail[] } | { ok: false; message?: string }> { + params: { limit?: number; cursor?: string } = {} +): Promise<{ ok: true; page: InboxEmailPage } | { ok: false; message?: string }> { try { - const res = await apiFetch(`/api/v1/workspaces/${workspaceId}/inbox/emails`, { - headers: inboxAuthHeaders(creds), - }) + const query = new URLSearchParams() + if (params.limit !== undefined) { + query.set("limit", String(params.limit)) + } + if (params.cursor) { + query.set("cursor", params.cursor) + } + const qs = query.toString() + const path = `/api/v1/workspaces/${workspaceId}/inbox/emails${qs ? `?${qs}` : ""}` + const res = await apiFetch(path) if (!res.ok) { - return { ok: false, message: "Failed to fetch email inbox messages" } + return { ok: false, message: await parseApiError(res, "Failed to fetch email inbox messages") } } - const data = (await res.json()) as StoredEmail[] - const sorted = [...data].sort( - (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime() - ) - return { ok: true, items: sorted } + const raw = await res.json() + const page: InboxEmailPage = Array.isArray(raw) + ? { items: raw, has_more: false } + : { + items: raw.items ?? [], + next_cursor: raw.next_cursor, + has_more: raw.has_more ?? false, + } + return { ok: true, page } } catch (err) { return { ok: false, message: err instanceof Error ? err.message : "Failed to load emails" } } } +export async function fetchInboxEmailById( + workspaceId: string, + messageId: string +): Promise<{ ok: true; item: StoredEmail } | { ok: false; message?: string }> { + try { + const res = await apiFetch(`/api/v1/workspaces/${workspaceId}/inbox/emails/${messageId}`) + if (!res.ok) { + return { ok: false, message: "Failed to fetch email" } + } + const item = (await res.json()) as StoredEmail + return { ok: true, item } + } catch (err) { + return { ok: false, message: err instanceof Error ? err.message : "Failed to fetch email" } + } +} + export async function deleteInboxEmail( workspaceId: string, - messageId: string, - creds: InboxCredentials + messageId: string ): Promise<{ ok: true } | { ok: false; message?: string }> { try { const res = await apiFetch(`/api/v1/workspaces/${workspaceId}/inbox/emails/${messageId}`, { method: "DELETE", - headers: inboxAuthHeaders(creds), }) if (!res.ok) { return { ok: false, message: "Failed to delete email message" } @@ -97,15 +125,10 @@ export async function deleteInboxEmail( } } -export function buildInboxEventsUrl( - workspaceId: string, - creds: InboxCredentials -): string { +export function buildInboxEventsUrl(workspaceId: string): string { const token = getToken() const params = new URLSearchParams({ access_token: token ?? "", - client_id: creds.clientId, - client_secret: creds.clientSecret, }) return `/api/v1/workspaces/${workspaceId}/inbox/events?${params.toString()}` } diff --git a/frontend/src/features/inbox/inbox.types.ts b/frontend/src/features/inbox/inbox.types.ts index 215e3810..54453171 100644 --- a/frontend/src/features/inbox/inbox.types.ts +++ b/frontend/src/features/inbox/inbox.types.ts @@ -35,9 +35,10 @@ export interface StoredEmail { } } -export type InboxCredentials = { - clientId: string - clientSecret: string +export type InboxEmailPage = { + items: StoredEmail[] + next_cursor?: string + has_more: boolean } export type EmailTemplate = { diff --git a/frontend/src/features/inbox/logs.schema.ts b/frontend/src/features/inbox/logs.schema.ts new file mode 100644 index 00000000..5ea1f80b --- /dev/null +++ b/frontend/src/features/inbox/logs.schema.ts @@ -0,0 +1,33 @@ +import { z } from "zod" + +import type { LogRow } from "./inbox.types" + +const logRowSchema = z.object({ + id: z.string(), + workspace_id: z.string(), + api_key_id: z.string().optional(), + channel_type: z.string(), + http_method: z.string(), + status_code: z.number(), + endpoint: z.string(), + provider_name: z.string().optional(), + request_id: z.string().optional(), + duration_ms: z.number().optional(), + error_message: z.string().optional(), + created_at: z.string(), + source_name: z.string().optional(), + client_id: z.string().optional(), +}) + +const logsResponseSchema = z.object({ + items: z.array(logRowSchema), + total: z.number(), +}) + +export function parseLogsResponse(raw: unknown): { items: LogRow[]; total: number } { + const parsed = logsResponseSchema.safeParse(raw) + if (!parsed.success) { + throw new Error("Invalid logs response from server") + } + return parsed.data +} diff --git a/frontend/src/features/inbox/pages/email-inbox.page.tsx b/frontend/src/features/inbox/pages/email-inbox.page.tsx index b5a9a4f1..6b7870bc 100644 --- a/frontend/src/features/inbox/pages/email-inbox.page.tsx +++ b/frontend/src/features/inbox/pages/email-inbox.page.tsx @@ -5,67 +5,155 @@ import { Button } from "@/components/ui/button" import { Icon } from "@/components/ui/icon" import { Spinner } from "@/components/ui/spinner" import { ROUTES } from "@/core/router/routes" -import { buildInboxEventsUrl, deleteInboxEmail, fetchInboxEmails } from "../inbox.api" -import { useInboxKey } from "../hooks/use-inbox-key.hook" +import { + buildInboxEventsUrl, + deleteInboxEmail, + fetchInboxEmailById, + fetchInboxEmails, +} from "../inbox.api" import { EmailList } from "../components/email-list" import { EmailContent } from "../components/email-content" import type { StoredEmail } from "../inbox.types" +const PAGE_SIZE = 50 + export function EmailInboxPage() { const navigate = useNavigate() const { wid } = useParams<{ wid: string }>() - const { creds, isLoading: credsLoading, error: credsError } = useInboxKey(wid) const [messages, setMessages] = useState([]) const [selectedMessageId, setSelectedMessageId] = useState(null) const [isLoading, setIsLoading] = useState(true) + const [isLoadingMore, setIsLoadingMore] = useState(false) const [isDeleting, setIsDeleting] = useState(false) const [error, setError] = useState(null) + const [nextCursor, setNextCursor] = useState() + const [hasMore, setHasMore] = useState(false) const fetchEmails = useCallback(async () => { - if (!wid || !creds) return - await Promise.resolve() + if (!wid) return setError(null) + setIsLoading(true) - const result = await fetchInboxEmails(wid, creds) + const result = await fetchInboxEmails(wid, { limit: PAGE_SIZE }) if (!result.ok) { setError(result.message ?? "Failed to load emails") setIsLoading(false) return } - setMessages(result.items) - if (result.items.length > 0) { - setSelectedMessageId((prev) => prev ?? result.items[0].id) + setMessages(result.page.items ?? []) + setNextCursor(result.page.next_cursor) + setHasMore(result.page.has_more) + if ((result.page.items ?? []).length > 0) { + setSelectedMessageId((prev) => prev ?? (result.page.items ?? [])[0].id) } setIsLoading(false) - }, [wid, creds]) + }, [wid]) + + const loadMoreEmails = useCallback(async () => { + if (!wid || !nextCursor || isLoadingMore) return + setIsLoadingMore(true) + + const result = await fetchInboxEmails(wid, { limit: PAGE_SIZE, cursor: nextCursor }) + if (!result.ok) { + setError(result.message ?? "Failed to load more emails") + setIsLoadingMore(false) + return + } + + setMessages((prev) => [...(prev ?? []), ...(result.page.items ?? [])]) + setNextCursor(result.page.next_cursor) + setHasMore(result.page.has_more) + setIsLoadingMore(false) + }, [wid, nextCursor, isLoadingMore]) useEffect(() => { - if (creds) { - Promise.resolve().then(() => { - void fetchEmails() - }) + let cancelled = false + ;(async () => { + if (!wid) { + setIsLoading(false) + return + } + setError(null) + setIsLoading(true) + + const result = await fetchInboxEmails(wid, { limit: PAGE_SIZE }) + if (cancelled) return + + if (!result.ok) { + setError(result.message ?? "Failed to load emails") + setIsLoading(false) + return + } + + setMessages(result.page.items ?? []) + setNextCursor(result.page.next_cursor) + setHasMore(result.page.has_more) + if ((result.page.items ?? []).length > 0) { + setSelectedMessageId((prev) => prev ?? (result.page.items ?? [])[0].id) + } + setIsLoading(false) + })() + + return () => { + cancelled = true } - }, [creds, fetchEmails]) + }, [wid]) useEffect(() => { - if (!wid || !creds) return + if (!wid) return - const sseUrl = buildInboxEventsUrl(wid, creds) + const sseUrl = buildInboxEventsUrl(wid) let eventSource: EventSource | null = null try { eventSource = new EventSource(sseUrl) eventSource.onmessage = (event) => { try { - const parsed = JSON.parse(event.data) as { type: string } - if ( - parsed.type === "email_received" || - parsed.type === "email_deleted" || - parsed.type === "messages_cleared" - ) { - void fetchEmails() + const parsed = JSON.parse(event.data) as { + type: string + data?: { id?: string } | string | null + } + + if (parsed.type === "email_received") { + const id = + typeof parsed.data === "object" && parsed.data !== null + ? parsed.data.id + : undefined + if (!id) return + void fetchInboxEmailById(wid, id).then((result) => { + if (!result.ok) return + setMessages((prev) => { + if (prev.some((m) => m.id === result.item.id)) return prev + return [result.item, ...prev] + }) + setSelectedMessageId((current) => current ?? id) + }) + return + } + + if (parsed.type === "email_deleted") { + const id = typeof parsed.data === "string" ? parsed.data : undefined + if (!id) return + setMessages((prev) => { + const remaining = prev.filter((m) => m.id !== id) + setSelectedMessageId((current) => { + if (current === id) { + return remaining.length > 0 ? remaining[0].id : null + } + return current + }) + return remaining + }) + return + } + + if (parsed.type === "messages_cleared") { + setMessages([]) + setSelectedMessageId(null) + setNextCursor(undefined) + setHasMore(false) } } catch (err) { console.error("Failed to parse SSE event data:", err) @@ -78,12 +166,12 @@ export function EmailInboxPage() { return () => { eventSource?.close() } - }, [wid, creds, fetchEmails]) + }, [wid]) const handleDeleteMessage = async (messageId: string) => { - if (!wid || !creds) return + if (!wid) return setIsDeleting(true) - const result = await deleteInboxEmail(wid, messageId, creds) + const result = await deleteInboxEmail(wid, messageId) if (!result.ok) { alert(result.message ?? "Failed to delete email") setIsDeleting(false) @@ -109,8 +197,7 @@ export function EmailInboxPage() { } } - const activeMessage = messages.find((m) => m.id === selectedMessageId) || null - const isBootstrapLoading = credsLoading || (isLoading && !!creds) + const activeMessage = (messages ?? []).find((m) => m.id === selectedMessageId) ?? null return (
@@ -125,8 +212,8 @@ export function EmailInboxPage() {
-
- {credsError || error ? ( + {error ? (
Could not connect to Simulated Inbox

- {credsError || error} + {error}

- ) : isBootstrapLoading ? ( + ) : isLoading ? (
- Connecting to secure simulated inbox... + Loading simulated inbox...
- ) : messages.length === 0 ? ( + ) : (messages ?? []).length === 0 ? (

Inbox is empty

- Send an email to `/v1/email` using this workspace API key to see it captured here in real-time. + Send a test email or POST to `/v1/email` to see messages captured here in real-time.

) : (
void loadMoreEmails()} /> {activeMessage ? ( { const q = searchQuery.toLowerCase() return ( - log.endpoint.toLowerCase().includes(q) || - log.http_method.toLowerCase().includes(q) || - String(log.status_code).includes(q) || - (log.source_name && log.source_name.toLowerCase().includes(q)) + (log.endpoint ?? "").toLowerCase().includes(q) || + (log.http_method ?? "").toLowerCase().includes(q) || + String(log.status_code ?? "").includes(q) || + (log.source_name ?? "").toLowerCase().includes(q) ) }) diff --git a/frontend/src/features/settings/hooks/use-settings.hook.ts b/frontend/src/features/settings/hooks/use-settings.hook.ts index 137140c5..b9db1670 100644 --- a/frontend/src/features/settings/hooks/use-settings.hook.ts +++ b/frontend/src/features/settings/hooks/use-settings.hook.ts @@ -51,6 +51,7 @@ export function useSettings(workspaceId: string) { async function saveSettings(patch: WorkspaceSettingsPatch) { await patchSettings(workspaceId, patch) setSettings((prev) => parseWorkspaceSettings({ ...prev, ...patch })) + setError(null) } async function addApiKey(name: string) { diff --git a/frontend/src/features/settings/pages/settings.page.tsx b/frontend/src/features/settings/pages/settings.page.tsx index 17fde658..03427c36 100644 --- a/frontend/src/features/settings/pages/settings.page.tsx +++ b/frontend/src/features/settings/pages/settings.page.tsx @@ -97,22 +97,41 @@ function DispatchSettingsPanel({ initialConfig, onSave }: DispatchSettingsPanelP const [mode, setMode] = useState(initialConfig.mode) const [storeMessageContent, setStoreMessageContent] = useState(initialConfig.storeMessageContent) const [isSaving, setIsSaving] = useState(false) + const [saveError, setSaveError] = useState(null) + const [saveSuccess, setSaveSuccess] = useState(false) const currentConfig: MessageDispatchConfig = { mode, storeMessageContent } const isDirty = !dispatchConfigsEqual(currentConfig, initialConfig) async function handleSave() { setIsSaving(true) + setSaveError(null) + setSaveSuccess(false) try { await onSave({ message_dispatch_mode: mode, store_message_content: toStoreMessageContentSetting(storeMessageContent), }) + setSaveSuccess(true) + } catch (err) { + setSaveError(err instanceof Error ? err.message : "Failed to save settings") } finally { setIsSaving(false) } } + function handleModeChange(nextMode: MessageDispatchMode) { + setMode(nextMode) + setSaveError(null) + setSaveSuccess(false) + } + + function handleStoreContentChange(checked: boolean) { + setStoreMessageContent(checked) + setSaveError(null) + setSaveSuccess(false) + } + return (
@@ -125,7 +144,7 @@ function DispatchSettingsPanel({ initialConfig, onSave }: DispatchSettingsPanelP label="Memory" description="Capture messages in memory for development and testing." checked={mode === "memory"} - onChange={() => setMode("memory")} + onChange={() => handleModeChange("memory")} /> setMode("provider")} + onChange={() => handleModeChange("provider")} />
@@ -148,10 +167,22 @@ function DispatchSettingsPanel({ initialConfig, onSave }: DispatchSettingsPanelP setStoreMessageContent(checked === true)} + onCheckedChange={(checked) => handleStoreContentChange(checked === true)} />
+ {saveError ? ( +

+ {saveError} +

+ ) : null} + + {saveSuccess ? ( +

+ Dispatch settings saved. +

+ ) : null} + @@ -295,7 +326,7 @@ export function SettingsPage() { diff --git a/frontend/src/features/settings/settings.api.ts b/frontend/src/features/settings/settings.api.ts index 79b003bb..2c06eeeb 100644 --- a/frontend/src/features/settings/settings.api.ts +++ b/frontend/src/features/settings/settings.api.ts @@ -3,6 +3,16 @@ import { apiFetch } from "@/core/api/client" import { parseWorkspaceSettings } from "./settings.schema" import type { ApiKey, WorkspaceSettings, WorkspaceSettingsPatch } from "./settings.types" +async function readApiErrorMessage(res: Response, fallback: string): Promise { + try { + const body = (await res.json()) as { message?: string } + const message = body.message?.trim() + return message || fallback + } catch { + return fallback + } +} + export async function getSettings(workspaceId: string): Promise { const res = await apiFetch(`/api/v1/workspaces/${workspaceId}/settings`) if (!res.ok) { @@ -18,7 +28,7 @@ export async function patchSettings(workspaceId: string, body: WorkspaceSettings body: JSON.stringify(body), }) if (!res.ok) { - throw new Error("Failed to save settings") + throw new Error(await readApiErrorMessage(res, "Failed to save settings")) } } diff --git a/internal/app/wire.go b/internal/app/wire.go index f76403c2..8628b1a5 100644 --- a/internal/app/wire.go +++ b/internal/app/wire.go @@ -66,7 +66,7 @@ func Wire(cfg *Config, sysLogger *pkglogger.Logger) (*Application, error) { if err := gateEngine.LoadPolicy(startCtx); err != nil { return nil, fmt.Errorf("load RBAC policies: %w", err) } - authGate := authgate.NewGoGateAdapter(gateEngine) + authGate := authgate.NewGoGateAdapter(gateEngine, pgClient.GetDB(startCtx)) // ── Encryption service ────────────────────────────────────────────────── encKey := cfg.EncryptionKey @@ -125,6 +125,12 @@ func Wire(cfg *Config, sysLogger *pkglogger.Logger) (*Application, error) { inboxStore := inbox.NewStore() memoryStore := memory.GetStore() + var portalInboxHandler *handler.PortalInboxHandler + inboxWriter := inbox.NewNotifyingWriter(inboxStore, inbox.PublishFunc(func(workspaceID, eventType string, data any) { + portalInboxHandler.PublishInboxEvent(workspaceID, eventType, data) + })) + portalInboxHandler = handler.NewPortalInboxHandler(inboxStore, inboxWriter) + // ── Auth service ────────────────────────────────────────────────────── authService := service.NewAuthService( userRepo, @@ -137,7 +143,7 @@ func Wire(cfg *Config, sysLogger *pkglogger.Logger) (*Application, error) { ) // ── Gateway service ───────────────────────────────────────────────────── - gatewaySvc := service.NewGatewayService(intgRepo, tmplRepo, settingsRepo, inboxStore, logRepo) + gatewaySvc := service.NewGatewayService(intgRepo, tmplRepo, settingsRepo, inboxWriter, logRepo) // ── Handlers ───────────────────────────────────────────────────────────── // Portal is always enabled — configuration, templates, and inbox require it. @@ -146,7 +152,6 @@ func Wire(cfg *Config, sysLogger *pkglogger.Logger) (*Application, error) { portalWorkspaceHandler := handler.NewPortalWorkspaceHandler(portalSvc) portalIntegrationHandler := handler.NewPortalIntegrationHandler(portalSvc) portalTemplateHandler := handler.NewPortalTemplateHandler(portalSvc) - portalInboxHandler := handler.NewPortalInboxHandler(inboxStore, inboxStore) gatewayHandler := handler.NewGatewayHandler(gatewaySvc) // ── Router ────────────────────────────────────────────────────────────── diff --git a/internal/core/port/inbox.go b/internal/core/port/inbox.go index 7b67f829..000191a3 100644 --- a/internal/core/port/inbox.go +++ b/internal/core/port/inbox.go @@ -39,10 +39,18 @@ type StoredChat struct { Chat contracts.ChatMessage `json:"chat"` } +// InboxEmailPage is a paginated slice of captured emails (newest first). +type InboxEmailPage struct { + Items []StoredEmail `json:"items"` + NextCursor string `json:"next_cursor,omitempty"` + HasMore bool `json:"has_more"` +} + // InboxReader is the read side of the in-process message capture store. type InboxReader interface { StatsForWorkspace(workspaceID string) map[string]int EmailsForWorkspace(workspaceID string) []StoredEmail + ListEmailsForWorkspace(workspaceID string, limit int, cursor string) InboxEmailPage EmailByIDForWorkspace(id, workspaceID string) (StoredEmail, bool) DeleteEmailByIDForWorkspace(id, workspaceID string) bool SMSForWorkspace(workspaceID string) []StoredSMS diff --git a/internal/core/port/portal.go b/internal/core/port/portal.go index bce329c9..afee1645 100644 --- a/internal/core/port/portal.go +++ b/internal/core/port/portal.go @@ -44,4 +44,5 @@ type AuthorizationGate interface { RemoveRole(ctx context.Context, modelType, modelID, teamID, roleName string) error GetRoleNames(ctx context.Context, modelType, modelID, teamID string) ([]string, error) GetAllPermissions(ctx context.Context, modelType, modelID, teamID string) ([]string, error) + GetPermissionsForTeams(ctx context.Context, modelType, modelID string, teamIDs []string) (map[string][]string, error) } diff --git a/internal/core/service/portal_service.go b/internal/core/service/portal_service.go index e7584701..e1f52b1c 100644 --- a/internal/core/service/portal_service.go +++ b/internal/core/service/portal_service.go @@ -208,32 +208,39 @@ func (s *PortalService) ListWorkspaces(ctx context.Context, userID string) ([]do return nil, err } + memberWorkspaceIDs := make([]string, 0, len(workspaces)) for i := range workspaces { w := &workspaces[i] - // Get role in this workspace - role, err := s.members.GetRole(ctx, w.ID, userID) - if err != nil { - // If not a member, check if it's public - if w.Visibility == "public" { - w.Role = "viewer" - w.Permissions = []string{ - domain.PermissionWorkspacesRead, - domain.PermissionMembersRead, - domain.PermissionAPIKeysRead, - domain.PermissionLogsRead, - domain.PermissionIntegrationsRead, - domain.PermissionTemplatesRead, - domain.PermissionSettingsRead, - domain.PermissionInvitationsRead, - } - } + if w.Role != "" { + memberWorkspaceIDs = append(memberWorkspaceIDs, w.ID) continue } + if w.Visibility == "public" { + w.Role = "viewer" + w.Permissions = []string{ + domain.PermissionWorkspacesRead, + domain.PermissionMembersRead, + domain.PermissionAPIKeysRead, + domain.PermissionLogsRead, + domain.PermissionIntegrationsRead, + domain.PermissionTemplatesRead, + domain.PermissionSettingsRead, + domain.PermissionInvitationsRead, + } + } + } + + permsByTeam, err := s.gate.GetPermissionsForTeams(ctx, "users", userID, memberWorkspaceIDs) + if err != nil { + return nil, fmt.Errorf("load workspace permissions: %w", err) + } - w.Role = role - // Get permissions from gogate - perms, err := s.gate.GetAllPermissions(ctx, "users", userID, w.ID) - if err == nil { + for i := range workspaces { + w := &workspaces[i] + if w.Role == "" { + continue + } + if perms, ok := permsByTeam[w.ID]; ok { w.Permissions = perms } } @@ -539,7 +546,7 @@ func (s *PortalService) PatchSettings(ctx context.Context, workspaceID string, k } } if !hasConnected { - return fmt.Errorf("%w: first configure a provider then you can enable it", port.ErrInvalidInput) + return fmt.Errorf("%w: configure a provider in Integrations before switching to provider mode", port.ErrInvalidInput) } } diff --git a/internal/infrastructure/authgate/gate_adapter.go b/internal/infrastructure/authgate/gate_adapter.go index 20717186..d5af11fb 100644 --- a/internal/infrastructure/authgate/gate_adapter.go +++ b/internal/infrastructure/authgate/gate_adapter.go @@ -2,7 +2,11 @@ package authgate import ( "context" + "sort" + "github.com/lib/pq" + + "github.com/weprodev/go-pkg/pgsql" gogate "github.com/weprodev/wpd-gogate" "github.com/weprodev/wpd-message-gateway/internal/core/port" @@ -10,10 +14,11 @@ import ( type GoGateAdapter struct { gate *gogate.Gate + db pgsql.DBTX } -func NewGoGateAdapter(gate *gogate.Gate) port.AuthorizationGate { - return &GoGateAdapter{gate: gate} +func NewGoGateAdapter(gate *gogate.Gate, db pgsql.DBTX) port.AuthorizationGate { + return &GoGateAdapter{gate: gate, db: db} } func (a *GoGateAdapter) AssignRole(ctx context.Context, modelType, modelID, teamID, roleName string) error { @@ -31,3 +36,67 @@ func (a *GoGateAdapter) GetRoleNames(ctx context.Context, modelType, modelID, te func (a *GoGateAdapter) GetAllPermissions(ctx context.Context, modelType, modelID, teamID string) ([]string, error) { return a.gate.Model(modelType, modelID, teamID).GetAllPermissions(ctx) } + +func (a *GoGateAdapter) GetPermissionsForTeams( + ctx context.Context, + modelType, modelID string, + teamIDs []string, +) (map[string][]string, error) { + if len(teamIDs) == 0 { + return map[string][]string{}, nil + } + + query := ` + SELECT team_id::text, permission FROM ( + SELECT mhr.team_id, p.name AS permission + FROM model_has_roles mhr + JOIN roles r ON r.id = mhr.role_id + JOIN role_has_permissions rhp ON rhp.role_id = r.id + JOIN permissions p ON p.id = rhp.permission_id + WHERE mhr.model_type = $1 AND mhr.model_id::text = $2 AND mhr.team_id = ANY($3::uuid[]) + UNION + SELECT mhp.team_id, p.name AS permission + FROM model_has_permissions mhp + JOIN permissions p ON p.id = mhp.permission_id + WHERE mhp.model_type = $1 AND mhp.model_id::text = $2 AND mhp.team_id = ANY($3::uuid[]) + ) AS combined + ORDER BY team_id, permission + ` + + rows, err := a.db.QueryContext(ctx, query, modelType, modelID, pq.Array(teamIDs)) + if err != nil { + return nil, err + } + defer rows.Close() //nolint:errcheck + + out := make(map[string][]string, len(teamIDs)) + for rows.Next() { + var teamID, permission string + if err := rows.Scan(&teamID, &permission); err != nil { + return nil, err + } + out[teamID] = append(out[teamID], permission) + } + if err := rows.Err(); err != nil { + return nil, err + } + + for teamID, perms := range out { + sort.Strings(perms) + out[teamID] = dedupeSortedStrings(perms) + } + return out, nil +} + +func dedupeSortedStrings(values []string) []string { + if len(values) == 0 { + return values + } + out := values[:1] + for _, v := range values[1:] { + if v != out[len(out)-1] { + out = append(out, v) + } + } + return out +} diff --git a/internal/infrastructure/inbox/notifying_writer.go b/internal/infrastructure/inbox/notifying_writer.go new file mode 100644 index 00000000..1a3d0702 --- /dev/null +++ b/internal/infrastructure/inbox/notifying_writer.go @@ -0,0 +1,66 @@ +package inbox + +import ( + "context" + + "github.com/weprodev/wpd-message-gateway/internal/core/port" + "github.com/weprodev/wpd-message-gateway/pkg/contracts" +) + +var _ port.InboxWriter = (*NotifyingWriter)(nil) + +// EventPublisher receives inbox change notifications for SSE subscribers. +type EventPublisher interface { + Publish(workspaceID, eventType string, data any) +} + +// PublishFunc adapts a function to EventPublisher. +type PublishFunc func(workspaceID, eventType string, data any) + +func (f PublishFunc) Publish(workspaceID, eventType string, data any) { + if f != nil { + f(workspaceID, eventType, data) + } +} + +// NotifyingWriter wraps an InboxWriter and publishes events after successful writes. +type NotifyingWriter struct { + inner port.InboxWriter + pub EventPublisher +} + +func NewNotifyingWriter(inner port.InboxWriter, pub EventPublisher) *NotifyingWriter { + return &NotifyingWriter{inner: inner, pub: pub} +} + +func (w *NotifyingWriter) WriteEmail(ctx context.Context, workspaceID string, email contracts.Email) (string, error) { + id, err := w.inner.WriteEmail(ctx, workspaceID, email) + if err == nil && w.pub != nil { + w.pub.Publish(workspaceID, "email_received", map[string]string{"id": id}) + } + return id, err +} + +func (w *NotifyingWriter) WriteSMS(ctx context.Context, workspaceID string, sms contracts.SMS) (string, error) { + id, err := w.inner.WriteSMS(ctx, workspaceID, sms) + if err == nil && w.pub != nil { + w.pub.Publish(workspaceID, "sms_received", map[string]string{"id": id}) + } + return id, err +} + +func (w *NotifyingWriter) WritePush(ctx context.Context, workspaceID string, push contracts.PushNotification) (string, error) { + id, err := w.inner.WritePush(ctx, workspaceID, push) + if err == nil && w.pub != nil { + w.pub.Publish(workspaceID, "push_received", map[string]string{"id": id}) + } + return id, err +} + +func (w *NotifyingWriter) WriteChat(ctx context.Context, workspaceID string, chat contracts.ChatMessage) (string, error) { + id, err := w.inner.WriteChat(ctx, workspaceID, chat) + if err == nil && w.pub != nil { + w.pub.Publish(workspaceID, "chat_received", map[string]string{"id": id}) + } + return id, err +} diff --git a/internal/infrastructure/inbox/store.go b/internal/infrastructure/inbox/store.go index d569e8b5..d85ab5dd 100644 --- a/internal/infrastructure/inbox/store.go +++ b/internal/infrastructure/inbox/store.go @@ -11,31 +11,51 @@ import ( "github.com/weprodev/wpd-message-gateway/pkg/contracts" ) +const ( + defaultInboxPageSize = 50 + maxInboxPageSize = 200 +) + var _ port.InboxReader = (*Store)(nil) var _ port.InboxWriter = (*Store)(nil) -// Store implements an in-memory message store that satisfies port.InboxReader and port.InboxWriter. +// Store implements an in-memory message store indexed by workspace for O(1) bucket lookup. type Store struct { - mu sync.RWMutex - emails []port.StoredEmail - sms []port.StoredSMS - pushes []port.StoredPush - chats []port.StoredChat + mu sync.RWMutex + emails map[string][]port.StoredEmail + sms map[string][]port.StoredSMS + pushes map[string][]port.StoredPush + chats map[string][]port.StoredChat } // NewStore creates a new in-memory store. func NewStore() *Store { - return &Store{} + return &Store{ + emails: make(map[string][]port.StoredEmail), + sms: make(map[string][]port.StoredSMS), + pushes: make(map[string][]port.StoredPush), + chats: make(map[string][]port.StoredChat), + } +} + +func clampInboxLimit(limit int) int { + if limit <= 0 { + return defaultInboxPageSize + } + if limit > maxInboxPageSize { + return maxInboxPageSize + } + return limit } // StatsForWorkspace returns counts for messages belonging to workspace. func (s *Store) StatsForWorkspace(workspaceID string) map[string]int { s.mu.RLock() defer s.mu.RUnlock() - nEmail := countWorkspace(s.emails, workspaceID) - nSMS := countWorkspaceSMS(s.sms, workspaceID) - nPush := countWorkspacePush(s.pushes, workspaceID) - nChat := countWorkspaceChat(s.chats, workspaceID) + nEmail := len(s.emails[workspaceID]) + nSMS := len(s.sms[workspaceID]) + nPush := len(s.pushes[workspaceID]) + nChat := len(s.chats[workspaceID]) return map[string]int{ "emails": nEmail, "sms": nSMS, @@ -45,63 +65,53 @@ func (s *Store) StatsForWorkspace(workspaceID string) map[string]int { } } -func countWorkspace(items []port.StoredEmail, workspaceID string) int { - n := 0 - for _, e := range items { - if e.WorkspaceID == workspaceID { - n++ - } - } - return n +func (s *Store) EmailsForWorkspace(workspaceID string) []port.StoredEmail { + page := s.ListEmailsForWorkspace(workspaceID, 0, "") + return page.Items } -func countWorkspaceSMS(items []port.StoredSMS, workspaceID string) int { - n := 0 - for _, e := range items { - if e.WorkspaceID == workspaceID { - n++ - } - } - return n -} +func (s *Store) ListEmailsForWorkspace(workspaceID string, limit int, cursor string) port.InboxEmailPage { + s.mu.RLock() + defer s.mu.RUnlock() -func countWorkspacePush(items []port.StoredPush, workspaceID string) int { - n := 0 - for _, e := range items { - if e.WorkspaceID == workspaceID { - n++ + all := s.emails[workspaceID] + limit = clampInboxLimit(limit) + start := 0 + if cursor != "" { + for i, item := range all { + if item.ID == cursor { + start = i + 1 + break + } } } - return n -} -func countWorkspaceChat(items []port.StoredChat, workspaceID string) int { - n := 0 - for _, e := range items { - if e.WorkspaceID == workspaceID { - n++ - } + end := start + limit + hasMore := end < len(all) + if end > len(all) { + end = len(all) } - return n -} -func (s *Store) EmailsForWorkspace(workspaceID string) []port.StoredEmail { - s.mu.RLock() - defer s.mu.RUnlock() - out := make([]port.StoredEmail, 0) - for _, e := range s.emails { - if e.WorkspaceID == workspaceID { - out = append(out, e) - } + items := make([]port.StoredEmail, 0, end-start) + if start < len(all) { + items = append(items, all[start:end]...) + } + nextCursor := "" + if hasMore && len(items) > 0 { + nextCursor = items[len(items)-1].ID + } + return port.InboxEmailPage{ + Items: items, + NextCursor: nextCursor, + HasMore: hasMore, } - return out } func (s *Store) EmailByIDForWorkspace(id, workspaceID string) (port.StoredEmail, bool) { s.mu.RLock() defer s.mu.RUnlock() - for _, e := range s.emails { - if e.ID == id && e.WorkspaceID == workspaceID { + for _, e := range s.emails[workspaceID] { + if e.ID == id { return e, true } } @@ -111,9 +121,10 @@ func (s *Store) EmailByIDForWorkspace(id, workspaceID string) (port.StoredEmail, func (s *Store) DeleteEmailByIDForWorkspace(id, workspaceID string) bool { s.mu.Lock() defer s.mu.Unlock() - for i, e := range s.emails { - if e.ID == id && e.WorkspaceID == workspaceID { - s.emails = append(s.emails[:i], s.emails[i+1:]...) + items := s.emails[workspaceID] + for i, e := range items { + if e.ID == id { + s.emails[workspaceID] = append(items[:i], items[i+1:]...) return true } } @@ -123,21 +134,15 @@ func (s *Store) DeleteEmailByIDForWorkspace(id, workspaceID string) bool { func (s *Store) SMSForWorkspace(workspaceID string) []port.StoredSMS { s.mu.RLock() defer s.mu.RUnlock() - out := make([]port.StoredSMS, 0) - for _, e := range s.sms { - if e.WorkspaceID == workspaceID { - out = append(out, e) - } - } - return out + return append([]port.StoredSMS(nil), s.sms[workspaceID]...) } func (s *Store) SMSByIDForWorkspace(id, workspaceID string) (port.StoredSMS, bool) { s.mu.RLock() defer s.mu.RUnlock() - for _, e := range s.sms { - if e.ID == id && e.WorkspaceID == workspaceID { - return e, true + for _, item := range s.sms[workspaceID] { + if item.ID == id { + return item, true } } return port.StoredSMS{}, false @@ -146,9 +151,10 @@ func (s *Store) SMSByIDForWorkspace(id, workspaceID string) (port.StoredSMS, boo func (s *Store) DeleteSMSByIDForWorkspace(id, workspaceID string) bool { s.mu.Lock() defer s.mu.Unlock() - for i, e := range s.sms { - if e.ID == id && e.WorkspaceID == workspaceID { - s.sms = append(s.sms[:i], s.sms[i+1:]...) + items := s.sms[workspaceID] + for i, e := range items { + if e.ID == id { + s.sms[workspaceID] = append(items[:i], items[i+1:]...) return true } } @@ -158,21 +164,15 @@ func (s *Store) DeleteSMSByIDForWorkspace(id, workspaceID string) bool { func (s *Store) PushForWorkspace(workspaceID string) []port.StoredPush { s.mu.RLock() defer s.mu.RUnlock() - out := make([]port.StoredPush, 0) - for _, e := range s.pushes { - if e.WorkspaceID == workspaceID { - out = append(out, e) - } - } - return out + return append([]port.StoredPush(nil), s.pushes[workspaceID]...) } func (s *Store) PushByIDForWorkspace(id, workspaceID string) (port.StoredPush, bool) { s.mu.RLock() defer s.mu.RUnlock() - for _, e := range s.pushes { - if e.ID == id && e.WorkspaceID == workspaceID { - return e, true + for _, item := range s.pushes[workspaceID] { + if item.ID == id { + return item, true } } return port.StoredPush{}, false @@ -181,9 +181,10 @@ func (s *Store) PushByIDForWorkspace(id, workspaceID string) (port.StoredPush, b func (s *Store) DeletePushByIDForWorkspace(id, workspaceID string) bool { s.mu.Lock() defer s.mu.Unlock() - for i, e := range s.pushes { - if e.ID == id && e.WorkspaceID == workspaceID { - s.pushes = append(s.pushes[:i], s.pushes[i+1:]...) + items := s.pushes[workspaceID] + for i, e := range items { + if e.ID == id { + s.pushes[workspaceID] = append(items[:i], items[i+1:]...) return true } } @@ -193,21 +194,15 @@ func (s *Store) DeletePushByIDForWorkspace(id, workspaceID string) bool { func (s *Store) ChatForWorkspace(workspaceID string) []port.StoredChat { s.mu.RLock() defer s.mu.RUnlock() - out := make([]port.StoredChat, 0) - for _, e := range s.chats { - if e.WorkspaceID == workspaceID { - out = append(out, e) - } - } - return out + return append([]port.StoredChat(nil), s.chats[workspaceID]...) } func (s *Store) ChatByIDForWorkspace(id, workspaceID string) (port.StoredChat, bool) { s.mu.RLock() defer s.mu.RUnlock() - for _, e := range s.chats { - if e.ID == id && e.WorkspaceID == workspaceID { - return e, true + for _, item := range s.chats[workspaceID] { + if item.ID == id { + return item, true } } return port.StoredChat{}, false @@ -216,9 +211,10 @@ func (s *Store) ChatByIDForWorkspace(id, workspaceID string) (port.StoredChat, b func (s *Store) DeleteChatByIDForWorkspace(id, workspaceID string) bool { s.mu.Lock() defer s.mu.Unlock() - for i, e := range s.chats { - if e.ID == id && e.WorkspaceID == workspaceID { - s.chats = append(s.chats[:i], s.chats[i+1:]...) + items := s.chats[workspaceID] + for i, e := range items { + if e.ID == id { + s.chats[workspaceID] = append(items[:i], items[i+1:]...) return true } } @@ -228,62 +224,23 @@ func (s *Store) DeleteChatByIDForWorkspace(id, workspaceID string) bool { func (s *Store) ClearWorkspace(workspaceID string) { s.mu.Lock() defer s.mu.Unlock() - s.emails = filterKeepOtherWorkspaces(s.emails, workspaceID) - s.sms = filterKeepOtherWorkspacesSMS(s.sms, workspaceID) - s.pushes = filterKeepOtherWorkspacesPush(s.pushes, workspaceID) - s.chats = filterKeepOtherWorkspacesChat(s.chats, workspaceID) -} - -func filterKeepOtherWorkspaces(items []port.StoredEmail, workspaceID string) []port.StoredEmail { - out := items[:0] - for _, e := range items { - if e.WorkspaceID != workspaceID { - out = append(out, e) - } - } - return out -} - -func filterKeepOtherWorkspacesSMS(items []port.StoredSMS, workspaceID string) []port.StoredSMS { - out := items[:0] - for _, e := range items { - if e.WorkspaceID != workspaceID { - out = append(out, e) - } - } - return out -} - -func filterKeepOtherWorkspacesPush(items []port.StoredPush, workspaceID string) []port.StoredPush { - out := items[:0] - for _, e := range items { - if e.WorkspaceID != workspaceID { - out = append(out, e) - } - } - return out -} - -func filterKeepOtherWorkspacesChat(items []port.StoredChat, workspaceID string) []port.StoredChat { - out := items[:0] - for _, e := range items { - if e.WorkspaceID != workspaceID { - out = append(out, e) - } - } - return out + delete(s.emails, workspaceID) + delete(s.sms, workspaceID) + delete(s.pushes, workspaceID) + delete(s.chats, workspaceID) } func (s *Store) WriteEmail(_ context.Context, workspaceID string, email contracts.Email) (string, error) { s.mu.Lock() defer s.mu.Unlock() id := uuid.New().String() - s.emails = append(s.emails, port.StoredEmail{ + item := port.StoredEmail{ ID: id, WorkspaceID: workspaceID, CreatedAt: time.Now(), Email: email, - }) + } + s.emails[workspaceID] = append([]port.StoredEmail{item}, s.emails[workspaceID]...) return id, nil } @@ -291,12 +248,13 @@ func (s *Store) WriteSMS(_ context.Context, workspaceID string, sms contracts.SM s.mu.Lock() defer s.mu.Unlock() id := uuid.New().String() - s.sms = append(s.sms, port.StoredSMS{ + item := port.StoredSMS{ ID: id, WorkspaceID: workspaceID, CreatedAt: time.Now(), SMS: sms, - }) + } + s.sms[workspaceID] = append([]port.StoredSMS{item}, s.sms[workspaceID]...) return id, nil } @@ -304,12 +262,13 @@ func (s *Store) WritePush(_ context.Context, workspaceID string, push contracts. s.mu.Lock() defer s.mu.Unlock() id := uuid.New().String() - s.pushes = append(s.pushes, port.StoredPush{ + item := port.StoredPush{ ID: id, WorkspaceID: workspaceID, CreatedAt: time.Now(), Push: push, - }) + } + s.pushes[workspaceID] = append([]port.StoredPush{item}, s.pushes[workspaceID]...) return id, nil } @@ -317,11 +276,12 @@ func (s *Store) WriteChat(_ context.Context, workspaceID string, chat contracts. s.mu.Lock() defer s.mu.Unlock() id := uuid.New().String() - s.chats = append(s.chats, port.StoredChat{ + item := port.StoredChat{ ID: id, WorkspaceID: workspaceID, CreatedAt: time.Now(), Chat: chat, - }) + } + s.chats[workspaceID] = append([]port.StoredChat{item}, s.chats[workspaceID]...) return id, nil } diff --git a/internal/infrastructure/repository/postgres/api_key_repository.go b/internal/infrastructure/repository/postgres/api_key_repository.go index 9508f175..2c141fa4 100644 --- a/internal/infrastructure/repository/postgres/api_key_repository.go +++ b/internal/infrastructure/repository/postgres/api_key_repository.go @@ -68,8 +68,12 @@ func (r *APIKeyRepository) GetByClientID(ctx context.Context, clientID string) ( } func (r *APIKeyRepository) UpdateLastUsedAt(ctx context.Context, id string) error { + // Throttle writes: inbox/SSE can hit this on every request. _, err := r.client.GetDB(ctx).ExecContext(ctx, - `UPDATE api_keys SET last_used_at = NOW() WHERE id = $1`, + `UPDATE api_keys + SET last_used_at = NOW() + WHERE id = $1 + AND (last_used_at IS NULL OR last_used_at < NOW() - INTERVAL '5 minutes')`, id, ) if err != nil { diff --git a/internal/infrastructure/repository/postgres/message_request_log_repository.go b/internal/infrastructure/repository/postgres/message_request_log_repository.go index 84356981..42026db7 100644 --- a/internal/infrastructure/repository/postgres/message_request_log_repository.go +++ b/internal/infrastructure/repository/postgres/message_request_log_repository.go @@ -46,34 +46,40 @@ func (r *MessageRequestLogRepository) Create(ctx context.Context, log *domain.Me if log.ProviderName != "" { provider = log.ProviderName } - return r.client.RunInTransaction(ctx, func(ctx context.Context) error { - err := r.client.GetDB(ctx).QueryRowContext(ctx, query, - log.WorkspaceID, apiKeyID, log.ChannelType, log.HTTPMethod, log.StatusCode, log.Endpoint, - provider, reqID, dur, errMsg, - ).Scan(&log.ID, &log.CreatedAt) - if err != nil { - slog.ErrorContext(ctx, "database error: failed to create message request log", "error", err, "endpoint", log.Endpoint) - return err - } - if log.Payload != nil { - log.Payload.LogID = log.ID - payloadQuery := ` - INSERT INTO message_request_payloads (log_id, request_body, response_body) - VALUES ($1, $2, $3) - RETURNING created_at - ` - err = r.client.GetDB(ctx).QueryRowContext(ctx, payloadQuery, - log.Payload.LogID, log.Payload.RequestBody, log.Payload.ResponseBody, - ).Scan(&log.Payload.CreatedAt) - if err != nil { - slog.ErrorContext(ctx, "database error: failed to create message request payload", "error", err, "log_id", log.ID) - return err - } + err := r.client.GetDB(ctx).QueryRowContext(ctx, query, + log.WorkspaceID, apiKeyID, log.ChannelType, log.HTTPMethod, log.StatusCode, log.Endpoint, + provider, reqID, dur, errMsg, + ).Scan(&log.ID, &log.CreatedAt) + if err != nil { + slog.ErrorContext(ctx, "database error: failed to create message request log", "error", err, "endpoint", log.Endpoint) + return err + } + + if log.Payload != nil { + if err := r.insertPayload(ctx, log); err != nil { + // Metadata logs must persist even when optional payload storage fails. + slog.WarnContext(ctx, "message request payload not stored", "error", err, "log_id", log.ID) } + } + + return nil +} - return nil - }) +func (r *MessageRequestLogRepository) insertPayload(ctx context.Context, log *domain.MessageRequestLog) error { + log.Payload.LogID = log.ID + payloadQuery := ` + INSERT INTO message_request_payloads (log_id, request_body, response_body) + VALUES ($1, $2, $3) + RETURNING created_at + ` + err := r.client.GetDB(ctx).QueryRowContext(ctx, payloadQuery, + log.Payload.LogID, log.Payload.RequestBody, log.Payload.ResponseBody, + ).Scan(&log.Payload.CreatedAt) + if err != nil { + return fmt.Errorf("insert message request payload: %w", err) + } + return nil } func (r *MessageRequestLogRepository) ListWithSource(ctx context.Context, q port.MessageLogQuery) ([]domain.MessageRequestLogWithSource, int, error) { @@ -96,22 +102,17 @@ func (r *MessageRequestLogRepository) ListWithSource(ctx context.Context, q port args = append(args, *q.To) } - countQuery := "SELECT COUNT(*) FROM message_request_logs l WHERE " + where - var total int - if err := db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil { - slog.ErrorContext(ctx, "database error: failed to count message request logs", "error", err) - return nil, 0, err - } - + limit, offset := clampLimitOffset(q.Limit, q.Offset) listQuery := fmt.Sprintf(` SELECT l.id, l.workspace_id, l.api_key_id, l.channel_type, l.http_method, l.status_code, l.endpoint, l.provider_name, l.request_id, l.duration_ms, l.error_message, l.created_at, - COALESCE(k.name, ''), COALESCE(k.client_id, '') + COALESCE(k.name, ''), COALESCE(k.client_id, ''), + COUNT(*) OVER() AS total_count FROM message_request_logs l LEFT JOIN api_keys k ON k.id = l.api_key_id WHERE %s ORDER BY l.created_at DESC - %s`, where, limitOffsetSQL(q.Limit, q.Offset)) + LIMIT %d OFFSET %d`, where, limit, offset) rows, err := db.QueryContext(ctx, listQuery, args...) if err != nil { @@ -121,6 +122,7 @@ func (r *MessageRequestLogRepository) ListWithSource(ctx context.Context, q port defer rows.Close() //nolint:errcheck var out []domain.MessageRequestLogWithSource + total := 0 for rows.Next() { var row domain.MessageRequestLogWithSource var apiKeyID sql.NullString @@ -128,14 +130,19 @@ func (r *MessageRequestLogRepository) ListWithSource(ctx context.Context, q port var dur sql.NullInt64 var errMsg sql.NullString var prov sql.NullString + var totalCount int64 if err := rows.Scan( &row.ID, &row.WorkspaceID, &apiKeyID, &row.ChannelType, &row.HTTPMethod, &row.StatusCode, &row.Endpoint, &prov, &reqID, &dur, &errMsg, &row.CreatedAt, &row.SourceName, &row.ClientID, + &totalCount, ); err != nil { slog.ErrorContext(ctx, "database error: failed to scan message request log", "error", err) return nil, 0, err } + if total == 0 { + total = int(totalCount) + } if apiKeyID.Valid { row.APIKeyID = apiKeyID.String } diff --git a/internal/infrastructure/repository/postgres/workspace_member_repository.go b/internal/infrastructure/repository/postgres/workspace_member_repository.go index 326a7746..6c1c35d1 100644 --- a/internal/infrastructure/repository/postgres/workspace_member_repository.go +++ b/internal/infrastructure/repository/postgres/workspace_member_repository.go @@ -76,7 +76,8 @@ func (r *WorkspaceMemberRepository) GetRole(ctx context.Context, workspaceID, us func (r *WorkspaceMemberRepository) ListMembers(ctx context.Context, workspaceID string) ([]domain.WorkspaceMember, error) { rows, err := r.client.GetDB(ctx).QueryContext(ctx, ` - SELECT wm.workspace_id, wm.user_id, r.name, wm.joined_at, u.email, COALESCE(u.display_name, '') + SELECT wm.workspace_id, wm.user_id, r.name, wm.joined_at, u.email, + COALESCE(NULLIF(TRIM(u.first_name || ' ' || u.last_name), ''), u.email) FROM workspace_members wm JOIN roles r ON r.id = wm.role_id INNER JOIN users u ON u.id = wm.user_id diff --git a/internal/infrastructure/repository/postgres/workspace_repository.go b/internal/infrastructure/repository/postgres/workspace_repository.go index c91a7d68..3b0acf64 100644 --- a/internal/infrastructure/repository/postgres/workspace_repository.go +++ b/internal/infrastructure/repository/postgres/workspace_repository.go @@ -140,11 +140,13 @@ func (r *WorkspaceRepository) ListForUser(ctx context.Context, userID string) ([ query := ` SELECT DISTINCT w.id, w.name, w.slug, u.email, w.status, CASE WHEN w.is_private THEN 'private' ELSE 'public' END, - w.hashed_pin_code, w.icon_key, w.created_at, w.updated_at + w.hashed_pin_code, w.icon_key, w.created_at, w.updated_at, + COALESCE(r.name, '') FROM workspaces w INNER JOIN users u ON u.id = w.owner_id - LEFT JOIN workspace_members wm ON wm.workspace_id = w.id - WHERE (wm.user_id = $1 OR w.is_private = false) AND w.status = 'active' + LEFT JOIN workspace_members wm ON wm.workspace_id = w.id AND wm.user_id = $1 + LEFT JOIN roles r ON r.id = wm.role_id + WHERE (wm.user_id IS NOT NULL OR w.is_private = false) AND w.status = 'active' ORDER BY w.name ASC ` rows, err := r.client.GetDB(ctx).QueryContext(ctx, query, userID) @@ -156,7 +158,7 @@ func (r *WorkspaceRepository) ListForUser(ctx context.Context, userID string) ([ out := []domain.Workspace{} for rows.Next() { - w, err := scanWorkspace(rows) + w, err := scanWorkspaceWithRole(rows) if err != nil { slog.ErrorContext(ctx, "database error: failed to scan workspace in list", "error", err, "user_id", userID) return nil, err @@ -169,3 +171,26 @@ func (r *WorkspaceRepository) ListForUser(ctx context.Context, userID string) ([ } return out, nil } + +func scanWorkspaceWithRole(scanner interface { + Scan(dest ...any) error +}) (domain.Workspace, error) { + var w domain.Workspace + var hashedPin, iconKey sql.NullString + var role sql.NullString + err := scanner.Scan(&w.ID, &w.Name, &w.Slug, &w.AdminEmail, &w.Status, &w.Visibility, + &hashedPin, &iconKey, &w.CreatedAt, &w.UpdatedAt, &role) + if err != nil { + return w, err + } + if hashedPin.Valid { + w.HashedPin = hashedPin.String + } + if iconKey.Valid { + w.IconKey = iconKey.String + } + if role.Valid { + w.Role = role.String + } + return w, nil +} diff --git a/internal/presentation/handler/portal_inbox_handler.go b/internal/presentation/handler/portal_inbox_handler.go index 121fdce2..548be158 100644 --- a/internal/presentation/handler/portal_inbox_handler.go +++ b/internal/presentation/handler/portal_inbox_handler.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "net/http" + "strconv" "sync" "github.com/labstack/echo/v4" @@ -16,7 +17,7 @@ import ( ) // PortalInboxHandler serves REST + SSE for the workspace-scoped message inbox (memory provider preview). -// Routes require JWT + workspace membership + workspace API key (see middleware). +// Routes require JWT + workspace membership (see middleware). type PortalInboxHandler struct { reader port.InboxReader writer port.InboxWriter @@ -41,7 +42,10 @@ func (h *PortalInboxHandler) HandleStats(c echo.Context) error { } func (h *PortalInboxHandler) HandleGetEmails(c echo.Context) error { - return c.JSON(http.StatusOK, h.reader.EmailsForWorkspace(workspaceIDParam(c))) + limit, _ := strconv.Atoi(c.QueryParam("limit")) + cursor := c.QueryParam("cursor") + page := h.reader.ListEmailsForWorkspace(workspaceIDParam(c), limit, cursor) + return c.JSON(http.StatusOK, page) } func (h *PortalInboxHandler) HandleGetEmailByID(c echo.Context) error { @@ -146,7 +150,6 @@ func (h *PortalInboxHandler) HandleIngestEmail(c echo.Context) error { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to store email"}) } - h.broadcast(w, "email_received", map[string]string{"id": id}) return c.JSON(http.StatusCreated, map[string]string{"id": id}) } @@ -161,7 +164,6 @@ func (h *PortalInboxHandler) HandleIngestSMS(c echo.Context) error { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to store sms"}) } - h.broadcast(workspaceIDParam(c), "sms_received", map[string]string{"id": id}) return c.JSON(http.StatusCreated, map[string]string{"id": id}) } @@ -176,7 +178,6 @@ func (h *PortalInboxHandler) HandleIngestPush(c echo.Context) error { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to store push"}) } - h.broadcast(workspaceIDParam(c), "push_received", map[string]string{"id": id}) return c.JSON(http.StatusCreated, map[string]string{"id": id}) } @@ -191,10 +192,14 @@ func (h *PortalInboxHandler) HandleIngestChat(c echo.Context) error { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to store chat"}) } - h.broadcast(workspaceIDParam(c), "chat_received", map[string]string{"id": id}) return c.JSON(http.StatusCreated, map[string]string{"id": id}) } +// PublishInboxEvent notifies SSE subscribers of an inbox change. +func (h *PortalInboxHandler) PublishInboxEvent(workspaceID, eventType string, data any) { + h.broadcast(workspaceID, eventType, data) +} + // HandleSSE streams Server-Sent Events for the workspace inbox (one connection per workspace). func (h *PortalInboxHandler) HandleSSE(c echo.Context) error { w := workspaceIDParam(c) diff --git a/internal/presentation/router.go b/internal/presentation/router.go index c1c9246b..1b96cf85 100644 --- a/internal/presentation/router.go +++ b/internal/presentation/router.go @@ -178,32 +178,31 @@ func (rt *Router) Setup() *echo.Echo { protected.POST("/workspaces/:wid/invitations", pw.CreateInvitation, customMiddleware.RequirePermission(rt.gate, rt.workspaceRepo, domain.PermissionInvitationsWrite)) protected.POST("/workspaces/:wid/send-test/:channel", ph.SendTest, customMiddleware.RequirePermission(rt.gate, rt.workspaceRepo, domain.PermissionSendTest)) - // Inbox API — always enabled (portal is always on). + // Inbox API — JWT + logs.read (same access model as message logs overview). if rt.memberRepo != nil { inbox := rt.portalInboxHandler inboxGroup := api.Group("/workspaces/:wid/inbox") inboxGroup.Use(customMiddleware.PortalJWTBearerOrQuery(rt.jwtSecret)) - inboxGroup.Use(customMiddleware.RequireWorkspaceMember(rt.memberRepo)) - inboxGroup.Use(customMiddleware.RequireWorkspaceAPIKey(rt.apiKeyRepo)) + inboxGroup.Use(customMiddleware.RequirePermission(rt.gate, rt.workspaceRepo, domain.PermissionLogsRead)) inboxGroup.GET("/stats", inbox.HandleStats) inboxGroup.GET("/emails", inbox.HandleGetEmails) inboxGroup.GET("/emails/:id", inbox.HandleGetEmailByID) - inboxGroup.DELETE("/emails/:id", inbox.HandleDeleteEmailByID) inboxGroup.GET("/sms", inbox.HandleGetSMS) inboxGroup.GET("/sms/:id", inbox.HandleGetSMSByID) - inboxGroup.DELETE("/sms/:id", inbox.HandleDeleteSMSByID) inboxGroup.GET("/push", inbox.HandleGetPush) inboxGroup.GET("/push/:id", inbox.HandleGetPushByID) - inboxGroup.DELETE("/push/:id", inbox.HandleDeletePushByID) inboxGroup.GET("/chat", inbox.HandleGetChat) inboxGroup.GET("/chat/:id", inbox.HandleGetChatByID) - inboxGroup.DELETE("/chat/:id", inbox.HandleDeleteChatByID) - inboxGroup.DELETE("/messages", inbox.HandleClearAll) inboxGroup.GET("/events", inbox.HandleSSE) + inboxGroup.DELETE("/emails/:id", inbox.HandleDeleteEmailByID, customMiddleware.RequirePermission(rt.gate, rt.workspaceRepo, domain.PermissionSendTest)) + inboxGroup.DELETE("/sms/:id", inbox.HandleDeleteSMSByID, customMiddleware.RequirePermission(rt.gate, rt.workspaceRepo, domain.PermissionSendTest)) + inboxGroup.DELETE("/push/:id", inbox.HandleDeletePushByID, customMiddleware.RequirePermission(rt.gate, rt.workspaceRepo, domain.PermissionSendTest)) + inboxGroup.DELETE("/chat/:id", inbox.HandleDeleteChatByID, customMiddleware.RequirePermission(rt.gate, rt.workspaceRepo, domain.PermissionSendTest)) + inboxGroup.DELETE("/messages", inbox.HandleClearAll, customMiddleware.RequirePermission(rt.gate, rt.workspaceRepo, domain.PermissionSendTest)) internal := api.Group("/workspaces/:wid/internal") internal.Use(customMiddleware.PortalJWTBearerOrQuery(rt.jwtSecret)) - internal.Use(customMiddleware.RequireWorkspaceMember(rt.memberRepo)) + internal.Use(customMiddleware.RequirePermission(rt.gate, rt.workspaceRepo, domain.PermissionSendTest)) internal.POST("/email", inbox.HandleIngestEmail) internal.POST("/sms", inbox.HandleIngestSMS) internal.POST("/push", inbox.HandleIngestPush) diff --git a/tests/bruno/Portal/Email/List Emails.bru b/tests/bruno/Portal/Email/List Emails.bru index ea09c26e..7c6230d5 100644 --- a/tests/bruno/Portal/Email/List Emails.bru +++ b/tests/bruno/Portal/Email/List Emails.bru @@ -12,23 +12,22 @@ get { headers { Authorization: Bearer {{portalJwt}} - X-Api-Client-Id: {{apiClientId}} - X-Api-Client-Secret: {{apiSecret}} } - tests { test("should return 200", function() { expect(res.status).to.equal(200); }); - test("should return array", function() { - expect(res.body).to.be.an("array"); + test("should return paginated page", function() { + expect(res.body).to.be.an("object"); + expect(res.body.items).to.be.an("array"); + expect(res.body).to.have.property("has_more"); }); test("should have email with required fields", function() { - if (res.body.length > 0) { - const email = res.body[0]; + if (res.body.items.length > 0) { + const email = res.body.items[0]; expect(email).to.have.property("id"); expect(email).to.have.property("email"); expect(email).to.have.property("created_at"); From ead3fa7291983351fdb6a3fb71207f379b19acbc Mon Sep 17 00:00:00 2001 From: MichaelAndish Date: Sat, 4 Jul 2026 11:46:27 +0200 Subject: [PATCH 10/20] feat: enhance inbox functionality and update code structure - Added `inbox_message_id` to `message_request_logs` for improved tracking of messages. - Updated Go module dependencies to include `github.com/lib/pq` for PostgreSQL support. - Refined documentation on DTO usage in the presentation layer, emphasizing the separation of domain and presentation concerns. - Introduced new utility functions for inbox log handling and improved email content display logic. - Removed deprecated `SendTestModal` component and replaced it with a `GetStartedDialog` for better onboarding experience. This commit improves the overall structure and functionality of the inbox feature, ensuring better data management and user experience. --- .agents/skills/golang-pro/SKILL.md | 2 +- .../references/wpd-message-gateway.md | 5 +- .claude/commands/smell.md | 6 +- .../20260318000000_init_gateway.up.sql | 5 + docs/agents/delivery-agent.md | 2 +- docs/agents/review-agent.md | 1 + docs/backend/architecture.md | 6 +- docs/backend/code-conventions.md | 53 ++++- docs/backend/usage.md | 5 +- frontend/src/core/router/routes.ts | 2 + .../email-content/email-content.tsx | 6 +- .../components/email-item/email-item.test.tsx | 2 +- .../components/email-item/email-item.tsx | 19 +- .../components/email-list/email-list.test.tsx | 2 +- .../components/email-list/email-list.tsx | 3 +- .../get-started-dialog.stories.tsx | 27 +++ .../get-started-dialog/get-started-dialog.tsx | 67 ++++++ .../components/get-started-dialog/index.ts | 1 + .../inbox/components/send-test-modal/index.ts | 1 - .../send-test-modal.stories.tsx | 45 ---- .../send-test-modal/send-test-modal.test.tsx | 23 -- .../send-test-modal/send-test-modal.tsx | 223 ------------------ frontend/src/features/inbox/inbox.api.ts | 21 -- frontend/src/features/inbox/inbox.types.ts | 1 + .../src/features/inbox/log-display.utils.ts | 9 + frontend/src/features/inbox/logs.schema.ts | 1 + .../features/inbox/pages/email-inbox.page.tsx | 36 ++- .../features/inbox/pages/overview.page.tsx | 181 +++++++------- .../features/settings/pages/settings.page.tsx | 2 +- .../layouts/workspace-nav.config.ts | 5 +- go.mod | 2 +- internal/app/wire.go | 2 +- internal/core/domain/message_request_log.go | 23 +- internal/core/service/email_enrichment.go | 51 ++++ .../core/service/email_enrichment_test.go | 105 +++++++++ internal/core/service/gateway_service.go | 5 +- internal/core/service/gateway_service_test.go | 8 +- internal/infrastructure/inbox/store.go | 10 +- .../message_request_log_repository.go | 104 ++++---- .../message_request_log_repository_test.go | 111 +++++++++ .../repository/postgres/scan_helpers.go | 26 ++ .../repository/postgres/scan_helpers_test.go | 28 +++ .../postgres/template_repository.go | 7 - internal/presentation/dto/api_key.go | 62 +++++ internal/presentation/dto/auth.go | 119 ++++++++++ internal/presentation/dto/auth_test.go | 54 +++++ internal/presentation/dto/integration.go | 77 ++++++ internal/presentation/dto/integration_test.go | 60 +++++ .../presentation/dto/message_request_log.go | 62 +++++ .../dto/message_request_log_test.go | 39 +++ internal/presentation/dto/settings.go | 4 + internal/presentation/dto/template.go | 76 ++++++ internal/presentation/dto/workspace.go | 74 ++++++ .../handler/portal_auth_handler.go | 32 +-- .../presentation/handler/portal_handler.go | 103 +------- .../handler/portal_integration_handler.go | 47 +--- internal/presentation/handler/portal_send.go | 46 ---- .../handler/portal_template_handler.go | 61 +---- .../handler/portal_workspace_handler.go | 52 +--- internal/presentation/handler/send_helper.go | 3 + .../presentation/handler/send_helper_test.go | 60 +++++ internal/presentation/router.go | 1 - pkg/contracts/message.go | 43 +++- pkg/contracts/message_test.go | 72 ++++++ 64 files changed, 1557 insertions(+), 834 deletions(-) create mode 100644 frontend/src/features/inbox/components/get-started-dialog/get-started-dialog.stories.tsx create mode 100644 frontend/src/features/inbox/components/get-started-dialog/get-started-dialog.tsx create mode 100644 frontend/src/features/inbox/components/get-started-dialog/index.ts delete mode 100644 frontend/src/features/inbox/components/send-test-modal/index.ts delete mode 100644 frontend/src/features/inbox/components/send-test-modal/send-test-modal.stories.tsx delete mode 100644 frontend/src/features/inbox/components/send-test-modal/send-test-modal.test.tsx delete mode 100644 frontend/src/features/inbox/components/send-test-modal/send-test-modal.tsx create mode 100644 frontend/src/features/inbox/log-display.utils.ts create mode 100644 internal/core/service/email_enrichment.go create mode 100644 internal/core/service/email_enrichment_test.go create mode 100644 internal/infrastructure/repository/postgres/message_request_log_repository_test.go create mode 100644 internal/infrastructure/repository/postgres/scan_helpers.go create mode 100644 internal/infrastructure/repository/postgres/scan_helpers_test.go create mode 100644 internal/presentation/dto/api_key.go create mode 100644 internal/presentation/dto/auth.go create mode 100644 internal/presentation/dto/auth_test.go create mode 100644 internal/presentation/dto/integration.go create mode 100644 internal/presentation/dto/integration_test.go create mode 100644 internal/presentation/dto/message_request_log.go create mode 100644 internal/presentation/dto/message_request_log_test.go create mode 100644 internal/presentation/dto/settings.go create mode 100644 internal/presentation/dto/template.go create mode 100644 internal/presentation/dto/workspace.go delete mode 100644 internal/presentation/handler/portal_send.go diff --git a/.agents/skills/golang-pro/SKILL.md b/.agents/skills/golang-pro/SKILL.md index 8fd7e8ce..4023f53e 100644 --- a/.agents/skills/golang-pro/SKILL.md +++ b/.agents/skills/golang-pro/SKILL.md @@ -100,7 +100,7 @@ Key properties demonstrated: bounded goroutine lifetime via `ctx`, error propaga - Use `X | Y` union constraints for generics (Go 1.18+) - Propagate errors with fmt.Errorf("%w", err) - Run race detector on tests (-race flag) -- Maintain pure domain entities (strict DDD): NEVER use `json:"..."`, `db:"..."` or other presentation/infrastructure tags in `internal/core/domain`. ALWAYS map domain models to DTOs in the presentation layer before returning them. +- Maintain pure domain entities (strict DDD): NEVER use `json:"..."`, `db:"..."` or other presentation/infrastructure tags in `internal/core/domain`. Map in **`internal/presentation/dto/`**: outbound **`{Type}FromDomain(domain.X)`** package funcs; inbound **`(r R) ToDomain()`** / **`ToPatch()`** on request DTOs. **Never pass `dto.*` to repositories** — only `domain.*` crosses the port boundary. ### MUST NOT DO - Ignore errors (avoid _ assignment without justification) diff --git a/.agents/skills/golang-pro/references/wpd-message-gateway.md b/.agents/skills/golang-pro/references/wpd-message-gateway.md index c3c3be72..cb6dd743 100644 --- a/.agents/skills/golang-pro/references/wpd-message-gateway.md +++ b/.agents/skills/golang-pro/references/wpd-message-gateway.md @@ -29,7 +29,7 @@ Router → Middleware → Handler → Service → Port ← Repository / Provider | Port | `internal/core/port/` | domain, `pkg/contracts` | implementations | | Service | `internal/core/service/` | port, domain, `pkg/contracts`, `pkg/registry` | SQL, HTTP, Echo | | Infrastructure | `internal/infrastructure/` | port, domain | presentation | -| Presentation | `internal/presentation/` | service, port, domain | direct SQL | +| Presentation | `internal/presentation/` | service, port, domain, **dto** | direct SQL; **inline HTTP DTOs in handlers**; **passing dto to port/repo** | | Public SDK | `pkg/*` | `pkg/*` only | `internal/*` | Sender interfaces (`EmailSender`, `SMSSender`, …) live in **`pkg/contracts/`**, not `internal/core/port/`. @@ -60,6 +60,7 @@ Do **not** add providers under `internal/infrastructure/provider/` (deprecated). - Wrap with `fmt.Errorf("…: %w", err)` - Domain/port sentinels: `port.ErrNotFound`, `port.ErrConflict`, etc. - Handlers: generic messages on 500; full error in slog only (`send_helper.go` pattern) +- **HTTP DTOs:** live in **`internal/presentation/dto/`** — never in handlers. Outbound: **`dto.XFromDomain(domain.Y)`** package funcs. Inbound: **`(r R) ToDomain()`** (→ `domain.*`) or **`ToPatch()`** (→ `service.*Patch`). **Ports/repos use `domain.*` only** — never import or accept `dto.*` (see `docs/backend/code-conventions.md`). ### Concurrency @@ -106,7 +107,7 @@ wpd-message-gateway/ │ ├── app/ # wire, imports, config │ ├── core/ # domain, port, service │ ├── infrastructure/ # postgres repos, logger, inbox store -│ └── presentation/ # router, handlers, middleware +│ └── presentation/ # router, handlers, middleware, dto/ ├── pkg/ │ ├── contracts/ # message types + sender interfaces │ ├── gateway/ # embedded SDK diff --git a/.claude/commands/smell.md b/.claude/commands/smell.md index 8333c987..dcd750e6 100644 --- a/.claude/commands/smell.md +++ b/.claude/commands/smell.md @@ -104,8 +104,8 @@ Router → Middleware → Handler → Service → Port ← Repository / Provider | `internal/core/domain/` | Imports Echo, HTTP, infra, presentation, or contains struct tags (`json`, `db`) | | `internal/core/port/` | Contains implementation instead of interfaces | | `internal/core/service/` | Direct SQL/HTTP; missing `ctx`; orchestration left in handler | -| `internal/infrastructure/` | Business rules; bypasses port interfaces | -| `internal/presentation/` | Business logic; handler → repository without service; RBAC in handler instead of middleware | +| `internal/infrastructure/` | Business rules; bypasses port interfaces; imports `presentation/dto` | +| `internal/presentation/` | Business logic; handler → repository without service; RBAC in handler instead of middleware; **request/response structs or JSON mappers in `handler/*.go`** | | `pkg/gateway`, `pkg/contracts` | Imports `internal/*`; embedded SDK requires PostgreSQL | | Providers | No `init()` registration or missing blank import in `internal/app/imports.go` | | Auth | Portal mutations without JWT + `RequirePermission`; internal ingest open outside local dev | @@ -184,6 +184,8 @@ Do not invent issues. If the diff is clean, say so. | MGW.MIGRATION | Migration hygiene failure | | MGW.LOG-SECRET | Secrets or PII in logs | | MGW.N+1-QUERY | Query inside a loop | +| MGW.HANDLER-DTO | HTTP struct or mapper (`XFromDomain` / `ToDomain` / `ToPatch`) in `handler/*.go` instead of `presentation/dto/` | +| MGW.DTO-IN-REPO | Repository or port interface accepts `presentation/dto.*` instead of `domain.*` | | MGW.GOROUTINE-LEAK | Goroutine without lifecycle control | **Frontend** diff --git a/database/migrations/20260318000000_init_gateway.up.sql b/database/migrations/20260318000000_init_gateway.up.sql index 82cbd7f0..acabc0aa 100644 --- a/database/migrations/20260318000000_init_gateway.up.sql +++ b/database/migrations/20260318000000_init_gateway.up.sql @@ -264,6 +264,7 @@ CREATE TABLE message_request_logs ( request_id VARCHAR(64), duration_ms INT CHECK (duration_ms >= 0), error_message TEXT, + inbox_message_id UUID, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); @@ -281,6 +282,10 @@ CREATE INDEX idx_message_request_logs_workspace_api_created CREATE INDEX idx_message_request_logs_workspace_channel_created ON message_request_logs (workspace_id, channel_type, created_at DESC); +CREATE INDEX idx_message_request_logs_workspace_inbox_message + ON message_request_logs (workspace_id, inbox_message_id) + WHERE inbox_message_id IS NOT NULL; + -- ============================================================================== -- 11.5. MESSAGE_REQUEST_PAYLOADS TABLE (Append-only body storage) -- ============================================================================== diff --git a/docs/agents/delivery-agent.md b/docs/agents/delivery-agent.md index 622908a4..aed90fd2 100644 --- a/docs/agents/delivery-agent.md +++ b/docs/agents/delivery-agent.md @@ -16,7 +16,7 @@ Orchestration note: the **[master-agent](./master-agent.md)** classifies request - **Traceability:** Before multi-file edits, state a **short plan** (files/areas + verification). After edits, summarize **what changed** and **how it was verified**. - **Bounded retries:** If [verification](./verification.md) fails twice for the **same** mistake class, **stop**, re-read output, and **narrow scope** instead of looping. - **No secret material (Data Exposure limits):** Do not echo or commit API keys, JWTs, or pasted credentials. **Hard requirement:** Any code generating secrets must use crypto-safe `crypto/rand`, and secrets must exclusively live in ENV or hashed in Postgres. -- **Engineering principles (implement time):** Apply **KISS** (smallest correct diff), **DRY** (extract on second duplication), **DDD** (domain constants/types in `internal/core/domain/` + FE `*.types.ts`), **SOLID** (thin handlers, services orchestrate, ports for I/O). Remove dead code and stale comments/docs you touch — do not leave refactor debris. +- **Engineering principles (implement time):** Apply **KISS**, **DRY**, **DDD**, **SOLID**. Portal HTTP shapes in **`internal/presentation/dto/`**; map to **`domain.*`** before calling services — **never pass DTOs to repositories**. - **Comments:** Add only when behavior is non-obvious; delete comments that restate code or became wrong after your edit. ## 1. Safety & Robustness Pre-Check diff --git a/docs/agents/review-agent.md b/docs/agents/review-agent.md index 299c8044..c71f6804 100644 --- a/docs/agents/review-agent.md +++ b/docs/agents/review-agent.md @@ -68,6 +68,7 @@ Derived from [backend-engineer.md](../backend/backend-engineer.md) and [code-con - [ ] **Database Schema**: If columns/tables changed, the DB diagram and relative docs must be updated. - [ ] **Robustness & Scalability**: No goroutine leaks. - [ ] **Tests**: Table-driven tests covering **edge cases** and **expected failures**. +- [ ] **Presentation DTOs**: Types/mappers in `internal/presentation/dto/`; **`domain.*` only** on port/repo interfaces (`MGW.HANDLER-DTO`, `MGW.DTO-IN-REPO`). - [ ] **Principles**: Domain constants for statuses/enums; no handler business rules; no `pkg`→`internal` (`DDD.VALUE-TYPE`, `DDD.LAYER`, `SOLID.DIP`). ## 4. Frontend review checklist diff --git a/docs/backend/architecture.md b/docs/backend/architecture.md index befdcf52..1b3f2ecd 100644 --- a/docs/backend/architecture.md +++ b/docs/backend/architecture.md @@ -186,7 +186,7 @@ Portal accounts use **email + password** (passwords are stored hashed). All mana The core service layer decouples from `wpd-gogate` by depending on the `port.AuthorizationGate` interface. The implementation adapter lives in `internal/infrastructure/authgate/gate_adapter.go`. - **admin**: Full read/write access to settings, integrations, templates, API keys, and member removal. Assigned automatically to the creator of a workspace. -- **member**: Read-only access to workspaces, settings, templates, API keys, plus permission to send test messages (`send.test`). +- **member**: Read-only access to workspaces, settings, templates, API keys, plus inbox management (`send.test`). Public workspaces (`is_private = false`) are dynamically accessible to any authenticated user as a `"viewer"` with read-only permissions (i.e. `*.read` operations are bypassed and approved automatically without requiring explicit membership or Casbin checks). All write operations on public workspaces remain strictly restricted to workspace admins. @@ -456,9 +456,9 @@ The **memory provider** captures messages in process RAM. It's always available The React Portal runs at `portal.ui_port` (default **10104**) when the server starts. -**Portal UI today:** sign in, list workspaces, manage **integrations**, **settings** (general, API keys, message dispatch), view message logs, send test messages. +**Portal UI today:** sign in, list workspaces, manage **integrations**, **settings** (general, API keys, message dispatch), view message logs, browse captured **email inbox**. -**REST only (no UI pages yet):** workspace create, templates, members, memory inbox browser. +**REST only (no UI pages yet):** workspace create, templates, members, SMS/push/chat inbox browser. --- diff --git a/docs/backend/code-conventions.md b/docs/backend/code-conventions.md index f11b9796..e247f6bc 100644 --- a/docs/backend/code-conventions.md +++ b/docs/backend/code-conventions.md @@ -17,7 +17,8 @@ internal/ # Private application code (HTTP server mode) │ ├── logger/ # Structured logging │ └── repository/ # Postgres repositories └── presentation/ # HTTP layer - ├── handler/ # Request handlers + ├── dto/ # Request/response shapes + domain→JSON mappers (never in handler/) + ├── handler/ # Request handlers (thin: bind DTO → service → DTO) └── middleware/ # Auth, JWT, inbox API key pkg/ # Public packages (embedded SDK + shared contracts) @@ -212,3 +213,53 @@ import ( ``` Use `goimports -local github.com/weprodev/wpd-message-gateway` to auto-format. + +## HTTP DTOs (presentation layer) + +**Never** define request/response structs in `handler/*.go`. HTTP JSON shapes belong in **`internal/presentation/dto/`** — one file per resource (e.g. `api_key.go`, `integration.go`). + +| Do | Don't | +| -- | ----- | +| `dto.CreateAPIKeyRequest` in handler bind | `type createAPIKeyBody struct` in handler | +| `dto.IntegrationFromDomain(intg)` | `integrationToJSON` helper in handler | +| Exported PascalCase names (`LoginRequest`) | Unexported `*Body` suffix types in handler | +| Pass `domain.*` to services/repositories | Pass `dto.*` to services or repositories | + +### Data flow (DTOs stop at the handler boundary) + +Repositories and port interfaces speak **domain only** — never presentation DTOs. + +``` +Inbound: JSON → handler.Bind(dto.Request) → req.ToDomain() → domain.* → Service → Port → Repository +Outbound: Repository → domain.* → Service → handler → dto.XFromDomain(domain.*) → JSON +Partial: JSON → dto.PatchRequest → req.ToPatch() → service.*Patch → Service (load domain, apply, save) +``` + +| Layer | Types it uses | Maps how | +| ----- | ------------- | -------- | +| **Handler** | `dto.*` | Bind JSON; call `ToDomain` / `ToPatch`; call `XFromDomain` before `c.JSON` | +| **Service** | `domain.*`, `service.*Patch` | Business rules; orchestrates ports | +| **Port / Repository** | `domain.*` | Postgres adapters scan rows ↔ domain structs | +| **Infrastructure** | `domain.*` | No `json` tags required; no import of `presentation/dto` | + +**Never send DTOs to a repository.** If a handler has a `dto.CreateTemplateRequest`, call `body.ToDomain(wid)` first, then pass the resulting `*domain.Template` to `PortalService.CreateTemplate`, which passes it to the template repository port. The repository interface looks like: + +```go +Create(ctx context.Context, t *domain.Template) error // port — domain only +``` + +Gateway `/v1/*` handlers may bind **`pkg/contracts`** types directly — those are the public SDK message API, not portal DTOs. + +### Mapping convention (idiomatic Go) + +| Direction | Style | Example | +| --------- | ----- | ------- | +| Domain → response DTO | Package func `{Type}FromDomain` | `dto.APIKeyPublicFromDomain(k)` | +| Request DTO → domain (create/upsert) | Value-receiver method `ToDomain()` | `body.ToDomain(wid)` | +| Request DTO → partial update | Value-receiver method `ToPatch()` | `body.ToPatch()` → `service.WorkspacePatch` | + +Use **`{Type}FromDomain`** package functions for outbound mapping (not methods on a zero-value receiver). Use **`ToDomain()`** on request DTOs when the result is a **`domain.*`** entity for persistence. Use **`ToPatch()`** when the result is a **service patch command** (service loads domain, applies fields, saves via repository). + +**Not infrastructure:** DTOs are presentation concerns (JSON field names, omitempty). `internal/infrastructure/` maps DB rows to **domain**, not HTTP. + +New or changed portal endpoints: add/update types in `dto/` and colocate mapper tests (`dto/*_test.go`) when mapping logic is non-trivial. diff --git a/docs/backend/usage.md b/docs/backend/usage.md index 35698c13..39263984 100644 --- a/docs/backend/usage.md +++ b/docs/backend/usage.md @@ -320,7 +320,7 @@ result, err := gw.SendChat(ctx, &contracts.ChatMessage{ ### Portal REST API (JWT auth) -Routes registered in `internal/presentation/router.go`. **Portal UI pages exist only for auth, workspace list, message logs, and send test** — other management routes are REST-only until UI is built. +Routes registered in `internal/presentation/router.go`. **Portal UI pages exist for auth, workspace list, message logs, email inbox, integrations, and settings** — other management routes are REST-only until UI is built. #### Auth @@ -337,7 +337,6 @@ Routes registered in `internal/presentation/router.go`. **Portal UI pages exist |--------|----------|:---------:|-------------| | GET | `/api/v1/workspaces` | ✓ | List workspaces for the logged-in user | | GET | `/api/v1/workspaces/:wid/logs` | ✓ | Message request logs (optionally filter by `channel`) | -| POST | `/api/v1/workspaces/:wid/send-test/:channel` | ✓ | Send test via gateway (`email` / `sms` / `push` / `chat`) | #### Workspace provisioning & management (REST only — no Portal UI) @@ -349,7 +348,7 @@ Workspace-scoped routes require a valid portal JWT and are governed by a Role-Ba Roles and permissions are defined in [permission.go](../../internal/core/domain/permission.go): - **admin**: Full read/write access. The creator of a workspace is automatically assigned this role (as `admin` in both members repository and `gogate`). -- **member**: Read-only access to workspaces, members, API keys, logs, integrations, templates, settings, and invitations, plus the ability to send test messages (`send.test`). +- **member**: Read-only access to workspaces, members, API keys, logs, integrations, templates, settings, and invitations, plus inbox management (`send.test`). To bootstrap roles and permissions, make sure you run the database seeds: 1. Run `database/seeds/001_seed_permissions.sql` to populate roles (`admin`, `member`), default permissions, and role-to-permission mappings. diff --git a/frontend/src/core/router/routes.ts b/frontend/src/core/router/routes.ts index 1fcea2a5..e03d63a6 100644 --- a/frontend/src/core/router/routes.ts +++ b/frontend/src/core/router/routes.ts @@ -7,6 +7,8 @@ export const ROUTES = { pattern: "/workspaces/:wid", overview: (workspaceId: string) => `/workspaces/${workspaceId}/overview`, email: (workspaceId: string) => `/workspaces/${workspaceId}/email`, + emailWithMessage: (workspaceId: string, messageId: string) => + `/workspaces/${workspaceId}/email?message=${encodeURIComponent(messageId)}`, emailTemplates: (workspaceId: string) => `/workspaces/${workspaceId}/email/templates`, sms: (workspaceId: string) => `/workspaces/${workspaceId}/sms`, push: (workspaceId: string) => `/workspaces/${workspaceId}/push`, diff --git a/frontend/src/features/inbox/components/email-content/email-content.tsx b/frontend/src/features/inbox/components/email-content/email-content.tsx index d36b9c97..cd91dc97 100644 --- a/frontend/src/features/inbox/components/email-content/email-content.tsx +++ b/frontend/src/features/inbox/components/email-content/email-content.tsx @@ -27,8 +27,8 @@ function formatFullTime(dateStr: string) { export function EmailContent({ message, onDelete, isDeleting = false }: EmailContentProps) { const sender = message.email.from_name ? `${message.email.from_name} <${message.email.from}>` - : message.email.from || "Unknown Sender" - + : message.email.from || "Unknown sender" + const recipients = (message.email.to || []).join(", ") const formattedDate = formatFullTime(message.created_at) @@ -48,7 +48,7 @@ export function EmailContent({ message, onDelete, isDeleting = false }: EmailCon {message.email.subject || "(No Subject)"}

- {sender} • {formattedDate} + To: {recipients || "—"} • {formattedDate}

diff --git a/frontend/src/features/inbox/components/email-item/email-item.test.tsx b/frontend/src/features/inbox/components/email-item/email-item.test.tsx index a204fab3..17c857d8 100644 --- a/frontend/src/features/inbox/components/email-item/email-item.test.tsx +++ b/frontend/src/features/inbox/components/email-item/email-item.test.tsx @@ -21,7 +21,7 @@ const mockEmail = { describe("EmailItem Component", () => { it("renders correctly with email properties", () => { render() - expect(screen.getByText("John Doe")).toBeInTheDocument() + expect(screen.getByText("recipient@example.com")).toBeInTheDocument() expect(screen.getByText("Test Subject")).toBeInTheDocument() expect(screen.getByText("This is the body snippet of the test email.")).toBeInTheDocument() }) diff --git a/frontend/src/features/inbox/components/email-item/email-item.tsx b/frontend/src/features/inbox/components/email-item/email-item.tsx index c3746504..5e995429 100644 --- a/frontend/src/features/inbox/components/email-item/email-item.tsx +++ b/frontend/src/features/inbox/components/email-item/email-item.tsx @@ -6,10 +6,15 @@ interface EmailItemProps { onClick: () => void } -function getSenderInitial(nameOrEmail: string) { - if (!nameOrEmail) return "?" - const cleaned = nameOrEmail.trim() - return cleaned[0].toUpperCase() +function formatRecipients(to: string[] | undefined) { + if (!to || to.length === 0) return "Unknown recipient" + return to.join(", ") +} + +function getRecipientInitial(recipient: string) { + if (!recipient) return "?" + const cleaned = recipient.trim() + return cleaned[0]?.toUpperCase() ?? "?" } function formatTime(dateStr: string) { @@ -26,8 +31,8 @@ function formatTime(dateStr: string) { } export function EmailItem({ message, isSelected, onClick }: EmailItemProps) { - const senderName = message.email.from_name || message.email.from || "Unknown Sender" - const initial = getSenderInitial(senderName) + const recipientLabel = formatRecipients(message.email.to) + const initial = getRecipientInitial(message.email.to?.[0] ?? recipientLabel) const timestamp = formatTime(message.created_at) const snippet = message.email.plain_text || message.email.html || message.email.subject || "" @@ -57,7 +62,7 @@ export function EmailItem({ message, isSelected, onClick }: EmailItemProps) { {initial} - {senderName} + {recipientLabel} diff --git a/frontend/src/features/inbox/components/email-list/email-list.test.tsx b/frontend/src/features/inbox/components/email-list/email-list.test.tsx index 9b94f21d..29605e05 100644 --- a/frontend/src/features/inbox/components/email-list/email-list.test.tsx +++ b/frontend/src/features/inbox/components/email-list/email-list.test.tsx @@ -26,6 +26,6 @@ describe("EmailList Component", () => { expect(screen.getByText("Inbox")).toBeInTheDocument() expect(screen.getByText("1")).toBeInTheDocument() expect(screen.getByPlaceholderText("Search inbox...")).toBeInTheDocument() - expect(screen.getByText("John Doe")).toBeInTheDocument() + expect(screen.getByText("recipient@example.com")).toBeInTheDocument() }) }) diff --git a/frontend/src/features/inbox/components/email-list/email-list.tsx b/frontend/src/features/inbox/components/email-list/email-list.tsx index 8baf8c38..ab5f297b 100644 --- a/frontend/src/features/inbox/components/email-list/email-list.tsx +++ b/frontend/src/features/inbox/components/email-list/email-list.tsx @@ -26,8 +26,9 @@ export function EmailList({ const filtered = messages.filter((msg) => { const q = searchQuery.toLowerCase() const subject = (msg.email.subject || "").toLowerCase() + const recipients = (msg.email.to || []).join(" ").toLowerCase() const sender = (msg.email.from_name || msg.email.from || "").toLowerCase() - return subject.includes(q) || sender.includes(q) + return subject.includes(q) || recipients.includes(q) || sender.includes(q) }) return ( diff --git a/frontend/src/features/inbox/components/get-started-dialog/get-started-dialog.stories.tsx b/frontend/src/features/inbox/components/get-started-dialog/get-started-dialog.stories.tsx new file mode 100644 index 00000000..5802a311 --- /dev/null +++ b/frontend/src/features/inbox/components/get-started-dialog/get-started-dialog.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite" +import { MemoryRouter } from "react-router-dom" + +import { GetStartedDialog } from "./get-started-dialog" + +const meta = { + title: "Features/Inbox/GetStartedDialog", + component: GetStartedDialog, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Open: Story = { + args: { + open: true, + onOpenChange: () => undefined, + workspaceId: "ws-demo", + }, +} diff --git a/frontend/src/features/inbox/components/get-started-dialog/get-started-dialog.tsx b/frontend/src/features/inbox/components/get-started-dialog/get-started-dialog.tsx new file mode 100644 index 00000000..d404e653 --- /dev/null +++ b/frontend/src/features/inbox/components/get-started-dialog/get-started-dialog.tsx @@ -0,0 +1,67 @@ +import { Link } from "react-router-dom" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { ROUTES } from "@/core/router/routes" + +interface GetStartedDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + workspaceId?: string +} + +export function GetStartedDialog({ open, onOpenChange, workspaceId }: GetStartedDialogProps) { + const settingsPath = workspaceId ? ROUTES.workspace.settings(workspaceId) : ROUTES.workspaces + + return ( + + + + Get started + + Send messages through the same gateway endpoints your integrations use in production. + + +
+

+ Create an API key in{" "} + + Settings + + , then authenticate each request with Basic auth and your workspace slug header. +

+
+
+

Email

+
{`curl -X POST http://localhost:10101/v1/email \\
+  -u "client_id:client_secret" \\
+  -H "X-Workspace-Key: your-workspace-slug" \\
+  -H "Content-Type: application/json" \\
+  -d '{"to":["user@example.com"],"subject":"Test","html":"

Hi

"}'`}
+
+
+

SMS

+
{`curl -X POST http://localhost:10101/v1/sms \\
+  -u "client_id:client_secret" \\
+  -H "X-Workspace-Key: your-workspace-slug" \\
+  -H "Content-Type: application/json" \\
+  -d '{"to":["+1234567890"],"message":"Hello"}'`}
+
+
+
+ + + +
+
+ ) +} diff --git a/frontend/src/features/inbox/components/get-started-dialog/index.ts b/frontend/src/features/inbox/components/get-started-dialog/index.ts new file mode 100644 index 00000000..989854b9 --- /dev/null +++ b/frontend/src/features/inbox/components/get-started-dialog/index.ts @@ -0,0 +1 @@ +export { GetStartedDialog } from "./get-started-dialog" diff --git a/frontend/src/features/inbox/components/send-test-modal/index.ts b/frontend/src/features/inbox/components/send-test-modal/index.ts deleted file mode 100644 index cb31fba5..00000000 --- a/frontend/src/features/inbox/components/send-test-modal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./send-test-modal" diff --git a/frontend/src/features/inbox/components/send-test-modal/send-test-modal.stories.tsx b/frontend/src/features/inbox/components/send-test-modal/send-test-modal.stories.tsx deleted file mode 100644 index bdee6290..00000000 --- a/frontend/src/features/inbox/components/send-test-modal/send-test-modal.stories.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite" -import { useState } from "react" - -import { Button } from "@/components/ui/button" -import { SendTestModal } from "./send-test-modal" - -const meta = { - title: "Features/Inbox/SendTestModal", - component: SendTestModal, - tags: ["autodocs"], - parameters: { layout: "centered" }, -} satisfies Meta - -export default meta - -type Story = StoryObj - -function SendTestModalDemo() { - const [open, setOpen] = useState(true) - return ( - <> - - undefined} - initialChannel="email" - /> - - ) -} - -export const EmailChannel: Story = { - render: () => , - args: { - workspaceId: "00000000-0000-0000-0000-000000000001", - open: true, - onOpenChange: () => undefined, - onSent: () => undefined, - initialChannel: "email", - }, -} diff --git a/frontend/src/features/inbox/components/send-test-modal/send-test-modal.test.tsx b/frontend/src/features/inbox/components/send-test-modal/send-test-modal.test.tsx deleted file mode 100644 index b563a3bf..00000000 --- a/frontend/src/features/inbox/components/send-test-modal/send-test-modal.test.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { render, screen } from "@testing-library/react" -import { describe, expect, it, vi } from "vitest" - -import { SendTestModal } from "./send-test-modal" - -vi.mock("../../inbox.api", () => ({ - sendTestRequest: vi.fn(), -})) - -describe("SendTestModal Component", () => { - it("renders correctly when open", () => { - render( - - ) - expect(screen.getByText("Send Test Request")).toBeInTheDocument() - expect(screen.getByLabelText("To (comma-separated)")).toBeInTheDocument() - }) -}) diff --git a/frontend/src/features/inbox/components/send-test-modal/send-test-modal.tsx b/frontend/src/features/inbox/components/send-test-modal/send-test-modal.tsx deleted file mode 100644 index 5491f047..00000000 --- a/frontend/src/features/inbox/components/send-test-modal/send-test-modal.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import { useState } from "react" -import { Icon } from "@/components/ui/icon" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { sendTestRequest } from "../../inbox.api" -import type { MessageChannel } from "../../inbox.types" - -interface SendTestModalProps { - workspaceId: string - open: boolean - onOpenChange: (open: boolean) => void - onSent: () => void - initialChannel?: MessageChannel -} - -export function SendTestModal({ - workspaceId, - open, - onOpenChange, - onSent, - initialChannel = "email", -}: SendTestModalProps) { - const [channel, setChannel] = useState(initialChannel) - const [to, setTo] = useState("") - const [subject, setSubject] = useState("") - const [body, setBody] = useState("") - const [html, setHtml] = useState("") - const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState(null) - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - setIsLoading(true) - setError(null) - - let payload: Record = {} - if (channel === "email") { - payload = { - to: to.split(",").map((s) => s.trim()), - subject, - html, - } - } else if (channel === "sms") { - payload = { to, body } - } else if (channel === "push") { - payload = { to, title: subject, body } - } else if (channel === "chat") { - payload = { to, body } - } - - const res = await sendTestRequest(workspaceId, channel, payload) - setIsLoading(false) - - if (!res.ok) { - setError(res.message ?? "Failed to trigger test request") - } else { - onSent() - onOpenChange(false) - } - } - - const handleChannelChange = (newChannel: "email" | "sms" | "push" | "chat") => { - setChannel(newChannel) - setError(null) - if (newChannel === "email") { - setTo("user@example.com") - setSubject("Welcome to Message Gateway!") - } else if (newChannel === "sms") { - setTo("+15551234567") - setBody("This is a test SMS message.") - } else if (newChannel === "push") { - setTo("device-token-12345") - setSubject("New Notification") - setBody("This is a test push notification.") - } else if (newChannel === "chat") { - setTo("slack-channel-id") - setBody("Hello from the Chat API!") - } - } - - return ( - - - - Send Test Request - Trigger a gateway send for your workspace. - - -
-
- {error && ( -
- - {error} -
- )} - -
- Channel -
- {(["email", "sms", "push", "chat"] as const).map((ch) => ( - - ))} -
-
- -
- - setTo(e.target.value)} - className="bg-background text-sm" - /> -
- - {(channel === "email" || channel === "push") && ( -
- - setSubject(e.target.value)} - className="bg-background text-sm" - /> -
- )} - - {channel === "email" && ( -
- -