diff --git a/Readme.md b/Readme.md
index 3f4ff3cf..2ac6749d 100644
--- a/Readme.md
+++ b/Readme.md
@@ -11,7 +11,7 @@
- A unified Go library and HTTP API for sending Email, SMS, Push, and Chat messages. + A unified Go library and HTTP API for sending Email, SMS, Push, Chat and OTP messages. One interface, multiple providers. Write your messaging code once — switch between Mailgun, Twilio, Firebase, WhatsApp, and more without changing a single line of application code. @@ -114,7 +114,7 @@ Building applications that send messages across multiple channels is complex. Yo **Message Gateway provides:** -- **Unified API** — Email, SMS, Push, and Chat through a single, consistent interface +- **Unified API** — Email, SMS, Push, Chat, and OTP through a single, consistent interface - **Provider abstraction** — Switch from Mailgun to SendGrid with a config change—no code changes - **DB-first config** — In server mode, all provider credentials live in PostgreSQL, managed via the Portal UI - **Workspace isolation** — Multiple workspaces, each with its own providers, API keys, templates, and members @@ -131,6 +131,8 @@ Building applications that send messages across multiple channels is complex. Yo | **SMS** | Text to mobile numbers | Memory (Twilio planned) | | **Push** | Mobile and web notifications | Memory (Firebase planned) | | **Chat** | Slack, WhatsApp, Telegram | Memory (planned) | +| **OTP** | Slack, WhatsApp, Telegram | Memory (planned) | + --- @@ -186,7 +188,7 @@ wpd-message-gateway/ │ ├── presentation/ # HTTP router, handlers, middleware │ └── registry/ # Provider factory registry (self-registration via init) ├── pkg/ # Public packages (contracts, gateway SDK, auth, encryption) -│ ├── contracts/ # Email, SMS, Push, Chat types +│ ├── contracts/ # Email, SMS, Push, Chat, OTP types │ ├── gateway/ # Embedded Go SDK — gateway.New() │ ├── auth/ # Password hashing (portal) │ └── encryption/ # AES helpers diff --git a/cmd/server/main.go b/cmd/server/main.go index 8d76101b..16819f88 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -60,6 +60,7 @@ func logConfiguration(cfg *app.Config) { "sms_provider", cfg.DefaultSMSProvider(), "push_provider", cfg.DefaultPushProvider(), "chat_provider", cfg.DefaultChatProvider(), + "otp_provider", cfg.DefaultOTPProvider(), ) } diff --git a/configs/local.yml b/configs/local.yml index 1eb6b2b5..62dd69f5 100644 --- a/configs/local.yml +++ b/configs/local.yml @@ -20,6 +20,7 @@ providers: sms: memory push: memory chat: memory + otp: memory # Provider configurations email: @@ -48,3 +49,5 @@ providers: # slack: # webhook_url: "https://hooks.slack.com/services/..." + otp: + memory: {} diff --git a/database/migrations/20260318000000_init_gateway.up.sql b/database/migrations/20260318000000_init_gateway.up.sql index d5fe95cb..8fb258d9 100644 --- a/database/migrations/20260318000000_init_gateway.up.sql +++ b/database/migrations/20260318000000_init_gateway.up.sql @@ -17,15 +17,14 @@ $$ LANGUAGE plpgsql; -- ===================================================== CREATE TABLE users ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - email TEXT NOT NULL, - password TEXT, - first_name TEXT, - last_name TEXT, - status TEXT NOT NULL DEFAULT 'active' + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL, + password_hash TEXT, + display_name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'suspended', 'blocked')), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT users_email_unique UNIQUE (email), CONSTRAINT users_email_lower_check CHECK (email = lower(email)) @@ -42,17 +41,18 @@ CREATE TRIGGER trg_users_set_updated_at CREATE TABLE workspaces ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(255) NOT NULL, - slug TEXT NOT NULL, + unique_key TEXT NOT NULL, owner_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'suspended')), - is_private BOOLEAN NOT NULL DEFAULT TRUE, - hashed_pin_code TEXT, + visibility TEXT NOT NULL DEFAULT 'private', + admin_email TEXT, + hashed_pin TEXT, icon_key VARCHAR(64), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - CONSTRAINT workspaces_slug_unique UNIQUE (slug) + CONSTRAINT workspaces_unique_key_unique UNIQUE (unique_key) ); COMMENT ON COLUMN workspaces.icon_key IS 'Optional icon identifier for workspace branding (e.g. marketing, product, support)'; @@ -95,7 +95,7 @@ CREATE TABLE workspace_members ( ); CREATE INDEX idx_workspace_members_user_id ON workspace_members(user_id); -CREATE INDEX idx_workspace_members_role_id ON workspace_members(role_id); +CREATE INDEX idx_workspace_members_role ON workspace_members(role); -- ===================================================== -- 5. invitations @@ -129,7 +129,7 @@ CREATE INDEX idx_invitations_token_hash ON invitations(token_hash); CREATE TABLE workspace_channels ( workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, - channel_type TEXT NOT NULL CHECK (channel_type IN ('email', 'sms', 'push', 'chat')), + channel_type TEXT NOT NULL CHECK (channel_type IN ('email', 'sms', 'push', 'chat', 'otp')), enabled BOOLEAN NOT NULL DEFAULT TRUE, PRIMARY KEY (workspace_id, channel_type) @@ -142,7 +142,7 @@ CREATE TABLE workspace_channels ( CREATE TABLE providers ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, - channel_type TEXT NOT NULL CHECK (channel_type IN ('email', 'sms', 'push', 'chat')), + channel_type TEXT NOT NULL CHECK (channel_type IN ('email', 'sms', 'push', 'chat', 'otp')), status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'not_supported', 'blocked')), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), @@ -167,7 +167,7 @@ CREATE TABLE provider_config_fields ( label TEXT NOT NULL, description TEXT, field_type TEXT NOT NULL - CHECK (field_type IN ('text', 'password', 'email', 'url', 'boolean', 'textarea', 'select')), + CHECK (field_type IN ('text', 'password_hash', 'email', 'url', 'boolean', 'textarea', 'select')), required BOOLEAN NOT NULL DEFAULT FALSE, default_value TEXT, options JSONB, -- for field_type='select': ["tls","ssl","none"] @@ -190,7 +190,7 @@ CREATE TABLE integrations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, provider_id UUID NOT NULL, - channel_type TEXT NOT NULL CHECK (channel_type IN ('email', 'sms', 'push', 'chat')), + channel_type TEXT NOT NULL CHECK (channel_type IN ('email', 'sms', 'push', 'chat', 'otp')), encrypted_config BYTEA NOT NULL, status TEXT NOT NULL DEFAULT 'connected' CHECK (status IN ('connected', 'error', 'disconnected')), @@ -245,7 +245,7 @@ CREATE TABLE message_request_logs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, api_key_id UUID REFERENCES api_keys(id) ON DELETE SET NULL, - channel_type TEXT NOT NULL CHECK (channel_type IN ('email', 'sms', 'push', 'chat')), + channel_type TEXT NOT NULL CHECK (channel_type IN ('email', 'sms', 'push', 'chat', 'otp')), provider_name VARCHAR(64), http_method VARCHAR(16) NOT NULL, status_code SMALLINT NOT NULL, @@ -277,7 +277,7 @@ CREATE TABLE templates ( name VARCHAR(255) NOT NULL, unique_key VARCHAR(255) NOT NULL, channel_type TEXT NOT NULL DEFAULT 'email' - CHECK (channel_type IN ('email', 'sms', 'push', 'chat')), + CHECK (channel_type IN ('email', 'sms', 'push', 'chat', 'otp')), subject VARCHAR(512), content TEXT NOT NULL, category VARCHAR(64), diff --git a/database/seeds/001_demo_workspace.sql b/database/seeds/001_demo_workspace.sql index 0590ab8f..5d1b4333 100644 --- a/database/seeds/001_demo_workspace.sql +++ b/database/seeds/001_demo_workspace.sql @@ -1,12 +1,11 @@ -- Demo workspace, API key, email integration, template (aligned with latest schema) -INSERT INTO users (id, email, password, first_name, last_name, status) +INSERT INTO users (id, email, password_hash, display_name, status) VALUES ( '00000000-0000-0000-0000-000000000010', 'demo@weprodev.com', '$2a$14$dummy.hash.for.demo.only', - 'Demo', - 'User', + 'Demo User', 'active' ) ON CONFLICT (id) DO NOTHING; @@ -17,29 +16,31 @@ VALUES ('00000000-0000-0000-0000-000000000021', 'member', 'Member') ON CONFLICT (id) DO NOTHING; -INSERT INTO workspaces (id, name, slug, owner_id, status, is_private, hashed_pin_code, icon_key) +INSERT INTO workspaces (id, name, unique_key, owner_id, admin_email, status, visibility, hashed_pin, icon_key) VALUES ( '00000000-0000-0000-0000-000000000001', 'Demo Workspace', 'demo', '00000000-0000-0000-0000-000000000010', + 'demo@weprodev.com', 'active', - true, + 'private', NULL, NULL ) ON CONFLICT (id) DO NOTHING; -INSERT INTO workspace_members (workspace_id, user_id, role_id) +INSERT INTO workspace_members (workspace_id, user_id, role) VALUES ( '00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000010', - '00000000-0000-0000-0000-000000000020' + 'admin' ) ON CONFLICT (workspace_id, user_id) DO NOTHING; INSERT INTO workspace_channels (workspace_id, channel_type, enabled) -VALUES ('00000000-0000-0000-0000-000000000001', 'email', true) +VALUES ('00000000-0000-0000-0000-000000000001', 'email', true), + ('00000000-0000-0000-0000-000000000001', 'otp', true) ON CONFLICT (workspace_id, channel_type) DO NOTHING; INSERT INTO api_keys (id, workspace_id, client_id, client_secret_hash, name, is_active) @@ -75,6 +76,29 @@ VALUES ( ) ON CONFLICT (workspace_id, provider_id) DO NOTHING; +-- OTP memory provider (global catalog) +INSERT INTO providers (id, name, channel_type, status) +VALUES ( + '00000000-0000-0000-0000-000000000031', + 'memory', + 'otp', + 'active' +) +ON CONFLICT (id) DO NOTHING; + +-- OTP default integration for demo workspace +INSERT INTO integrations (id, workspace_id, channel_type, provider_id, encrypted_config, status, is_default) +VALUES ( + '00000000-0000-0000-0000-000000000004', + '00000000-0000-0000-0000-000000000001', + 'otp', + '00000000-0000-0000-0000-000000000031', + E'\\x720231d45885b8d808a3e2930264f2b60ca166004abf449b6be50328217e', + 'connected', + true +) +ON CONFLICT (workspace_id, provider_id) DO NOTHING; + INSERT INTO templates (id, workspace_id, name, unique_key, channel_type, subject, content, category, is_active, is_default) VALUES ( '00000000-0000-0000-0000-000000000004', diff --git a/docs/backend/architecture.md b/docs/backend/architecture.md index 32146c97..780bddd4 100644 --- a/docs/backend/architecture.md +++ b/docs/backend/architecture.md @@ -121,7 +121,7 @@ gw.SendEmail(ctx, &contracts.Email{ │ │ Router │ │ PortalHandler │ │ GatewayHandler │ │ │ │ │ │ (auth, WS mgmt)│ │ (send email, │ │ │ └─────────────┘ │ InboxHandler │ │ sms, push, │ │ -│ │ (captured msgs)│ │ chat) │ │ +│ │ (captured msgs)│ │ chat, otp) │ │ │ └────────┬────────┘ └────────┬────────┘ │ └─────────────────────────────┼────────────────────┼──────────────┘ │ │ @@ -136,14 +136,14 @@ gw.SendEmail(ctx, &contracts.Email{ │ └──────────────────────────────────────────────────────────┘ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ GatewayService │ │ -│ │ SendEmail() · SendSMS() · SendPush() · SendChat() │ │ -│ │ Reads dispatch_mode from workspace_settings │ │ +│ │ SendEmail() · SendSMS() · SendPush() · SendChat() · │ │ +│ │ SendOTP() Reads dispatch_mode from workspace_setting │ │ │ │ Reads provider credentials from integrations table │ │ │ └──────────────────────────────────────────────────────────┘ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ Ports (Interfaces) │ │ -│ │ EmailSender · SMSSender · PushSender · ChatSender │ │ -│ │ Repository interfaces for all DB entities │ │ +│ │ EmailSender · SMSSender · PushSender · ChatSender · │ │ +│ │ OTPSender Repository interfaces for all DB entities │ │ │ └──────────────────────────────────────────────────────────┘ │ └──────────────────────────────┬──────────────────────────────────┘ │ implements @@ -228,7 +228,7 @@ This doc intentionally does **not** duplicate full column lists. 1. **Open Portal** → select workspace 2. **Go to Integrations** → Add Integration -3. **Select channel** (Email, SMS, Push, Chat) and **provider** (Mailgun, etc.) +3. **Select channel** (Email, SMS, Push, Chat, OTP) and **provider** (Mailgun, etc.) 4. **Enter credentials** — stored AES-encrypted in `integrations` table 5. **Set dispatch mode** in Settings → `memory_only` | `provider_only` | `memory_and_provider` @@ -384,7 +384,7 @@ wpd-message-gateway/ │ ├── auth/ # Hash utilities (shared between SDK and server) │ ├── encryption/ # AES encryption (for DB-stored provider config) │ └── gateway/ # Embedded SDK — gateway.New() for pure Go usage -│ ├── gateway.go # Gateway struct, SendEmail/SMS/Push/Chat +│ ├── gateway.go # Gateway struct, SendEmail/SMS/Push/Chat/OTP │ ├── config.go # Config struct + New() constructor │ └── errors.go │ @@ -413,7 +413,7 @@ In server mode, provider credentials (Mailgun API keys, etc.) are stored **encry Ports define **capabilities** — the "What" (Send Email), not the "How" (using Mailgun API). - **Location**: `internal/core/port/` -- **Interfaces**: `EmailSender`, `SMSSender`, `PushSender`, `ChatSender` +- **Interfaces**: `EmailSender`, `SMSSender`, `PushSender`, `ChatSender`, `OTPSneder` - **Benefit**: Providers are interchangeable — any implementation that satisfies the interface works ### 4. Provider Self-Registration @@ -463,7 +463,7 @@ The React Portal UI is **always enabled** when running the server. It serves as: | **Single Responsibility** | Each provider handles one vendor. GatewayService handles routing. | | **Open/Closed** | Add new providers without modifying existing code. | | **Liskov Substitution** | Any `EmailSender` implementation works interchangeably. | -| **Interface Segregation** | Separate interfaces for Email, SMS, Push, Chat. | +| **Interface Segregation** | Separate interfaces for Email, SMS, Push, Chat, OTP. | | **Dependency Inversion** | Core depends on abstractions (ports), not implementations. | ### Other Principles diff --git a/docs/backend/contributing.md b/docs/backend/contributing.md index ff550600..bd948e04 100644 --- a/docs/backend/contributing.md +++ b/docs/backend/contributing.md @@ -217,6 +217,7 @@ internal/infrastructure/provider/ │ ├── email.go # Memory email provider │ ├── sms.go # Memory SMS provider │ ├── push.go # Memory push provider +│ ├── otp.go # Memory OTP provider │ ├── chat.go # Memory chat provider │ └── register.go # init() self-registration │ @@ -258,6 +259,7 @@ Same pattern — use the corresponding registry function and port interface: | SMS | `registry.RegisterSMSProvider(name, factory)` | `port.SMSSender` | | Push | `registry.RegisterPushProvider(name, factory)` | `port.PushSender` | | Chat | `registry.RegisterChatProvider(name, factory)` | `port.ChatSender` | +| OTP | `registry.RegisterOTPProvider(name, factory)` | `port.OTPSender` | --- diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 13f0c1a0..c0969559 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -555,6 +555,40 @@ "node": ">=20.19.0" } }, + "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==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", @@ -1293,9 +1327,9 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", - "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "dev": true, "license": "MIT", "optional": true, @@ -1312,9 +1346,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.122.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", - "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "version": "0.128.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.128.0.tgz", + "integrity": "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==", "dev": true, "license": "MIT", "funding": { @@ -1855,9 +1889,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==", "cpu": [ "arm64" ], @@ -1872,9 +1906,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==", "cpu": [ "arm64" ], @@ -1889,9 +1923,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.18.tgz", + "integrity": "sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==", "cpu": [ "x64" ], @@ -1906,9 +1940,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.18.tgz", + "integrity": "sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==", "cpu": [ "x64" ], @@ -1923,9 +1957,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", - "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.18.tgz", + "integrity": "sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==", "cpu": [ "arm" ], @@ -1940,16 +1974,13 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1960,16 +1991,13 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.18.tgz", + "integrity": "sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1980,16 +2008,13 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==", "cpu": [ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2000,16 +2025,13 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==", "cpu": [ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2020,16 +2042,13 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2040,16 +2059,13 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.18.tgz", + "integrity": "sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2060,9 +2076,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==", "cpu": [ "arm64" ], @@ -2077,9 +2093,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", - "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.18.tgz", + "integrity": "sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==", "cpu": [ "wasm32" ], @@ -2087,16 +2103,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz", + "integrity": "sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==", "cpu": [ "arm64" ], @@ -2111,9 +2129,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.18.tgz", + "integrity": "sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==", "cpu": [ "x64" ], @@ -2454,9 +2472,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -2474,9 +2489,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -2494,9 +2506,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -2514,9 +2523,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -2534,9 +2540,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -2554,9 +2557,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -2767,9 +2767,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2787,9 +2784,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2807,9 +2801,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2827,9 +2818,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5205,9 +5193,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5229,9 +5214,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5253,9 +5235,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5277,9 +5256,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5706,9 +5682,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "dev": true, "funding": [ { @@ -5997,14 +5973,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", - "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz", + "integrity": "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.122.0", - "@rolldown/pluginutils": "1.0.0-rc.12" + "@oxc-project/types": "=0.128.0", + "@rolldown/pluginutils": "1.0.0-rc.18" }, "bin": { "rolldown": "bin/cli.mjs" @@ -6013,27 +5989,27 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-x64": "1.0.0-rc.12", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + "@rolldown/binding-android-arm64": "1.0.0-rc.18", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.18", + "@rolldown/binding-darwin-x64": "1.0.0-rc.18", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.18", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.18", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.18", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.18", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18" } }, "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", - "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz", + "integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==", "dev": true, "license": "MIT" }, @@ -6319,14 +6295,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -6624,17 +6600,17 @@ } }, "node_modules/vite": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", - "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "version": "8.0.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz", + "integrity": "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.12", - "tinyglobby": "^0.2.15" + "postcss": "^8.5.14", + "rolldown": "1.0.0-rc.18", + "tinyglobby": "^0.2.16" }, "bin": { "vite": "bin/vite.js" @@ -6650,8 +6626,8 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", diff --git a/go.mod b/go.mod index 5653a085..3802e5e0 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.26.1 require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 - github.com/labstack/echo/v4 v4.15.1 + github.com/labstack/echo/v4 v4.15.2 github.com/mailgun/mailgun-go/v4 v4.23.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -18,25 +18,25 @@ require ( github.com/go-playground/validator/v10 v10.30.2 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/lib/pq v1.12.1 // indirect + github.com/lib/pq v1.12.3 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect - golang.org/x/crypto v0.49.0 // indirect + golang.org/x/crypto v0.51.0 // indirect ) require ( github.com/go-chi/chi/v5 v5.2.5 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/labstack/gommon v0.4.2 // indirect - github.com/mailgun/errors v0.5.0 // indirect + github.com/labstack/gommon v0.5.0 // indirect + github.com/mailgun/errors v0.6.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 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.0 - golang.org/x/net v0.52.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.35.0 // indirect + golang.org/x/net v0.54.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.15.0 // indirect ) diff --git a/go.sum b/go.sum index 2153af3d..58160cee 100644 --- a/go.sum +++ b/go.sum @@ -24,22 +24,22 @@ github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/labstack/echo/v4 v4.15.1 h1:S9keusg26gZpjMmPqB5hOEvNKnmd1lNmcHrbbH2lnFs= -github.com/labstack/echo/v4 v4.15.1/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c= -github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= -github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/labstack/echo/v4 v4.15.2 h1:nnh2sCzGCVYnU+wCisMPiYapEg/QVo/gcI9ePKg5/T4= +github.com/labstack/echo/v4 v4.15.2/go.mod h1:Xzp1Ns1RA2c9fY7nSgUJkpkUZGNbEIVHZbtbOMPktBI= +github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c= +github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/lib/pq v1.12.1 h1:x1nbl/338GLqeDJ/FAiILallhAsqubLzEZu/pXtHUow= -github.com/lib/pq v1.12.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= -github.com/mailgun/errors v0.5.0 h1:pLQo8uhAdORsjN69mGixSr0pGs46z/BW/FQXd8HG1VM= -github.com/mailgun/errors v0.5.0/go.mod h1:+2nrgY77E0vDkG4ErehpcpbSkMLkseJzKbrva89WeSs= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/mailgun/errors v0.6.0 h1:IWmzIGwXCSN/Q60JT/lXvam3xRAgTUJSX88KwKJ7hss= +github.com/mailgun/errors v0.6.0/go.mod h1:+2nrgY77E0vDkG4ErehpcpbSkMLkseJzKbrva89WeSs= github.com/mailgun/mailgun-go/v4 v4.23.0 h1:jPEMJzzin2s7lvehcfv/0UkyBu18GvcURPr2+xtZRbk= github.com/mailgun/mailgun-go/v4 v4.23.0/go.mod h1:imTtizoFtpfZqPqGP8vltVBB6q9yWcv6llBhfFeElZU= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -61,15 +61,14 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/weprodev/go-pkg v1.1.0 h1:nnH5NEryxSCELdLYysM5QM7xJT9kA2MRkBiISpbWrck= github.com/weprodev/go-pkg v1.1.0/go.mod h1:hVsr/LOIeKp8Uoy1gn4/ppwxMDX2KHpbTu35tIjVG8o= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/internal/app/config.go b/internal/app/config.go index 49719f28..020e999c 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -21,6 +21,7 @@ type Config struct { SMSProviders map[string]registry.SMSConfig `yaml:"-"` PushProviders map[string]registry.PushConfig `yaml:"-"` ChatProviders map[string]registry.ChatConfig `yaml:"-"` + OTPProviders map[string]registry.OTPConfig `yaml:"-"` } // ServerConfig holds server configuration. @@ -42,6 +43,7 @@ type ProviderConfig struct { SMS map[string]SMSConfigMap `yaml:"sms"` Push map[string]PushConfigMap `yaml:"push"` Chat map[string]ChatConfigMap `yaml:"chat"` + OTP map[string]OTPConfigMap `yaml:"otp"` } // ProviderDefaults holds default provider names. @@ -50,17 +52,20 @@ type ProviderDefaults struct { SMS string `yaml:"sms"` Push string `yaml:"push"` Chat string `yaml:"chat"` + OTP string `yaml:"otp"` } type EmailConfigMap map[string]string type SMSConfigMap map[string]string type PushConfigMap map[string]string type ChatConfigMap map[string]string +type OTPConfigMap map[string]string func (c *Config) DefaultEmailProvider() string { return c.Providers.Defaults.Email } 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 } +func (c *Config) DefaultOTPProvider() string { return c.Providers.Defaults.OTP } // LoadConfig loads configuration from a YAML file. func LoadConfig(path string) (*Config, error) { @@ -78,6 +83,7 @@ func LoadConfig(path string) (*Config, error) { SMSProviders: make(map[string]registry.SMSConfig), PushProviders: make(map[string]registry.PushConfig), ChatProviders: make(map[string]registry.ChatConfig), + OTPProviders: make(map[string]registry.OTPConfig), } if err := yaml.Unmarshal(data, cfg); err != nil { @@ -117,6 +123,8 @@ func (c *Config) applyEnvOverrides() { c.Providers.Defaults.Push = val case "MESSAGE_DEFAULT_CHAT_PROVIDER": c.Providers.Defaults.Chat = val + case "MESSAGE_DEFAULT_OTP_PROVIDER": + c.Providers.Defaults.OTP = val case "MESSAGE_JWT_SECRET": c.Portal.JWTSecret = val } @@ -166,4 +174,14 @@ func (c *Config) parseProviderConfigs() { WebhookURL: m["webhook_url"], } } + + for name, m := range c.Providers.OTP { + c.OTPProviders[name] = registry.OTPConfig{ + CommonConfig: buildCommon(map[string]string(m)), + PhoneNumber: m["phone_number"], + Code: m["code"], + CodeLength: m["code_length"], + SenderUsername: m["sender_username"], + } + } } diff --git a/internal/app/validation.go b/internal/app/validation.go index 134ff2aa..9c76f4d9 100644 --- a/internal/app/validation.go +++ b/internal/app/validation.go @@ -49,8 +49,17 @@ func ValidateConfig(cfg *Config) error { ) } + if cfg.DefaultOTPProvider() == "" { + missingProviders = append(missingProviders, "OTP") + } else if !registry.IsOTPProviderRegistered(cfg.DefaultOTPProvider()) { + return fmt.Errorf( + "missing or invalid required configuration: MESSAGE_DEFAULT_OTP_PROVIDER (unknown provider: %s)", + cfg.DefaultOTPProvider(), + ) + } + // If ALL providers are missing, that's an error - if len(missingProviders) == 4 { + if len(missingProviders) == 5 { return fmt.Errorf( "no default providers configured. Please set at least one in configs/local.yml:\n" + " providers:\n" + @@ -58,7 +67,8 @@ func ValidateConfig(cfg *Config) error { " email: memory\n" + " sms: memory\n" + " push: memory\n" + - " chat: memory", + " chat: memory\n" + + " otp: memory", ) } diff --git a/internal/core/domain/workspace_member.go b/internal/core/domain/workspace_member.go index 9065f486..6b9dc766 100644 --- a/internal/core/domain/workspace_member.go +++ b/internal/core/domain/workspace_member.go @@ -6,7 +6,7 @@ import "time" type WorkspaceMember struct { WorkspaceID string `json:"workspace_id"` UserID string `json:"user_id"` - Role string `json:"role"` + RoleID string `json:"role_id"` JoinedAt time.Time `json:"joined_at"` UserEmail string `json:"user_email,omitempty"` DisplayName string `json:"display_name,omitempty"` diff --git a/internal/core/port/inbox.go b/internal/core/port/inbox.go index a092c0e0..240bcf1f 100644 --- a/internal/core/port/inbox.go +++ b/internal/core/port/inbox.go @@ -23,4 +23,7 @@ type InboxWriter interface { // WriteChat captures a chat message against workspaceID and returns an assigned ID. WriteChat(ctx context.Context, workspaceID string, chat *contracts.ChatMessage) (id string, err error) + + // WriteOTP captures a otp verification message against workspaceID and returns an assigned ID. + WriteOTP(ctx context.Context, workspaceID string, otp *contracts.OTP) (id string, err error) } diff --git a/internal/core/port/otp.go b/internal/core/port/otp.go new file mode 100644 index 00000000..00445621 --- /dev/null +++ b/internal/core/port/otp.go @@ -0,0 +1,13 @@ +package port + +import ( + "context" + + "github.com/weprodev/wpd-message-gateway/pkg/contracts" +) + +// OTPSender defines the contract for sending OTP Verification messages. +type OTPSender interface { + Send(ctx context.Context, otp *contracts.OTP) (*contracts.SendResult, error) + Name() string +} diff --git a/internal/core/service/gateway_service.go b/internal/core/service/gateway_service.go index 9f763608..a45d36b0 100644 --- a/internal/core/service/gateway_service.go +++ b/internal/core/service/gateway_service.go @@ -16,6 +16,7 @@ const ( channelSMS = "sms" channelPush = "push" channelChat = "chat" + channelOTP = "otp" // memoryProviderName is the sentinel name stored in the integrations table // when a workspace uses memory dispatch. Checked here to skip DB lookup. @@ -40,6 +41,7 @@ type GatewayService struct { smsCache *smsSenderCache pushCache *pushSenderCache chatCache *chatSenderCache + otpCache *otpSenderCache } // NewGatewayService constructs a GatewayService. @@ -61,6 +63,7 @@ func NewGatewayService( smsCache: newProviderCache[port.SMSSender](), pushCache: newProviderCache[port.PushSender](), chatCache: newProviderCache[port.ChatSender](), + otpCache: newProviderCache[port.OTPSender](), } } @@ -124,6 +127,21 @@ func (s *GatewayService) SendChat(ctx context.Context, workspaceID string, chat ) } +// SendOTP dispatches an OTP message for workspaceID according to its dispatch mode. +func (s *GatewayService) SendOTP(ctx context.Context, workspaceID string, otp *contracts.OTP) (*contracts.SendResult, error) { + mode := s.resolveDispatchMode(ctx, workspaceID) + return s.dispatch(ctx, workspaceID, mode, channelOTP, + func() (*contracts.SendResult, error) { return s.writeOTPToInbox(ctx, workspaceID, otp) }, + func(intg *domain.Integration) (*contracts.SendResult, error) { + sender, err := resolveOTPSender(s.otpCache, intg) + if err != nil { + return nil, err + } + return sender.Send(ctx, otp) + }, + ) +} + // dispatch is the single entry point for all channel dispatch logic. // It applies the workspace dispatch mode and calls the appropriate fn(s). // @@ -277,6 +295,17 @@ func (s *GatewayService) writeChatToInbox(ctx context.Context, workspaceID strin return &contracts.SendResult{ID: id, StatusCode: 200, Message: "captured in memory"}, nil } +func (s *GatewayService) writeOTPToInbox(ctx context.Context, workspaceID string, otp *contracts.OTP) (*contracts.SendResult, error) { + if s.inbox == nil { + return nil, fmt.Errorf("inbox writer not configured") + } + id, err := s.inbox.WriteOTP(ctx, workspaceID, otp) + if err != nil { + return nil, fmt.Errorf("write OTP to inbox: %w", err) + } + 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 string) { diff --git a/internal/core/service/portal_service.go b/internal/core/service/portal_service.go index d2871203..eb3c3484 100644 --- a/internal/core/service/portal_service.go +++ b/internal/core/service/portal_service.go @@ -81,7 +81,7 @@ func (s *PortalService) Register(ctx context.Context, email, password, displayNa } if existing, err := s.users.GetByEmail(ctx, email); err == nil && existing != nil { return nil, "", errors.New("email already registered") - } else if err != nil && err.Error() != "user not found" { + } else if err != nil && !errors.Is(err, port.ErrNotFound) { return nil, "", err } diff --git a/internal/core/service/provider_cache.go b/internal/core/service/provider_cache.go index c900e4ed..bf3ceecc 100644 --- a/internal/core/service/provider_cache.go +++ b/internal/core/service/provider_cache.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "sync" - "time" "github.com/weprodev/wpd-message-gateway/internal/core/domain" "github.com/weprodev/wpd-message-gateway/internal/core/port" @@ -62,6 +61,9 @@ type pushSenderCache = providerCache[port.PushSender] // chatSenderCache resolves (and caches) port.ChatSenders from integrations. type chatSenderCache = providerCache[port.ChatSender] +// otpSenderCache resolves (and caches) port.OTPSenders from integrations. +type otpSenderCache = providerCache[port.OTPSender] + // resolveEmailSender returns a cached or newly constructed EmailSender for the integration. func resolveEmailSender(cache *emailSenderCache, intg *domain.Integration) (port.EmailSender, error) { if s, ok := cache.get(intg); ok { @@ -146,5 +148,23 @@ func resolveChatSender(cache *chatSenderCache, intg *domain.Integration) (port.C return sender, nil } -// ensure time is used (for cacheKey.updatedAt type alignment). -var _ = time.Now +// resolveOTPSender returns a cached or newly constructed OTPSender. +func resolveOTPSender(cache *otpSenderCache, intg *domain.Integration) (port.OTPSender, error) { + if s, ok := cache.get(intg); ok { + return s, nil + } + factory, err := registry.GetOTPFactory(intg.ProviderName) + if err != nil { + return nil, fmt.Errorf("provider factory: %w", err) + } + var cfg registry.OTPConfig + if err := json.Unmarshal(intg.Config, &cfg); err != nil { + return nil, fmt.Errorf("parse integration config: %w", err) + } + sender, err := factory(cfg) + if err != nil { + return nil, fmt.Errorf("init OTP provider %q: %w", intg.ProviderName, err) + } + cache.set(intg, sender) + return sender, nil +} diff --git a/internal/infrastructure/provider/memory/inbox_writer.go b/internal/infrastructure/provider/memory/inbox_writer.go index 83612abe..d1b6346a 100644 --- a/internal/infrastructure/provider/memory/inbox_writer.go +++ b/internal/infrastructure/provider/memory/inbox_writer.go @@ -72,3 +72,15 @@ func (a *InboxWriterAdapter) WriteChat(_ context.Context, _ string, chat *contra }) return id, nil } + +// WriteOTP stores an OTP message in memory. +func (a *InboxWriterAdapter) WriteOTP(_ context.Context, workspaceID string, otp *contracts.OTP) (string, error) { + id := uuid.New().String() + a.store.AddOTP(&StoredOTP{ + ID: id, + WorkspaceID: workspaceID, + CreatedAt: time.Now(), + OTP: otp, + }) + return id, nil +} diff --git a/internal/infrastructure/provider/memory/otp.go b/internal/infrastructure/provider/memory/otp.go new file mode 100644 index 00000000..0a5dc0ad --- /dev/null +++ b/internal/infrastructure/provider/memory/otp.go @@ -0,0 +1,58 @@ +package memory + +import ( + "context" + "time" + + "github.com/google/uuid" + + "github.com/weprodev/wpd-message-gateway/pkg/contracts" +) + +// OTPProvider implements port.OTPSender using an in-memory store. +type OTPProvider struct { + store *Store + workspaceID string +} + +// NewOTPProvider creates a new memory OTP provider (no workspace scope; global inbox). +func NewOTPProvider(store *Store) *OTPProvider { + return NewOTPProviderForWorkspace(store, "") +} + +// NewOTPProviderForWorkspace tags stored OTPs with workspaceID for multi-tenant portal inbox. +func NewOTPProviderForWorkspace(store *Store, workspaceID string) *OTPProvider { + return &OTPProvider{ + store: store, + workspaceID: workspaceID, + } +} + +// Store returns the underlying memory store. +func (o *OTPProvider) Store() *Store { + return o.store +} + +// Name returns the provider name. +func (o *OTPProvider) Name() string { + return ProviderName +} + +// Send stores the OTP in memory and returns a success result. +func (o *OTPProvider) Send(ctx context.Context, otp *contracts.OTP) (*contracts.SendResult, error) { + id := uuid.New().String() + + stored := &StoredOTP{ + ID: id, + WorkspaceID: o.workspaceID, + CreatedAt: time.Now(), + OTP: otp, + } + o.store.AddOTP(stored) + + return &contracts.SendResult{ + ID: id, + StatusCode: 200, + Message: "Stored OTP in memory", + }, nil +} diff --git a/internal/infrastructure/provider/memory/register.go b/internal/infrastructure/provider/memory/register.go index fdab4e1d..1d4ff3fa 100644 --- a/internal/infrastructure/provider/memory/register.go +++ b/internal/infrastructure/provider/memory/register.go @@ -21,4 +21,8 @@ func init() { registry.RegisterChatProvider("memory", func(cfg registry.ChatConfig) (port.ChatSender, error) { return NewChatProvider(GetStore()), nil }) + + registry.RegisterOTPProvider("memory", func(cfg registry.OTPConfig) (port.OTPSender, error) { + return NewOTPProvider(GetStore()), nil + }) } diff --git a/internal/infrastructure/provider/memory/store.go b/internal/infrastructure/provider/memory/store.go index 231ccb7d..a006f93a 100644 --- a/internal/infrastructure/provider/memory/store.go +++ b/internal/infrastructure/provider/memory/store.go @@ -52,6 +52,14 @@ type StoredChat struct { Chat *contracts.ChatMessage `json:"chat"` } +// StoredOTP wraps an OTP with metadata for storage. +type StoredOTP struct { + ID string `json:"id"` + WorkspaceID string `json:"workspace_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + OTP *contracts.OTP `json:"otp"` +} + // Store implements an in-memory message store for all message types. type Store struct { mu sync.RWMutex @@ -59,6 +67,7 @@ type Store struct { sms []*StoredSMS pushes []*StoredPush chats []*StoredChat + otps []*StoredOTP } // NewStore creates a new in-memory store. @@ -68,6 +77,7 @@ func NewStore() *Store { sms: make([]*StoredSMS, 0), pushes: make([]*StoredPush, 0), chats: make([]*StoredChat, 0), + otps: make([]*StoredOTP, 0), } } @@ -118,36 +128,46 @@ func (s *Store) DeleteEmailByIDForWorkspace(id, workspaceID string) bool { return false } -// StatsForWorkspace returns counts for messages belonging to workspace (email only until SMS/Push/Chat are tagged). +// StatsForWorkspace returns counts for messages belonging to workspace. func (s *Store) StatsForWorkspace(workspaceID string) map[string]int { s.mu.RLock() defer s.mu.RUnlock() - n := 0 + emails, otps := 0, 0 for _, e := range s.emails { if e != nil && e.WorkspaceID == workspaceID { - n++ + emails++ + } + } + for _, o := range s.otps { + if o != nil && o.WorkspaceID == workspaceID { + otps++ } } return map[string]int{ - "emails": n, - "sms": 0, - "push": 0, - "chat": 0, - "total": n, + "emails": emails, + "otp": otps, + "total": emails + otps, } } -// ClearWorkspace removes in-memory messages for a single workspace (emails only for now). +// ClearWorkspace removes in-memory messages for a single workspace. func (s *Store) ClearWorkspace(workspaceID string) { s.mu.Lock() defer s.mu.Unlock() - kept := s.emails[:0] + emails := s.emails[:0] for _, e := range s.emails { if e == nil || e.WorkspaceID != workspaceID { - kept = append(kept, e) + emails = append(emails, e) } } - s.emails = kept + s.emails = emails + otps := s.otps[:0] + for _, o := range s.otps { + if o == nil || o.WorkspaceID != workspaceID { + otps = append(otps, o) + } + } + s.otps = otps } // EmailByID returns a stored email by its ID, or nil if not found. @@ -305,11 +325,90 @@ func (s *Store) AddChat(stored *StoredChat) { s.chats = append(s.chats, stored) } +// OTPs returns a copy of all stored OTPs. +func (s *Store) OTPs() []*StoredOTP { + s.mu.RLock() + defer s.mu.RUnlock() + msgs := make([]*StoredOTP, len(s.otps)) + copy(msgs, s.otps) + return msgs +} + +// OTPByID returns a stored OTP by its ID, or nil if not found. +func (s *Store) OTPByID(id string) *StoredOTP { + s.mu.RLock() + defer s.mu.RUnlock() + for _, o := range s.otps { + if o.ID == id { + return o + } + } + return nil +} + +// DeleteOTPByID deletes an OTP by ID. Returns true if deleted. +func (s *Store) DeleteOTPByID(id string) bool { + s.mu.Lock() + defer s.mu.Unlock() + for i, o := range s.otps { + if o.ID == id { + s.otps = append(s.otps[:i], s.otps[i+1:]...) + return true + } + } + return false +} + +// AddOTP adds a stored OTP. +func (s *Store) AddOTP(stored *StoredOTP) { + s.mu.Lock() + defer s.mu.Unlock() + s.otps = append(s.otps, stored) +} + +// OTPsForWorkspace returns stored OTPs tagged with the given workspace ID. +func (s *Store) OTPsForWorkspace(workspaceID string) []*StoredOTP { + s.mu.RLock() + defer s.mu.RUnlock() + var out []*StoredOTP + for _, o := range s.otps { + if o != nil && o.WorkspaceID == workspaceID { + out = append(out, o) + } + } + return out +} + +// OTPByIDForWorkspace returns an OTP by ID only if it belongs to the workspace. +func (s *Store) OTPByIDForWorkspace(id, workspaceID string) *StoredOTP { + s.mu.RLock() + defer s.mu.RUnlock() + for _, o := range s.otps { + if o != nil && o.ID == id && o.WorkspaceID == workspaceID { + return o + } + } + return nil +} + +// DeleteOTPByIDForWorkspace deletes an OTP if it belongs to the workspace. +func (s *Store) DeleteOTPByIDForWorkspace(id, workspaceID string) bool { + s.mu.Lock() + defer s.mu.Unlock() + for i, o := range s.otps { + if o != nil && o.ID == id && o.WorkspaceID == workspaceID { + s.otps = append(s.otps[:i], s.otps[i+1:]...) + return true + } + } + return false +} + // Count returns the total number of stored messages across all types. func (s *Store) Count() int { s.mu.RLock() defer s.mu.RUnlock() - return len(s.emails) + len(s.sms) + len(s.pushes) + len(s.chats) + return len(s.emails) + len(s.sms) + len(s.pushes) + len(s.chats) + len(s.otps) } // Stats returns message counts by type. @@ -321,7 +420,8 @@ func (s *Store) Stats() map[string]int { "sms": len(s.sms), "push": len(s.pushes), "chat": len(s.chats), - "total": len(s.emails) + len(s.sms) + len(s.pushes) + len(s.chats), + "otp": len(s.otps), + "total": len(s.emails) + len(s.sms) + len(s.pushes) + len(s.chats) + len(s.otps), } } @@ -333,4 +433,5 @@ func (s *Store) Clear() { s.sms = make([]*StoredSMS, 0) s.pushes = make([]*StoredPush, 0) s.chats = make([]*StoredChat, 0) + s.otps = make([]*StoredOTP, 0) } diff --git a/internal/infrastructure/repository/postgres/api_key_repository.go b/internal/infrastructure/repository/postgres/api_key_repository.go index 1cfeb125..3b926811 100644 --- a/internal/infrastructure/repository/postgres/api_key_repository.go +++ b/internal/infrastructure/repository/postgres/api_key_repository.go @@ -107,7 +107,7 @@ func (r *APIKeyRepository) ListByWorkspace(ctx context.Context, workspaceID stri } defer rows.Close() //nolint:errcheck - var out []domain.APIKey + out := make([]domain.APIKey, 0) for rows.Next() { var key domain.APIKey var lastUsed, exp sql.NullTime diff --git a/internal/infrastructure/repository/postgres/integration_repository.go b/internal/infrastructure/repository/postgres/integration_repository.go index 63af3791..4350cb73 100644 --- a/internal/infrastructure/repository/postgres/integration_repository.go +++ b/internal/infrastructure/repository/postgres/integration_repository.go @@ -86,7 +86,7 @@ func (r *IntegrationRepository) ListByWorkspace(ctx context.Context, workspaceID } defer rows.Close() //nolint:errcheck - var out []domain.Integration + out := make([]domain.Integration, 0) for rows.Next() { var intg domain.Integration var enc []byte diff --git a/internal/infrastructure/repository/postgres/invitation_repository.go b/internal/infrastructure/repository/postgres/invitation_repository.go index 7b918bad..43b0256f 100644 --- a/internal/infrastructure/repository/postgres/invitation_repository.go +++ b/internal/infrastructure/repository/postgres/invitation_repository.go @@ -40,7 +40,7 @@ func (r *InvitationRepository) ListPendingByWorkspace(ctx context.Context, works } defer rows.Close() //nolint:errcheck - var out []domain.Invitation + out := make([]domain.Invitation, 0) for rows.Next() { var inv domain.Invitation if err := rows.Scan(&inv.ID, &inv.WorkspaceID, &inv.Email, &inv.Role, &inv.TokenHash, &inv.ExpiresAt, &inv.Status, &inv.CreatedAt); 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 185c98c3..67d8b23c 100644 --- a/internal/infrastructure/repository/postgres/message_request_log_repository.go +++ b/internal/infrastructure/repository/postgres/message_request_log_repository.go @@ -103,7 +103,7 @@ func (r *MessageRequestLogRepository) ListWithSource(ctx context.Context, q port } defer rows.Close() //nolint:errcheck - var out []domain.MessageRequestLogWithSource + out := make([]domain.MessageRequestLogWithSource, 0) for rows.Next() { var row domain.MessageRequestLogWithSource var apiKeyID sql.NullString diff --git a/internal/infrastructure/repository/postgres/template_repository.go b/internal/infrastructure/repository/postgres/template_repository.go index 9603a78f..f0441cb9 100644 --- a/internal/infrastructure/repository/postgres/template_repository.go +++ b/internal/infrastructure/repository/postgres/template_repository.go @@ -103,7 +103,7 @@ func (r *TemplateRepository) ListByWorkspace(ctx context.Context, workspaceID st } defer rows.Close() //nolint:errcheck - var out []domain.Template + out := make([]domain.Template, 0) for rows.Next() { var t domain.Template var subject, category sql.NullString diff --git a/internal/infrastructure/repository/postgres/workspace_member_repository.go b/internal/infrastructure/repository/postgres/workspace_member_repository.go index 3026b6a6..6386babe 100644 --- a/internal/infrastructure/repository/postgres/workspace_member_repository.go +++ b/internal/infrastructure/repository/postgres/workspace_member_repository.go @@ -20,12 +20,12 @@ func NewWorkspaceMemberRepository(client *pgsql.PgClient) port.WorkspaceMemberRe return &WorkspaceMemberRepository{client: client} } -func (r *WorkspaceMemberRepository) Add(ctx context.Context, workspaceID, userID, role string) error { +func (r *WorkspaceMemberRepository) Add(ctx context.Context, workspaceID, userID, roleID string) error { _, err := r.client.GetDB(ctx).ExecContext(ctx, ` - INSERT INTO workspace_members (workspace_id, user_id, role) + INSERT INTO workspace_members (workspace_id, user_id, role_id) VALUES ($1, $2, $3) - ON CONFLICT (workspace_id, user_id) DO UPDATE SET role = EXCLUDED.role - `, workspaceID, userID, role) + ON CONFLICT (workspace_id, user_id) DO UPDATE SET role_id = EXCLUDED.role_id + `, workspaceID, userID, roleID) return err } @@ -45,20 +45,20 @@ func (r *WorkspaceMemberRepository) Remove(ctx context.Context, workspaceID, use } func (r *WorkspaceMemberRepository) GetRole(ctx context.Context, workspaceID, userID string) (string, error) { - var role string + var roleID string err := r.client.GetDB(ctx).QueryRowContext(ctx, - `SELECT role FROM workspace_members WHERE workspace_id = $1 AND user_id = $2`, + `SELECT role_id FROM workspace_members WHERE workspace_id = $1 AND user_id = $2`, workspaceID, userID, - ).Scan(&role) + ).Scan(&roleID) if errors.Is(err, sql.ErrNoRows) { return "", fmt.Errorf("workspace member workspace=%s user=%s: %w", workspaceID, userID, port.ErrNotFound) } - return role, err + return roleID, err } 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, wm.role, wm.joined_at, u.email, COALESCE(u.display_name, '') + SELECT wm.workspace_id, wm.user_id, wm.role_id, wm.joined_at, u.email, COALESCE(u.display_name, '') FROM workspace_members wm INNER JOIN users u ON u.id = wm.user_id WHERE wm.workspace_id = $1 @@ -69,10 +69,10 @@ func (r *WorkspaceMemberRepository) ListMembers(ctx context.Context, workspaceID } defer rows.Close() //nolint:errcheck - var out []domain.WorkspaceMember + out := make([]domain.WorkspaceMember, 0) for rows.Next() { var m domain.WorkspaceMember - if err := rows.Scan(&m.WorkspaceID, &m.UserID, &m.Role, &m.JoinedAt, &m.UserEmail, &m.DisplayName); err != nil { + if err := rows.Scan(&m.WorkspaceID, &m.UserID, &m.RoleID, &m.JoinedAt, &m.UserEmail, &m.DisplayName); err != nil { return nil, err } out = append(out, m) diff --git a/internal/infrastructure/repository/postgres/workspace_repository.go b/internal/infrastructure/repository/postgres/workspace_repository.go index bd321c51..40d43acf 100644 --- a/internal/infrastructure/repository/postgres/workspace_repository.go +++ b/internal/infrastructure/repository/postgres/workspace_repository.go @@ -126,7 +126,7 @@ func (r *WorkspaceRepository) ListForUser(ctx context.Context, userID string) ([ } defer rows.Close() //nolint:errcheck - var out []domain.Workspace + out := make([]domain.Workspace, 0) for rows.Next() { w, err := scanWorkspace(rows) if err != nil { diff --git a/internal/presentation/handler/gateway_handler.go b/internal/presentation/handler/gateway_handler.go index c68055ff..a84e01ae 100644 --- a/internal/presentation/handler/gateway_handler.go +++ b/internal/presentation/handler/gateway_handler.go @@ -62,7 +62,15 @@ func (h *GatewayHandler) HandleSendChat(c echo.Context) error { }) } -// handleSend is the single DRY dispatcher for all four channels. +// HandleSendOTP handles POST /v1/otp. +func (h *GatewayHandler) HandleSendOTP(c echo.Context) error { + var req contracts.OTP + return h.handleSend(c, "otp", &req, func(ctx context.Context, wsID string) (*contracts.SendResult, error) { + return h.service.SendOTP(ctx, wsID, &req) + }) +} + +// handleSend is the single DRY dispatcher for all channels. // It decodes the JSON body into dst, calls send, records the audit log, and // returns the appropriate HTTP response. func (h *GatewayHandler) handleSend( diff --git a/internal/presentation/handler/portal_inbox_handler.go b/internal/presentation/handler/portal_inbox_handler.go index fbfa3dc8..ddabd2a0 100644 --- a/internal/presentation/handler/portal_inbox_handler.go +++ b/internal/presentation/handler/portal_inbox_handler.go @@ -101,6 +101,29 @@ func (h *PortalInboxHandler) HandleDeleteChatByID(c echo.Context) error { return c.JSON(http.StatusNotFound, map[string]string{"error": "chat message not found"}) } +func (h *PortalInboxHandler) HandleGetOTP(c echo.Context) error { + otps := h.store.OTPsForWorkspace(wid(c)) + return c.JSON(http.StatusOK, otps) +} + +func (h *PortalInboxHandler) HandleGetOTPByID(c echo.Context) error { + id := c.Param("id") + otp := h.store.OTPByIDForWorkspace(id, wid(c)) + if otp == nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "otp not found"}) + } + return c.JSON(http.StatusOK, otp) +} + +func (h *PortalInboxHandler) HandleDeleteOTPByID(c echo.Context) error { + id := c.Param("id") + if !h.store.DeleteOTPByIDForWorkspace(id, wid(c)) { + return c.JSON(http.StatusNotFound, map[string]string{"error": "otp not found"}) + } + h.broadcast(wid(c), "otp_deleted", id) + return c.NoContent(http.StatusNoContent) +} + // HandleClearAll removes in-memory messages for this workspace (emails). func (h *PortalInboxHandler) HandleClearAll(c echo.Context) error { h.store.ClearWorkspace(wid(c)) @@ -175,6 +198,22 @@ func (h *PortalInboxHandler) HandleIngestChat(c echo.Context) error { return c.JSON(http.StatusCreated, map[string]string{"id": result.ID}) } +func (h *PortalInboxHandler) HandleIngestOTP(c echo.Context) error { + var otp contracts.OTP + if err := json.NewDecoder(c.Request().Body).Decode(&otp); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid otp payload"}) + } + + otpProvider := memory.NewOTPProvider(h.store) + result, err := otpProvider.Send(c.Request().Context(), &otp) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to store otp"}) + } + + h.broadcast(wid(c), "otp_received", map[string]string{"id": result.ID}) + return c.JSON(http.StatusCreated, map[string]string{"id": result.ID}) +} + // HandleSSE streams events for one workspace only. func (h *PortalInboxHandler) HandleSSE(c echo.Context) error { w := wid(c) diff --git a/internal/presentation/router.go b/internal/presentation/router.go index ae808faf..cffd7522 100644 --- a/internal/presentation/router.go +++ b/internal/presentation/router.go @@ -97,6 +97,7 @@ func (rt *Router) Setup() *echo.Echo { v1.POST("/sms", rt.gatewayHandler.HandleSendSMS) v1.POST("/push", rt.gatewayHandler.HandleSendPush) v1.POST("/chat", rt.gatewayHandler.HandleSendChat) + v1.POST("/otp", rt.gatewayHandler.HandleSendOTP) api := e.Group("/api/v1") @@ -152,6 +153,9 @@ func (rt *Router) Setup() *echo.Echo { inboxGroup.GET("/chat", inbox.HandleGetChat) inboxGroup.GET("/chat/:id", inbox.HandleGetChatByID) inboxGroup.DELETE("/chat/:id", inbox.HandleDeleteChatByID) + inboxGroup.GET("/otp", inbox.HandleGetOTP) + inboxGroup.GET("/otp/:id", inbox.HandleGetOTPByID) + inboxGroup.DELETE("/otp/:id", inbox.HandleDeleteOTPByID) inboxGroup.DELETE("/messages", inbox.HandleClearAll) inboxGroup.GET("/events", inbox.HandleSSE) @@ -161,6 +165,7 @@ func (rt *Router) Setup() *echo.Echo { internal.POST("/sms", inbox.HandleIngestSMS) internal.POST("/push", inbox.HandleIngestPush) internal.POST("/chat", inbox.HandleIngestChat) + internal.POST("/otp", inbox.HandleIngestOTP) } return e diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 29113840..b975eb4d 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -51,6 +51,14 @@ type ChatConfig struct { WebhookURL string } +type OTPConfig struct { + CommonConfig + PhoneNumber string + Code string + CodeLength string + SenderUsername string +} + // EmailProviderFactory constructs an EmailSender from static config. type EmailProviderFactory func(cfg EmailConfig) (port.EmailSender, error) @@ -63,12 +71,15 @@ type PushProviderFactory func(cfg PushConfig) (port.PushSender, error) // ChatProviderFactory constructs a ChatSender from static config. type ChatProviderFactory func(cfg ChatConfig) (port.ChatSender, error) +type OTPProviderFactory func(cfg OTPConfig) (port.OTPSender, error) + var ( mu sync.RWMutex emailFactories = make(map[string]EmailProviderFactory) smsFactories = make(map[string]SMSProviderFactory) pushFactories = make(map[string]PushProviderFactory) chatFactories = make(map[string]ChatProviderFactory) + otpFactories = make(map[string]OTPProviderFactory) ) // RegisterEmailProvider registers an email provider factory by name. @@ -100,6 +111,14 @@ func RegisterChatProvider(name string, factory ChatProviderFactory) { chatFactories[name] = factory } +// RegisterOTPProvider registers an OTP provider factory by name. +// Call this inside your provider's init() function. +func RegisterOTPProvider(name string, factory OTPProviderFactory) { + mu.Lock() + defer mu.Unlock() + otpFactories[name] = factory +} + // GetEmailFactory returns the registered factory for the named email provider. func GetEmailFactory(name string) (EmailProviderFactory, error) { mu.RLock() @@ -144,6 +163,17 @@ func GetChatFactory(name string) (ChatProviderFactory, error) { return f, nil } +// GetOTPFactory returns the registered factory for the named OTP provider. +func GetOTPFactory(name string) (OTPProviderFactory, error) { + mu.RLock() + defer mu.RUnlock() + f, ok := otpFactories[name] + if !ok { + return nil, fmt.Errorf("registry: unknown OTP provider %q (not registered)", name) + } + return f, nil +} + // IsEmailProviderRegistered reports whether an email provider is registered. func IsEmailProviderRegistered(name string) bool { mu.RLock() @@ -175,3 +205,11 @@ func IsChatProviderRegistered(name string) bool { _, ok := chatFactories[name] return ok } + +// IsOTPProviderRegistered reports whether an OTP provider is registered. +func IsOTPProviderRegistered(name string) bool { + mu.RLock() + defer mu.RUnlock() + _, ok := otpFactories[name] + return ok +} diff --git a/pkg/contracts/otp.go b/pkg/contracts/otp.go new file mode 100644 index 00000000..45fb253e --- /dev/null +++ b/pkg/contracts/otp.go @@ -0,0 +1,17 @@ +package contracts + +import "context" + +// OTP represents an OTP verification message to be sent. +type OTP struct { + PhoneNumber []string `json:"phone_number,omitempty"` + SenderUsername []string `json:"sender_username,omitempty"` + Code []string `json:"code,omitempty"` + CodeLength int `json:"code_length"` +} + +// OTPSender defines the contract for sending OTP verification messages. +type OTPSender interface { + Send(ctx context.Context, otp *OTP) (*SendResult, error) + Name() string +} diff --git a/pkg/gateway/config.go b/pkg/gateway/config.go index 67420b35..75fa3c14 100644 --- a/pkg/gateway/config.go +++ b/pkg/gateway/config.go @@ -42,6 +42,15 @@ type ChatConfig struct { WebhookURL string } +// OTPConfig holds OTP-specific static provider configuration. +type OTPConfig struct { + CommonConfig + PhoneNumber string + Code string + CodeLength string + SenderUsername string +} + // Config is the static gateway configuration used with New(). // Providers are identified by name and must have been registered via init(). type Config struct { @@ -49,11 +58,13 @@ type Config struct { DefaultSMSProvider string DefaultPushProvider string DefaultChatProvider string + DefaultOTPProvider string EmailProviders map[string]EmailConfig SMSProviders map[string]SMSConfig PushProviders map[string]PushConfig ChatProviders map[string]ChatConfig + OTPProviders map[string]OTPConfig } // buildEmailSenders constructs email senders from cfg and stores them in g. @@ -120,6 +131,22 @@ func buildChatSenders(g *Gateway, cfg Config) error { return nil } +// buildOTPSenders constructs OTP senders from cfg and stores them in g. +func buildOTPSenders(g *Gateway, cfg Config) error { + for name, oc := range cfg.OTPProviders { + factory, err := registry.GetOTPFactory(name) + if err != nil { + return fmt.Errorf("gateway: OTP provider %q: %w", name, err) + } + sender, err := factory(toRegistryOTP(oc)) + if err != nil { + return fmt.Errorf("gateway: init OTP provider %q: %w", name, err) + } + g.otpSenders[name] = sender + } + return nil +} + func toRegistryEmail(ec EmailConfig) registry.EmailConfig { return registry.EmailConfig{ CommonConfig: registry.CommonConfig{APIKey: ec.APIKey, APISecret: ec.APISecret, Region: ec.Region, BaseURL: ec.BaseURL}, @@ -151,3 +178,13 @@ func toRegistryChat(cc ChatConfig) registry.ChatConfig { WebhookURL: cc.WebhookURL, } } + +func toRegistryOTP(oc OTPConfig) registry.OTPConfig { + return registry.OTPConfig{ + CommonConfig: registry.CommonConfig{APIKey: oc.APIKey, APISecret: oc.APISecret, Region: oc.Region, BaseURL: oc.BaseURL}, + PhoneNumber: oc.PhoneNumber, + Code: oc.Code, + CodeLength: oc.CodeLength, + SenderUsername: oc.SenderUsername, + } +} diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index d375f8c4..77882d92 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -31,10 +31,12 @@ type Gateway struct { smsSenders map[string]port.SMSSender pushSenders map[string]port.PushSender chatSenders map[string]port.ChatSender + otpSenders map[string]port.OTPSender defaultEmail string defaultSMS string defaultPush string defaultChat string + defaultOTP string } // NewWithService creates a Gateway scoped to a workspace using a wired @@ -55,10 +57,12 @@ func New(cfg Config) (*Gateway, error) { smsSenders: make(map[string]port.SMSSender), pushSenders: make(map[string]port.PushSender), chatSenders: make(map[string]port.ChatSender), + otpSenders: make(map[string]port.OTPSender), defaultEmail: cfg.DefaultEmailProvider, defaultSMS: cfg.DefaultSMSProvider, defaultPush: cfg.DefaultPushProvider, defaultChat: cfg.DefaultChatProvider, + defaultOTP: cfg.DefaultOTPProvider, } if err := buildEmailSenders(g, cfg); err != nil { return nil, err @@ -72,6 +76,9 @@ func New(cfg Config) (*Gateway, error) { if err := buildChatSenders(g, cfg); err != nil { return nil, err } + if err := buildOTPSenders(g, cfg); err != nil { + return nil, err + } return g, nil } @@ -124,6 +131,17 @@ func (g *Gateway) SendChat(ctx context.Context, chat *contracts.ChatMessage) (*c return sender.Send(ctx, chat) } +func (g *Gateway) SendOTP(ctx context.Context, otp *contracts.OTP) (*contracts.SendResult, error) { + if g.svc != nil { + return g.svc.SendOTP(ctx, g.workspaceID, otp) + } + sender, err := g.otpSender() + if err != nil { + return nil, err + } + return sender.Send(ctx, otp) +} + func (g *Gateway) emailSender() (port.EmailSender, error) { s, ok := g.emailSenders[g.defaultEmail] if !ok { @@ -155,3 +173,11 @@ func (g *Gateway) chatSender() (port.ChatSender, error) { } return s, nil } + +func (g *Gateway) otpSender() (port.OTPSender, error) { + s, ok := g.otpSenders[g.defaultOTP] + if !ok { + return nil, fmt.Errorf("gateway: OTP provider %q not configured", g.defaultOTP) + } + return s, nil +} diff --git a/tests/bruno/Gateway/Send OTP.bru b/tests/bruno/Gateway/Send OTP.bru new file mode 100644 index 00000000..5d471572 --- /dev/null +++ b/tests/bruno/Gateway/Send OTP.bru @@ -0,0 +1,40 @@ +meta { + name: Send OTP + type: http + seq: 3 +} + +post { + url: {{baseUrl}}/v1/otp + body: json + auth: basic +} + +auth:basic { + username: demo-client-id + password: demo-secret +} + +headers { + X-Workspace-Key: {{workspaceKey}} +} + +body:json { + { + "phone_number": "+123456789", + "sender_username": "Solaris", + "code": "895663", + "code_length": 6 + } +} + +tests { + test("should return 200", function() { + expect(res.status).to.equal(200); + }); + + test("should return non-empty message ID", function() { + expect(res.body.id).to.be.a("string"); + expect(res.body.id.length).to.be.greaterThan(0); + }); +} diff --git a/tests/bruno/Portal/OTP/Delete OTP.bru b/tests/bruno/Portal/OTP/Delete OTP.bru new file mode 100644 index 00000000..5291f015 --- /dev/null +++ b/tests/bruno/Portal/OTP/Delete OTP.bru @@ -0,0 +1,24 @@ +meta { + name: Delete OTP + type: http + seq: 4 +} + +delete { + url: {{baseUrl}}/api/v1/workspaces/{{workspaceId}}/inbox/otp/{{otpId}} + body: none + auth: none +} + +headers { + Authorization: Bearer {{portalJwt}} + X-Api-Client-Id: {{apiClientId}} + X-Api-Client-Secret: {{apiSecret}} +} + + +tests { + test("should return 204", function() { + expect(res.status).to.equal(204); + }); +} diff --git a/tests/bruno/Portal/OTP/Get OTP by ID.bru b/tests/bruno/Portal/OTP/Get OTP by ID.bru new file mode 100644 index 00000000..eb64b828 --- /dev/null +++ b/tests/bruno/Portal/OTP/Get OTP by ID.bru @@ -0,0 +1,37 @@ +meta { + name: Get OTP by ID + type: http + seq: 3 +} + +get { + url: {{baseUrl}}/api/v1/workspaces/{{workspaceId}}/inbox/otp/{{otpId}} + body: none + auth: none +} + +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 otp object", function() { + expect(res.body).to.have.property("id"); + expect(res.body).to.have.property("otp"); + }); + + test("should have correct otp fields", function() { + const otp = res.body.otp; + expect(otp.phone_number).to.be.an("array"); + expect(otp.sender_username).to.be.an("array"); + expect(otp.code).to.be.an("array"); + expect(otp.code_length).to.be.a("number"); + }); +} diff --git a/tests/bruno/Portal/OTP/Ingest OTP.bru b/tests/bruno/Portal/OTP/Ingest OTP.bru new file mode 100644 index 00000000..89f4b793 --- /dev/null +++ b/tests/bruno/Portal/OTP/Ingest OTP.bru @@ -0,0 +1,37 @@ +meta { + name: Ingest OTP + type: http + seq: 1 +} + +post { + url: {{baseUrl}}/api/v1/workspaces/{{workspaceId}}/internal/otp + body: json + auth: none +} + +headers { + X-Internal-Secret: {{internalSecret}} +} + + +body:json { + { + "phone_number": ["+49123456789"], + "sender_username": ["verify-service"], + "code": ["847291"], + "code_length": 6 + } +} + +tests { + test("should return 201", function() { + expect(res.status).to.equal(201); + }); + + test("should return non-empty message ID", function() { + expect(res.body.id).to.be.a("string"); + expect(res.body.id.length).to.be.greaterThan(0); + bru.setVar("otpId", res.body.id); + }); +} diff --git a/tests/bruno/Portal/OTP/List OTP.bru b/tests/bruno/Portal/OTP/List OTP.bru new file mode 100644 index 00000000..61460318 --- /dev/null +++ b/tests/bruno/Portal/OTP/List OTP.bru @@ -0,0 +1,37 @@ +meta { + name: List OTP + type: http + seq: 2 +} + +get { + url: {{baseUrl}}/api/v1/workspaces/{{workspaceId}}/inbox/otp + body: none + auth: none +} + +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 have otp with required fields", function() { + if (res.body.length > 0) { + const otp = res.body[0]; + expect(otp).to.have.property("id"); + expect(otp).to.have.property("otp"); + expect(otp).to.have.property("created_at"); + } + }); +} diff --git a/tests/bruno/environments/local.bru b/tests/bruno/environments/local.bru index bd2d8ffb..c29d5701 100644 --- a/tests/bruno/environments/local.bru +++ b/tests/bruno/environments/local.bru @@ -1,9 +1,9 @@ vars { baseUrl: http://localhost:10101 - workspaceKey: demo + workspaceKey: test-workspace workspaceId: 00000000-0000-0000-0000-000000000001 portalJwt: - apiClientId: demo-client-id - apiSecret: replace-with-plaintext-secret-from-portal + apiClientId: my-test-client + apiSecret: demo-secret internalSecret: }