From 80243df15233a743092d118a49a8653a8e082be9 Mon Sep 17 00:00:00 2001
From: Parsa Soroush
Date: Wed, 6 May 2026 19:11:56 +0330
Subject: [PATCH 1/7] WPD-455-feat: Added new changes for adding OTP config
---
configs/local.yml | 3 +++
docs/backend/architecture.md | 18 +++++++++---------
internal/app/config.go | 1 +
internal/core/port/otp.go | 13 +++++++++++++
internal/registry/registry.go | 10 ++++++++++
pkg/contracts/otp.go | 17 +++++++++++++++++
6 files changed, 53 insertions(+), 9 deletions(-)
create mode 100644 internal/core/port/otp.go
create mode 100644 pkg/contracts/otp.go
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/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/internal/app/config.go b/internal/app/config.go
index 49719f28..5e1cdcd2 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.
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/registry/registry.go b/internal/registry/registry.go
index 29113840..48421841 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,6 +71,8 @@ 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)
diff --git a/pkg/contracts/otp.go b/pkg/contracts/otp.go
new file mode 100644
index 00000000..68ae4e65
--- /dev/null
+++ b/pkg/contracts/otp.go
@@ -0,0 +1,17 @@
+package contracts
+
+import "context"
+
+// OTP represents a OTP Verification message to be sent.
+type OTP struct {
+ PhoneNumber []string `json:"phone_number,omitempty"`
+ SenderUsername []string `json:"sender_username"`
+ 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
+}
From 21a19f44c96d63929842b0456a187c1476e14e68 Mon Sep 17 00:00:00 2001
From: Parsa Soroush
Date: Thu, 7 May 2026 05:42:47 +0330
Subject: [PATCH 2/7] WPD-455-feat: Finished for Adding OTP Config
---
Readme.md | 8 ++-
docs/backend/contributing.md | 2 +
internal/app/config.go | 13 +++++
internal/app/validation.go | 14 ++++-
internal/core/service/gateway_service.go | 29 ++++++++++
internal/core/service/provider_cache.go | 24 ++++++++
.../infrastructure/provider/memory/otp.go | 48 ++++++++++++++++
.../provider/memory/register.go | 4 ++
.../infrastructure/provider/memory/store.go | 57 ++++++++++++++++++-
.../presentation/handler/gateway_handler.go | 10 +++-
.../handler/portal_inbox_handler.go | 28 +++++++++
internal/presentation/router.go | 5 ++
internal/registry/registry.go | 2 +-
pkg/contracts/otp.go | 8 +--
pkg/gateway/config.go | 31 ++++++++++
pkg/gateway/gateway.go | 26 +++++++++
tests/bruno/Gateway/Send OTP.bru | 35 ++++++++++++
tests/bruno/Portal/OTP/Delete OTP.bru | 24 ++++++++
tests/bruno/Portal/OTP/Get OTP by ID.bru | 37 ++++++++++++
tests/bruno/Portal/OTP/Ingest OTP.bru | 37 ++++++++++++
tests/bruno/Portal/OTP/List OTP.bru | 37 ++++++++++++
21 files changed, 466 insertions(+), 13 deletions(-)
create mode 100644 internal/infrastructure/provider/memory/otp.go
create mode 100644 tests/bruno/Gateway/Send OTP.bru
create mode 100644 tests/bruno/Portal/OTP/Delete OTP.bru
create mode 100644 tests/bruno/Portal/OTP/Get OTP by ID.bru
create mode 100644 tests/bruno/Portal/OTP/Ingest OTP.bru
create mode 100644 tests/bruno/Portal/OTP/List OTP.bru
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/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/internal/app/config.go b/internal/app/config.go
index 5e1cdcd2..2f5c61a6 100644
--- a/internal/app/config.go
+++ b/internal/app/config.go
@@ -43,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.
@@ -51,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) {
@@ -79,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 {
@@ -118,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
}
@@ -167,4 +174,10 @@ 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)),
+ }
+ }
}
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/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/provider_cache.go b/internal/core/service/provider_cache.go
index c900e4ed..26c67033 100644
--- a/internal/core/service/provider_cache.go
+++ b/internal/core/service/provider_cache.go
@@ -62,6 +62,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 +149,26 @@ func resolveChatSender(cache *chatSenderCache, intg *domain.Integration) (port.C
return sender, nil
}
+// 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
+}
+
// ensure time is used (for cacheKey.updatedAt type alignment).
var _ = time.Now
diff --git a/internal/infrastructure/provider/memory/otp.go b/internal/infrastructure/provider/memory/otp.go
new file mode 100644
index 00000000..42e0b987
--- /dev/null
+++ b/internal/infrastructure/provider/memory/otp.go
@@ -0,0 +1,48 @@
+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
+}
+
+// NewOTPProvider creates a new memory OTP provider.
+func NewOTPProvider(store *Store) *OTPProvider {
+ return &OTPProvider{store: store}
+}
+
+// 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,
+ CreatedAt: time.Now(),
+ OTP: otp,
+ }
+ o.store.AddOTP(stored)
+
+ return &contracts.SendResult{
+ ID: id,
+ StatusCode: 200,
+ Message: "Stored OTP in memory",
+ }, nil
+}
\ No newline at end of file
diff --git a/internal/infrastructure/provider/memory/register.go b/internal/infrastructure/provider/memory/register.go
index fdab4e1d..2313eb3c 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 NewChatProvider(GetStore()), nil
+ })
}
diff --git a/internal/infrastructure/provider/memory/store.go b/internal/infrastructure/provider/memory/store.go
index 231ccb7d..db007077 100644
--- a/internal/infrastructure/provider/memory/store.go
+++ b/internal/infrastructure/provider/memory/store.go
@@ -52,6 +52,13 @@ type StoredChat struct {
Chat *contracts.ChatMessage `json:"chat"`
}
+// StoredOTP wraps an OTP with metadata for storage.
+type StoredOTP struct {
+ ID string `json:"id"`
+ 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 +66,7 @@ type Store struct {
sms []*StoredSMS
pushes []*StoredPush
chats []*StoredChat
+ otps []*StoredOTP
}
// NewStore creates a new in-memory store.
@@ -68,6 +76,7 @@ func NewStore() *Store {
sms: make([]*StoredSMS, 0),
pushes: make([]*StoredPush, 0),
chats: make([]*StoredChat, 0),
+ otps: make([]*StoredOTP, 0),
}
}
@@ -133,6 +142,7 @@ func (s *Store) StatsForWorkspace(workspaceID string) map[string]int {
"sms": 0,
"push": 0,
"chat": 0,
+ "otp": 0,
"total": n,
}
}
@@ -305,11 +315,52 @@ 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)
+}
+
// 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 +372,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 +385,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/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..4f31bce8 100644
--- a/internal/presentation/handler/portal_inbox_handler.go
+++ b/internal/presentation/handler/portal_inbox_handler.go
@@ -101,6 +101,18 @@ 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 {
+ return c.JSON(http.StatusOK, []any{})
+}
+
+func (h *PortalInboxHandler) HandleGetOTPByID(c echo.Context) error {
+ return c.JSON(http.StatusNotFound, map[string]string{"error": "otp not found"})
+}
+
+func (h *PortalInboxHandler) HandleDeleteOTPByID(c echo.Context) error {
+ return c.JSON(http.StatusNotFound, map[string]string{"error": "otp not found"})
+}
+
// HandleClearAll removes in-memory messages for this workspace (emails).
func (h *PortalInboxHandler) HandleClearAll(c echo.Context) error {
h.store.ClearWorkspace(wid(c))
@@ -175,6 +187,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: " + err.Error()})
+ }
+
+ 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: " + err.Error()})
+ }
+
+ 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 48421841..e686764c 100644
--- a/internal/registry/registry.go
+++ b/internal/registry/registry.go
@@ -51,7 +51,7 @@ type ChatConfig struct {
WebhookURL string
}
-type OTPConfig struct{
+type OTPConfig struct {
CommonConfig
PhoneNumber string
Code string
diff --git a/pkg/contracts/otp.go b/pkg/contracts/otp.go
index 68ae4e65..ea05708e 100644
--- a/pkg/contracts/otp.go
+++ b/pkg/contracts/otp.go
@@ -4,10 +4,10 @@ import "context"
// OTP represents a OTP Verification message to be sent.
type OTP struct {
- PhoneNumber []string `json:"phone_number,omitempty"`
- SenderUsername []string `json:"sender_username"`
- Code []string `json:"code,omitempty"`
- CodeLength int `json:"code_length"`
+ PhoneNumber []string `json:"phone_number,omitempty"`
+ SenderUsername []string `json:"sender_username"`
+ Code []string `json:"code,omitempty"`
+ CodeLength int `json:"code_length"`
}
// OTPSender defines the contract for sending OTP verification messages.
diff --git a/pkg/gateway/config.go b/pkg/gateway/config.go
index 67420b35..7cba1ce0 100644
--- a/pkg/gateway/config.go
+++ b/pkg/gateway/config.go
@@ -42,6 +42,12 @@ type ChatConfig struct {
WebhookURL string
}
+// OTPConfig holds OTP-specific static provider configuration.
+type OTPConfig struct {
+ CommonConfig
+ SenderID 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 +55,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 +128,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 +175,10 @@ 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},
+ SenderID: oc.SenderID,
+ }
+}
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..455f9594
--- /dev/null
+++ b/tests/bruno/Gateway/Send OTP.bru
@@ -0,0 +1,35 @@
+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 501 (OTP not implemented)", function() {
+ expect(res.status).to.equal(501);
+ });
+}
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");
+ }
+ });
+}
From d4adefd74cad804a57228cc5eb0632ad6b3466c3 Mon Sep 17 00:00:00 2001
From: Parsa Soroush
Date: Tue, 19 May 2026 13:38:31 +0330
Subject: [PATCH 3/7] WPD-455-refactor: Changed Name of users Columns to Work
with Portal UI
---
.specify/memory/setup.json | 8 +-
cmd/server/main.go | 1 +
.../20260318000000_init_gateway.up.sql | 17 +-
frontend/package-lock.json | 278 ++++++++----------
go.mod | 18 +-
go.sum | 37 ++-
internal/core/port/inbox.go | 3 +
.../provider/memory/inbox_writer.go | 11 +
.../provider/memory/register.go | 2 +-
internal/registry/registry.go | 28 ++
pkg/gateway/config.go | 4 +-
11 files changed, 212 insertions(+), 195 deletions(-)
diff --git a/.specify/memory/setup.json b/.specify/memory/setup.json
index c57205fd..2b846774 100644
--- a/.specify/memory/setup.json
+++ b/.specify/memory/setup.json
@@ -2,9 +2,9 @@
"platform": "macos",
"default_branch": "develop",
"tools": {
- "default": { "tools": ["antigravity"], "model": "gemini-3-pro-preview" },
- "reviewer": { "tools": ["cursor"], "model": "gemini-3-pro-preview" },
- "test_docs": { "tools": ["antigravity"], "model": "claude-sonnet" }
+ "default": { "tools": ["cursor"], "model": "claude-sonnet" },
+ "reviewer": { "tools": ["cursor"], "model": "claude-sonnet" },
+ "test_docs": { "tools": ["cursor"], "model": "claude-sonnet" }
},
- "default_model": "gemini-3-pro-preview"
+ "default_model": "claude-sonnet"
}
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/database/migrations/20260318000000_init_gateway.up.sql b/database/migrations/20260318000000_init_gateway.up.sql
index d5fe95cb..8fdbd743 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))
@@ -167,7 +166,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"]
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/core/port/inbox.go b/internal/core/port/inbox.go
index a092c0e0..9cab9743 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 chat 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/infrastructure/provider/memory/inbox_writer.go b/internal/infrastructure/provider/memory/inbox_writer.go
index 83612abe..e8e27ba0 100644
--- a/internal/infrastructure/provider/memory/inbox_writer.go
+++ b/internal/infrastructure/provider/memory/inbox_writer.go
@@ -72,3 +72,14 @@ 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, _ string, otp *contracts.OTP) (string, error) {
+ id := uuid.New().String()
+ a.store.AddOTP(&StoredOTP{
+ ID: id,
+ CreatedAt: time.Now(),
+ OTP: otp,
+ })
+ return id, nil
+}
diff --git a/internal/infrastructure/provider/memory/register.go b/internal/infrastructure/provider/memory/register.go
index 2313eb3c..1d4ff3fa 100644
--- a/internal/infrastructure/provider/memory/register.go
+++ b/internal/infrastructure/provider/memory/register.go
@@ -23,6 +23,6 @@ func init() {
})
registry.RegisterOTPProvider("memory", func(cfg registry.OTPConfig) (port.OTPSender, error) {
- return NewChatProvider(GetStore()), nil
+ return NewOTPProvider(GetStore()), nil
})
}
diff --git a/internal/registry/registry.go b/internal/registry/registry.go
index e686764c..aa7e2cf8 100644
--- a/internal/registry/registry.go
+++ b/internal/registry/registry.go
@@ -79,6 +79,7 @@ var (
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.
@@ -110,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()
@@ -154,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()
@@ -185,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
+}
\ No newline at end of file
diff --git a/pkg/gateway/config.go b/pkg/gateway/config.go
index 7cba1ce0..4ffa7927 100644
--- a/pkg/gateway/config.go
+++ b/pkg/gateway/config.go
@@ -45,7 +45,7 @@ type ChatConfig struct {
// OTPConfig holds OTP-specific static provider configuration.
type OTPConfig struct {
CommonConfig
- SenderID string
+ SenderUsername string
}
// Config is the static gateway configuration used with New().
@@ -179,6 +179,6 @@ func toRegistryChat(cc ChatConfig) registry.ChatConfig {
func toRegistryOTP(oc OTPConfig) registry.OTPConfig {
return registry.OTPConfig{
CommonConfig: registry.CommonConfig{APIKey: oc.APIKey, APISecret: oc.APISecret, Region: oc.Region, BaseURL: oc.BaseURL},
- SenderID: oc.SenderID,
+ SenderUsername: oc.SenderUsername,
}
}
From 59f173b996ba4aca0b1391c5c885baf9b080cdc2 Mon Sep 17 00:00:00 2001
From: Parsa Soroush
Date: Wed, 20 May 2026 08:27:11 +0330
Subject: [PATCH 4/7] WPD-455-feat: add OTP channel support with
workspace-scoped memory storage
---
.specify/memory/setup.json | 8 +-
.../20260318000000_init_gateway.up.sql | 25 +++---
database/seeds/001_demo_workspace.sql | 40 ++++++++--
internal/app/config.go | 6 +-
internal/core/domain/workspace_member.go | 2 +-
internal/core/service/portal_service.go | 2 +-
.../provider/memory/inbox_writer.go | 9 ++-
.../infrastructure/provider/memory/otp.go | 22 ++++--
.../infrastructure/provider/memory/store.go | 77 +++++++++++++++----
.../repository/postgres/api_key_repository.go | 2 +-
.../postgres/integration_repository.go | 2 +-
.../postgres/invitation_repository.go | 2 +-
.../message_request_log_repository.go | 2 +-
.../postgres/template_repository.go | 2 +-
.../postgres/workspace_member_repository.go | 22 +++---
.../postgres/workspace_repository.go | 2 +-
.../handler/portal_inbox_handler.go | 17 +++-
pkg/gateway/config.go | 10 ++-
tests/bruno/Gateway/Send OTP.bru | 9 ++-
tests/bruno/environments/local.bru | 8 +-
20 files changed, 191 insertions(+), 78 deletions(-)
diff --git a/.specify/memory/setup.json b/.specify/memory/setup.json
index 2b846774..5bbb3c03 100644
--- a/.specify/memory/setup.json
+++ b/.specify/memory/setup.json
@@ -2,9 +2,9 @@
"platform": "macos",
"default_branch": "develop",
"tools": {
- "default": { "tools": ["cursor"], "model": "claude-sonnet" },
- "reviewer": { "tools": ["cursor"], "model": "claude-sonnet" },
- "test_docs": { "tools": ["cursor"], "model": "claude-sonnet" }
+ "default": { "tools": ["cursor"], "model": "gpt-5" },
+ "reviewer": { "tools": ["cursor"], "model": "gpt-5" },
+ "test_docs": { "tools": ["cursor"], "model": "gpt-5" }
},
- "default_model": "claude-sonnet"
+ "default_model": "gpt-5"
}
diff --git a/database/migrations/20260318000000_init_gateway.up.sql b/database/migrations/20260318000000_init_gateway.up.sql
index 8fdbd743..7b02aec8 100644
--- a/database/migrations/20260318000000_init_gateway.up.sql
+++ b/database/migrations/20260318000000_init_gateway.up.sql
@@ -20,7 +20,7 @@ CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL,
password_hash TEXT,
- display_name TEXT, NOT NULL,
+ display_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'suspended', 'blocked')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
@@ -41,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)';
@@ -94,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
@@ -104,7 +105,7 @@ CREATE TABLE invitations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
email TEXT NOT NULL,
- role_id UUID NOT NULL REFERENCES roles(id) ON DELETE RESTRICT,
+ role TEXT NOT NULL,
token_hash TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
@@ -128,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)
@@ -141,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(),
@@ -189,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')),
@@ -244,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,
@@ -276,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/internal/app/config.go b/internal/app/config.go
index 2f5c61a6..020e999c 100644
--- a/internal/app/config.go
+++ b/internal/app/config.go
@@ -177,7 +177,11 @@ func (c *Config) parseProviderConfigs() {
for name, m := range c.Providers.OTP {
c.OTPProviders[name] = registry.OTPConfig{
- CommonConfig: buildCommon(map[string]string(m)),
+ 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/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/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/infrastructure/provider/memory/inbox_writer.go b/internal/infrastructure/provider/memory/inbox_writer.go
index e8e27ba0..d1b6346a 100644
--- a/internal/infrastructure/provider/memory/inbox_writer.go
+++ b/internal/infrastructure/provider/memory/inbox_writer.go
@@ -74,12 +74,13 @@ func (a *InboxWriterAdapter) WriteChat(_ context.Context, _ string, chat *contra
}
// WriteOTP stores an OTP message in memory.
-func (a *InboxWriterAdapter) WriteOTP(_ context.Context, _ string, otp *contracts.OTP) (string, error) {
+func (a *InboxWriterAdapter) WriteOTP(_ context.Context, workspaceID string, otp *contracts.OTP) (string, error) {
id := uuid.New().String()
a.store.AddOTP(&StoredOTP{
- ID: id,
- CreatedAt: time.Now(),
- OTP: otp,
+ 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
index 42e0b987..2c84c071 100644
--- a/internal/infrastructure/provider/memory/otp.go
+++ b/internal/infrastructure/provider/memory/otp.go
@@ -11,12 +11,21 @@ import (
// OTPProvider implements port.OTPSender using an in-memory store.
type OTPProvider struct {
- store *Store
+ store *Store
+ workspaceID string
}
-// NewOTPProvider creates a new memory OTP provider.
+// NewOTPProvider creates a new memory OTP provider (no workspace scope; global inbox).
func NewOTPProvider(store *Store) *OTPProvider {
- return &OTPProvider{store: store}
+ 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.
@@ -34,9 +43,10 @@ func (o *OTPProvider) Send(ctx context.Context, otp *contracts.OTP) (*contracts.
id := uuid.New().String()
stored := &StoredOTP{
- ID: id,
- CreatedAt: time.Now(),
- OTP: otp,
+ ID: id,
+ WorkspaceID: o.workspaceID,
+ CreatedAt: time.Now(),
+ OTP: otp,
}
o.store.AddOTP(stored)
diff --git a/internal/infrastructure/provider/memory/store.go b/internal/infrastructure/provider/memory/store.go
index db007077..d60fcf3c 100644
--- a/internal/infrastructure/provider/memory/store.go
+++ b/internal/infrastructure/provider/memory/store.go
@@ -54,9 +54,10 @@ type StoredChat struct {
// StoredOTP wraps an OTP with metadata for storage.
type StoredOTP struct {
- ID string `json:"id"`
- CreatedAt time.Time `json:"created_at"`
- OTP *contracts.OTP `json:"otp"`
+ 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.
@@ -127,37 +128,49 @@ 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,
+ "emails": emails,
"sms": 0,
"push": 0,
"chat": 0,
- "otp": 0,
- "total": n,
+ "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.
@@ -356,6 +369,44 @@ func (s *Store) AddOTP(stored *StoredOTP) {
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()
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/portal_inbox_handler.go b/internal/presentation/handler/portal_inbox_handler.go
index 4f31bce8..219d43b0 100644
--- a/internal/presentation/handler/portal_inbox_handler.go
+++ b/internal/presentation/handler/portal_inbox_handler.go
@@ -102,15 +102,26 @@ func (h *PortalInboxHandler) HandleDeleteChatByID(c echo.Context) error {
}
func (h *PortalInboxHandler) HandleGetOTP(c echo.Context) error {
- return c.JSON(http.StatusOK, []any{})
+ otps := h.store.OTPsForWorkspace(wid(c))
+ return c.JSON(http.StatusOK, otps)
}
func (h *PortalInboxHandler) HandleGetOTPByID(c echo.Context) error {
- return c.JSON(http.StatusNotFound, map[string]string{"error": "otp not found"})
+ 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 {
- return c.JSON(http.StatusNotFound, map[string]string{"error": "otp not found"})
+ 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).
diff --git a/pkg/gateway/config.go b/pkg/gateway/config.go
index 4ffa7927..75fa3c14 100644
--- a/pkg/gateway/config.go
+++ b/pkg/gateway/config.go
@@ -45,6 +45,9 @@ type ChatConfig struct {
// OTPConfig holds OTP-specific static provider configuration.
type OTPConfig struct {
CommonConfig
+ PhoneNumber string
+ Code string
+ CodeLength string
SenderUsername string
}
@@ -178,7 +181,10 @@ func toRegistryChat(cc ChatConfig) registry.ChatConfig {
func toRegistryOTP(oc OTPConfig) registry.OTPConfig {
return registry.OTPConfig{
- CommonConfig: registry.CommonConfig{APIKey: oc.APIKey, APISecret: oc.APISecret, Region: oc.Region, BaseURL: oc.BaseURL},
- SenderUsername: oc.SenderUsername,
+ 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/tests/bruno/Gateway/Send OTP.bru b/tests/bruno/Gateway/Send OTP.bru
index 455f9594..5d471572 100644
--- a/tests/bruno/Gateway/Send OTP.bru
+++ b/tests/bruno/Gateway/Send OTP.bru
@@ -29,7 +29,12 @@ body:json {
}
tests {
- test("should return 501 (OTP not implemented)", function() {
- expect(res.status).to.equal(501);
+ 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/environments/local.bru b/tests/bruno/environments/local.bru
index bd2d8ffb..8c15b653 100644
--- a/tests/bruno/environments/local.bru
+++ b/tests/bruno/environments/local.bru
@@ -1,9 +1,9 @@
vars {
baseUrl: http://localhost:10101
- workspaceKey: demo
- workspaceId: 00000000-0000-0000-0000-000000000001
+ workspaceKey: test-workspace
+ workspaceId: f3083f6e-822c-4fd1-a334-38401df48f6c
portalJwt:
- apiClientId: demo-client-id
- apiSecret: replace-with-plaintext-secret-from-portal
+ apiClientId: my-test-client
+ apiSecret: demo-secret
internalSecret:
}
From 9c62cfab8afa6c5fa36b795082109d39d008e130 Mon Sep 17 00:00:00 2001
From: Parsa Soroush
Date: Wed, 20 May 2026 09:56:42 +0330
Subject: [PATCH 5/7] WPD-455-refactor: Fixed found Bugs
---
database/migrations/20260318000000_init_gateway.up.sql | 2 +-
internal/core/port/inbox.go | 2 +-
internal/core/service/provider_cache.go | 4 ----
internal/infrastructure/provider/memory/otp.go | 2 +-
internal/infrastructure/provider/memory/store.go | 3 ---
internal/presentation/handler/portal_inbox_handler.go | 4 ++--
internal/registry/registry.go | 2 +-
pkg/contracts/otp.go | 4 ++--
tests/bruno/environments/local.bru | 2 +-
9 files changed, 9 insertions(+), 16 deletions(-)
diff --git a/database/migrations/20260318000000_init_gateway.up.sql b/database/migrations/20260318000000_init_gateway.up.sql
index 7b02aec8..8fb258d9 100644
--- a/database/migrations/20260318000000_init_gateway.up.sql
+++ b/database/migrations/20260318000000_init_gateway.up.sql
@@ -105,7 +105,7 @@ CREATE TABLE invitations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
email TEXT NOT NULL,
- role TEXT NOT NULL,
+ role_id UUID NOT NULL REFERENCES roles(id) ON DELETE RESTRICT,
token_hash TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
diff --git a/internal/core/port/inbox.go b/internal/core/port/inbox.go
index 9cab9743..240bcf1f 100644
--- a/internal/core/port/inbox.go
+++ b/internal/core/port/inbox.go
@@ -24,6 +24,6 @@ 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 chat message against workspaceID and returns an assigned ID.
+ // 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/service/provider_cache.go b/internal/core/service/provider_cache.go
index 26c67033..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"
@@ -169,6 +168,3 @@ func resolveOTPSender(cache *otpSenderCache, intg *domain.Integration) (port.OTP
cache.set(intg, sender)
return sender, nil
}
-
-// ensure time is used (for cacheKey.updatedAt type alignment).
-var _ = time.Now
diff --git a/internal/infrastructure/provider/memory/otp.go b/internal/infrastructure/provider/memory/otp.go
index 2c84c071..0a5dc0ad 100644
--- a/internal/infrastructure/provider/memory/otp.go
+++ b/internal/infrastructure/provider/memory/otp.go
@@ -55,4 +55,4 @@ func (o *OTPProvider) Send(ctx context.Context, otp *contracts.OTP) (*contracts.
StatusCode: 200,
Message: "Stored OTP in memory",
}, nil
-}
\ No newline at end of file
+}
diff --git a/internal/infrastructure/provider/memory/store.go b/internal/infrastructure/provider/memory/store.go
index d60fcf3c..a006f93a 100644
--- a/internal/infrastructure/provider/memory/store.go
+++ b/internal/infrastructure/provider/memory/store.go
@@ -145,9 +145,6 @@ func (s *Store) StatsForWorkspace(workspaceID string) map[string]int {
}
return map[string]int{
"emails": emails,
- "sms": 0,
- "push": 0,
- "chat": 0,
"otp": otps,
"total": emails + otps,
}
diff --git a/internal/presentation/handler/portal_inbox_handler.go b/internal/presentation/handler/portal_inbox_handler.go
index 219d43b0..ddabd2a0 100644
--- a/internal/presentation/handler/portal_inbox_handler.go
+++ b/internal/presentation/handler/portal_inbox_handler.go
@@ -201,13 +201,13 @@ func (h *PortalInboxHandler) HandleIngestChat(c echo.Context) error {
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: " + err.Error()})
+ 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: " + err.Error()})
+ 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})
diff --git a/internal/registry/registry.go b/internal/registry/registry.go
index aa7e2cf8..b975eb4d 100644
--- a/internal/registry/registry.go
+++ b/internal/registry/registry.go
@@ -212,4 +212,4 @@ func IsOTPProviderRegistered(name string) bool {
defer mu.RUnlock()
_, ok := otpFactories[name]
return ok
-}
\ No newline at end of file
+}
diff --git a/pkg/contracts/otp.go b/pkg/contracts/otp.go
index ea05708e..45fb253e 100644
--- a/pkg/contracts/otp.go
+++ b/pkg/contracts/otp.go
@@ -2,10 +2,10 @@ package contracts
import "context"
-// OTP represents a OTP Verification message to be sent.
+// OTP represents an OTP verification message to be sent.
type OTP struct {
PhoneNumber []string `json:"phone_number,omitempty"`
- SenderUsername []string `json:"sender_username"`
+ SenderUsername []string `json:"sender_username,omitempty"`
Code []string `json:"code,omitempty"`
CodeLength int `json:"code_length"`
}
diff --git a/tests/bruno/environments/local.bru b/tests/bruno/environments/local.bru
index 8c15b653..c29d5701 100644
--- a/tests/bruno/environments/local.bru
+++ b/tests/bruno/environments/local.bru
@@ -1,7 +1,7 @@
vars {
baseUrl: http://localhost:10101
workspaceKey: test-workspace
- workspaceId: f3083f6e-822c-4fd1-a334-38401df48f6c
+ workspaceId: 00000000-0000-0000-0000-000000000001
portalJwt:
apiClientId: my-test-client
apiSecret: demo-secret
From 4ef1f1c4ce72bf2c0e5c534528ecd883d3219d3f Mon Sep 17 00:00:00 2001
From: Parsa Soroush
Date: Wed, 20 May 2026 10:21:20 +0330
Subject: [PATCH 6/7] WPD-455-refactor: Reverted not needed codes
---
.specify/memory/setup.json | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/.specify/memory/setup.json b/.specify/memory/setup.json
index 5bbb3c03..9e88e32c 100644
--- a/.specify/memory/setup.json
+++ b/.specify/memory/setup.json
@@ -2,9 +2,9 @@
"platform": "macos",
"default_branch": "develop",
"tools": {
- "default": { "tools": ["cursor"], "model": "gpt-5" },
- "reviewer": { "tools": ["cursor"], "model": "gpt-5" },
- "test_docs": { "tools": ["cursor"], "model": "gpt-5" }
+ "default": { "tools": ["antigravity"], "model": "gemini-3-pro-preview" },
+ "reviewer": { "tools": ["cursor"], "model": "gemini-3-pro-preview" },
+ "test_docs": { "tools": ["antigravity"], "model": "calude-sonnet" }
},
- "default_model": "gpt-5"
+ "default_model": "gemini-3-pro-preview"
}
From 85e02b95b6e874980cfcd29d5f32199d3406d3c4 Mon Sep 17 00:00:00 2001
From: Parsa Soroush
Date: Wed, 20 May 2026 10:22:25 +0330
Subject: [PATCH 7/7] WPD-455-fix: Spell Correction
---
.specify/memory/setup.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.specify/memory/setup.json b/.specify/memory/setup.json
index 9e88e32c..c57205fd 100644
--- a/.specify/memory/setup.json
+++ b/.specify/memory/setup.json
@@ -4,7 +4,7 @@
"tools": {
"default": { "tools": ["antigravity"], "model": "gemini-3-pro-preview" },
"reviewer": { "tools": ["cursor"], "model": "gemini-3-pro-preview" },
- "test_docs": { "tools": ["antigravity"], "model": "calude-sonnet" }
+ "test_docs": { "tools": ["antigravity"], "model": "claude-sonnet" }
},
"default_model": "gemini-3-pro-preview"
}