Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d8ce8d1
WPD-33-feat: refactored message dispatch and added data retention modes
MichaelAndish Jul 2, 2026
2a59c8f
WPD-33-feat: added demo credentials output to make dev command
MichaelAndish Jul 2, 2026
f900fd5
WPD-33-fix: corrected footer slogan text and alignment
MichaelAndish Jul 2, 2026
fd62916
WPD-33-fix: moved Danger zone inside General tab content
MichaelAndish Jul 2, 2026
c4b976f
WPD-33-fix: unlocked DB storage config across all dispatch modes and …
MichaelAndish Jul 2, 2026
bb14502
WPD-33-fix: replaced deprecated React.ElementRef with React.ComponentRef
MichaelAndish Jul 2, 2026
bf1c441
chore: update deps
MichaelAndish Jul 3, 2026
7809080
feat: implement message request payload storage and logging enhancements
MichaelAndish Jul 3, 2026
6465933
refactor: consolidate inbox API, optimize database indices, and imple…
MichaelAndish Jul 3, 2026
ead3fa7
feat: enhance inbox functionality and update code structure
MichaelAndish Jul 4, 2026
2557364
feat: enhance database initialization and update workspace ID handling
MichaelAndish Jul 4, 2026
80aacdf
feat: add new UI components and tests for improved user experience
MichaelAndish Jul 4, 2026
7dabe3a
feat: update roles and permissions handling in database and frontend
MichaelAndish Jul 4, 2026
e6cd722
fix: update test files and SQL queries for improved functionality
MichaelAndish Jul 4, 2026
a45c02b
feat: refactor database seed application and enhance workspace manage…
MichaelAndish Jul 4, 2026
dd29ae9
feat: enhance message dispatch and inbox capture functionality
MichaelAndish Jul 4, 2026
4d80edf
refactor: streamline database initialization and seed application pro…
MichaelAndish Jul 4, 2026
9cfeb08
feat: enhance inbox email handling and testing
MichaelAndish Jul 4, 2026
636894f
chore: update deps
MichaelAndish Jul 4, 2026
cc6633b
chore: update CI workflow and refactor server initialization
MichaelAndish Jul 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .agents/skills/golang-pro/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,10 @@ Key properties demonstrated: bounded goroutine lifetime via `ctx`, error propaga
- Handle all errors explicitly (no naked returns)
- Write table-driven tests with subtests
- Document all exported functions, types, and packages
- Use `X | Y` union constraints for generics (Go 1.18+)
- Use `X | Y` union constraints for generics (Go 1.18+)
- Propagate errors with fmt.Errorf("%w", err)
- Run race detector on tests (-race flag)
- Maintain pure domain entities (strict DDD): NEVER use `json:"..."`, `db:"..."` or other presentation/infrastructure tags in `internal/core/domain`. Map in **`internal/presentation/dto/`**: outbound **`{Type}FromDomain(domain.X)`** package funcs; inbound **`(r R) ToDomain()`** / **`ToPatch()`** on request DTOs. **Never pass `dto.*` to repositories** — only `domain.*` crosses the port boundary.

### MUST NOT DO
- Ignore errors (avoid _ assignment without justification)
Expand Down
7 changes: 4 additions & 3 deletions .agents/skills/golang-pro/references/wpd-message-gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Router → Middleware → Handler → Service → Port ← Repository / Provider
| Port | `internal/core/port/` | domain, `pkg/contracts` | implementations |
| Service | `internal/core/service/` | port, domain, `pkg/contracts`, `pkg/registry` | SQL, HTTP, Echo |
| Infrastructure | `internal/infrastructure/` | port, domain | presentation |
| Presentation | `internal/presentation/` | service, port, domain | direct SQL |
| Presentation | `internal/presentation/` | service, port, domain, **dto** | direct SQL; **inline HTTP DTOs in handlers**; **passing dto to port/repo** |
| Public SDK | `pkg/*` | `pkg/*` only | `internal/*` |

Sender interfaces (`EmailSender`, `SMSSender`, …) live in **`pkg/contracts/`**, not `internal/core/port/`.
Expand All @@ -42,7 +42,7 @@ Sender interfaces (`EmailSender`, `SMSSender`, …) live in **`pkg/contracts/`**
2. Register via `init()` in `register.go` → `pkg/registry`
3. Blank-import in **`internal/app/imports.go`** (server mode only)

Do **not** add providers under `internal/infrastructure/provider/` (deprecated).
Add providers only under **`pkg/provider/<name>/`** (registered via `pkg/registry`, blank-imported in `internal/app/imports.go`).

---

Expand All @@ -60,6 +60,7 @@ Do **not** add providers under `internal/infrastructure/provider/` (deprecated).
- Wrap with `fmt.Errorf("…: %w", err)`
- Domain/port sentinels: `port.ErrNotFound`, `port.ErrConflict`, etc.
- Handlers: generic messages on 500; full error in slog only (`send_helper.go` pattern)
- **HTTP DTOs:** live in **`internal/presentation/dto/`** — never in handlers. Outbound: **`dto.XFromDomain(domain.Y)`** package funcs. Inbound: **`(r R) ToDomain()`** (→ `domain.*`) or **`ToPatch()`** (→ `service.*Patch`). **Ports/repos use `domain.*` only** — never import or accept `dto.*` (see `docs/backend/code-conventions.md`).

### Concurrency

Expand Down Expand Up @@ -106,7 +107,7 @@ wpd-message-gateway/
│ ├── app/ # wire, imports, config
│ ├── core/ # domain, port, service
│ ├── infrastructure/ # postgres repos, logger, inbox store
│ └── presentation/ # router, handlers, middleware
│ └── presentation/ # router, handlers, middleware, dto/
├── pkg/
│ ├── contracts/ # message types + sender interfaces
│ ├── gateway/ # embedded SDK
Expand Down
1 change: 1 addition & 0 deletions .agents/skills/software-architecture/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ This skill provides guidance for quality focused software development and archit
- **Clean Architecture & DDD Principles:**
- Follow domain-driven design and ubiquitous language
- Separate domain entities from infrastructure concerns
- **Strict boundary**: Presentation data transfer objects (DTOs) must be completely separate from Domain entities. The Presentation layer is responsible for mapping Domain objects to DTOs. Domain objects must never contain presentation tags (e.g. JSON) and must never be serialized directly.
- Keep business logic independent of frameworks
- Define use cases clearly and keep them isolated
- **Naming Conventions:**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Router → Middleware → Handler → Service → Port ← Repository / Provider
| `MGW.ARCH-PKG-INTERNAL` | `pkg/` imports `internal/` |
| `MGW.ARCH-HANDLER-SQL` | SQL or provider SDK in handlers |
| `MGW.ARCH-SERVICE-HTTP` | Echo/HTTP types in services |
| `MGW.ARCH-PROVIDER-INTERNAL` | New provider under `internal/infrastructure/provider/` |
| `MGW.ARCH-PROVIDER-INTERNAL` | New provider under `internal/` instead of `pkg/provider/<name>/` |
| `MGW.ARCH-FAT-HANDLER` | Handler > ~200 lines or business logic in handler |

**Prefer:** split handlers by portal concern (`portal_auth_handler`, `portal_workspace_handler`, …). Keep handlers thin — decode, call service, map response.
Expand Down
9 changes: 6 additions & 3 deletions .claude/commands/smell.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,11 @@ Router → Middleware → Handler → Service → Port ← Repository / Provider

| Area | Violation |
| ---- | --------- |
| `internal/core/domain/` | Imports Echo, HTTP, infra, or presentation |
| `internal/core/domain/` | Imports Echo, HTTP, infra, presentation, or contains struct tags (`json`, `db`) |
| `internal/core/port/` | Contains implementation instead of interfaces |
| `internal/core/service/` | Direct SQL/HTTP; missing `ctx`; orchestration left in handler |
| `internal/infrastructure/` | Business rules; bypasses port interfaces |
| `internal/presentation/` | Business logic; handler → repository without service; RBAC in handler instead of middleware |
| `internal/infrastructure/` | Business rules; bypasses port interfaces; imports `presentation/dto` |
| `internal/presentation/` | Business logic; handler → repository without service; RBAC in handler instead of middleware; **request/response structs or JSON mappers in `handler/*.go`** |
| `pkg/gateway`, `pkg/contracts` | Imports `internal/*`; embedded SDK requires PostgreSQL |
| Providers | No `init()` registration or missing blank import in `internal/app/imports.go` |
| Auth | Portal mutations without JWT + `RequirePermission`; internal ingest open outside local dev |
Expand Down Expand Up @@ -184,6 +184,8 @@ Do not invent issues. If the diff is clean, say so.
| MGW.MIGRATION | Migration hygiene failure |
| MGW.LOG-SECRET | Secrets or PII in logs |
| MGW.N+1-QUERY | Query inside a loop |
| MGW.HANDLER-DTO | HTTP struct or mapper (`XFromDomain` / `ToDomain` / `ToPatch`) in `handler/*.go` instead of `presentation/dto/` |
| MGW.DTO-IN-REPO | Repository or port interface accepts `presentation/dto.*` instead of `domain.*` |
| MGW.GOROUTINE-LEAK | Goroutine without lifecycle control |

**Frontend**
Expand Down Expand Up @@ -219,6 +221,7 @@ Do not invent issues. If the diff is clean, say so.
| -- | ------- |
| DDD.LAYER | Business rule or orchestration in the wrong layer (handler/UI/infra) |
| DDD.BOUNDARY | Bounded context violated (feature↔feature, `pkg`→`internal`, shared→feature) |
| DDD.DOMAIN-LEAK | Domain object returned directly from handler without DTO mapping, or containing presentation tags (`json`) |
| DDD.VALUE-TYPE | Domain concept as raw string/number — use typed constant in domain + API/FE mirror |

**General — SOLID**
Expand Down
8 changes: 7 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -201,13 +201,19 @@ jobs:
# Wait for server
- name: Wait for server to be ready
run: |
ready=0
for i in {1..30}; do
if curl -s http://localhost:10101/health > /dev/null 2>&1; then
if curl -sf http://localhost:10101/health > /dev/null 2>&1; then
echo "Server ready"
ready=1
break
fi
sleep 1
done
if [ "$ready" -ne 1 ]; then
echo "Server did not become ready within 30s"
exit 1
fi

- name: Install Bruno CLI
run: npm install -g @usebruno/cli
Expand Down
38 changes: 28 additions & 10 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: it install upgrade setup specify spec clarify clr plan pln tasks tsk implement impl analyze alyz checklist chk pr sync agents agents-kill start stop test audit build clean docker-check dev dev-down seed-demo help ui-install ui ui-build ui-format ui-test ui-lint storybook
.PHONY: it install upgrade setup specify spec clarify clr plan pln tasks tsk implement impl analyze alyz checklist chk pr sync agents agents-kill start stop test audit build clean docker-check dev dev-down db-init seed-demo help ui-install ui ui-build ui-format ui-test ui-lint storybook

# ============================================================================
# ANSI Color Codes
Expand Down Expand Up @@ -300,11 +300,19 @@ docker-check:
dev: docker-check
@printf "\n"
@printf "$(BOLD)$(CYAN)🐳 Starting Gateway, database, and Portal UI via Docker...$(RESET)\n"
@docker compose up -d --build
@docker compose up -d --build --renew-anon-volumes
@printf "$(GREEN)✅ All services started!$(RESET)\n"
@printf "\n"
@printf " $(BOLD)Gateway API:$(RESET) http://localhost:10101\n"
@printf " $(BOLD)Portal UI:$(RESET) http://localhost:10104\n"
@printf " ╭───────────────────────────────────────────────────╮\n"
@printf " │ │\n"
@printf " │ $(BOLD)Gateway API:$(RESET) http://localhost:10101 │\n"
@printf " │ $(BOLD)Portal UI:$(RESET) http://localhost:10104 │\n"
@printf " │ │\n"
@printf " │ $(BOLD)Demo Account$(RESET) │\n"
@printf " │ Email: demo@weprodev.com │\n"
@printf " │ Password: secret │\n"
@printf " │ │\n"
@printf " ╰───────────────────────────────────────────────────╯\n"
@printf "\n"

## Stop Docker Compose
Expand All @@ -314,13 +322,22 @@ dev-down: docker-check
@docker compose down
@printf "$(GREEN)✅ Stopped!$(RESET)\n"

## Re-apply SQL seeds to the running Postgres container (demo user, RBAC, providers)
seed-demo: docker-check
## Apply schema when Postgres volume exists but tables are missing (then restart gateway)
db-init: docker-check
@printf "\n"
@printf "$(BOLD)$(CYAN)🌱 Applying database seeds...$(RESET)\n"
@printf "$(BOLD)$(CYAN)🗄️ Initializing database schema...$(RESET)\n"
@docker compose exec -T -e POSTGRES_USER=gateway -e POSTGRES_DB=gateway db \
bash /docker-entrypoint-initdb.d/apply-seeds.sh /docker-entrypoint-initdb.d/seeds
@printf "$(GREEN)✅ Seeds applied (demo@weprodev.com / secret)$(RESET)\n"
bash /docker-entrypoint-initdb.d/init-db.sh
@docker compose restart gateway
@printf "$(GREEN)✅ Database ready — gateway will apply seeds on startup (demo@weprodev.com / secret)$(RESET)\n"
@printf "\n"

## Re-apply SQL seeds by restarting the gateway (seeds run on every startup)
seed-demo: docker-check
@printf "\n"
@printf "$(BOLD)$(CYAN)🌱 Re-applying database seeds (gateway restart)...$(RESET)\n"
@docker compose restart gateway
@printf "$(GREEN)✅ Seeds applied on gateway startup (demo@weprodev.com / secret)$(RESET)\n"
@printf "\n"


Expand Down Expand Up @@ -368,7 +385,8 @@ help:
@printf "$(BOLD)$(GREEN)🐳 Docker$(RESET)\n"
@printf " $(YELLOW)make dev$(RESET) Start Gateway, DB, and UI via Docker (with hot-reloading)\n"
@printf " $(YELLOW)make dev-down$(RESET) Stop Docker containers\n"
@printf " $(YELLOW)make seed-demo$(RESET) Re-apply SQL seeds (demo@weprodev.com / secret)\n"
@printf " $(YELLOW)make db-init$(RESET) Apply schema if DB volume is empty; gateway seeds on start\n"
@printf " $(YELLOW)make seed-demo$(RESET) Re-apply seeds via gateway restart\n"
@printf "\n"

@printf "$(BOLD)$(CYAN)━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━$(RESET)\n"
Expand Down
4 changes: 2 additions & 2 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,12 +108,12 @@ Seeded on first DB init. Reset anytime: `make seed-demo`.
|--|--|
| Portal | `demo@weprodev.com` / `secret` |
| API key | `demo-client-id` / `demo-secret` |
| Workspace key | `demo` |
| Workspace ID | `00000000-0000-0000-0000-000000000001` |

```bash
curl -X POST http://localhost:10101/v1/email \
-u "demo-client-id:demo-secret" \
-H "X-Workspace-Key: demo" \
-H "X-Workspace-Key: 00000000-0000-0000-0000-000000000001" \
-H "Content-Type: application/json" \
-d '{"to":["user@example.com"],"subject":"Hello","html":"<h1>World</h1>"}'
```
Expand Down
33 changes: 33 additions & 0 deletions cmd/server/init.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package main

import (
"fmt"

pkglogger "github.com/weprodev/go-pkg/logger"

"github.com/weprodev/wpd-message-gateway/internal/app"
applogger "github.com/weprodev/wpd-message-gateway/internal/infrastructure/logger"
)

func loadAppConfig(configPath string) (*app.Config, error) {
return app.LoadAppConfig(configPath)
}

func validateAppConfig(cfg *app.Config) error {
if err := app.ValidateConfig(cfg); err != nil {
return fmt.Errorf("configuration error: %w (hint: copy configs/local.example.yml to configs/local.yml)", err)
}
return nil
}

func setLogger(cfg *app.Config) (*pkglogger.Logger, error) {
return applogger.New(cfg.EnvironmentName())
}

func wireApplication(cfg *app.Config) (*app.Application, error) {
sysLogger, err := setLogger(cfg)
if err != nil {
return nil, fmt.Errorf("init logger: %w", err)
}
return app.Wire(cfg, sysLogger)
}
89 changes: 42 additions & 47 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
@@ -1,74 +1,69 @@
package main

import (
"context"
"flag"
"fmt"
"log/slog"
"net/http"
"os"

"github.com/weprodev/wpd-message-gateway/internal/app"
"github.com/weprodev/wpd-message-gateway/internal/infrastructure/logger"
"os/signal"
"syscall"
"time"
)

func main() {
// Initialise the structured logger first so every subsequent slog call is
// formatted and levelled correctly. Uses APP_ENVIRONMENT to pick text (local)
// vs JSON (production) format.
env := os.Getenv("APP_ENVIRONMENT")
sysLogger, err := logger.New(env)
if err != nil {
// Logger not yet up — fall back to bare stderr before exiting.
fmt.Fprintf(os.Stderr, "FATAL: init logger: %v\n", err)
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "fatal error: %v\n", err)
os.Exit(1)
}
}

func run() error {
configPath := flag.String("config", "", "Path to config file (default: CONFIG_PATH env or configs/local.yml)")
flag.Parse()

configPath := os.Getenv("CONFIG_PATH")
cfg, err := app.LoadConfig(configPath)
cfg, err := loadAppConfig(*configPath)
if err != nil {
slog.Error("failed to load config", "error", err)
os.Exit(1)
return fmt.Errorf("load config: %w", err)
}

if err := app.ValidateConfig(cfg); err != nil {
slog.Error("configuration error",
"error", err,
"hint", "each message type requires a default provider (e.g. memory, mailgun); "+
"copy configs/local.example.yml to configs/local.yml and configure providers",
)
os.Exit(1)
if err := validateAppConfig(cfg); err != nil {
return err
}

application, err := app.Wire(cfg, sysLogger)
application, err := wireApplication(cfg)
if err != nil {
slog.Error("failed to initialise application", "error", err)
os.Exit(1)
return fmt.Errorf("wire application: %w", err)
}

logConfiguration(cfg)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

port := resolvePort(cfg)
slog.Info("gateway server starting", "port", port, "environment", cfg.Environment)
cfg.LogSummary()
slog.Info("gateway server starting")

if err := application.Echo.Start(":" + port); err != nil {
slog.Error("server stopped", "error", err)
os.Exit(1)
errCh := make(chan error, 1)
go func() {
if err := application.Echo.Start(cfg.ListenAddr()); err != nil && err != http.ErrServerClosed {
errCh <- err
}
}()

select {
case err := <-errCh:
return fmt.Errorf("server stopped unexpectedly: %w", err)
case <-ctx.Done():
slog.Info("shutdown signal received")
}
}

func logConfiguration(cfg *app.Config) {
slog.Info("loaded configuration",
"email_provider", cfg.DefaultEmailProvider(),
"sms_provider", cfg.DefaultSMSProvider(),
"push_provider", cfg.DefaultPushProvider(),
"chat_provider", cfg.DefaultChatProvider(),
)
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

func resolvePort(cfg *app.Config) string {
if port := os.Getenv("PORT"); port != "" {
return port
}
if cfg.Server.Port != 0 {
return fmt.Sprintf("%d", cfg.Server.Port)
if err := application.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("graceful shutdown: %w", err)
}
return "10101"

slog.Info("server stopped")
return nil
}
16 changes: 0 additions & 16 deletions database/apply-seeds.sh

This file was deleted.

14 changes: 9 additions & 5 deletions database/init-db.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@ set -e

echo "=== Running Database Init Script ==="

TABLE_EXISTS=$(psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -tAc \
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'users');")

if [ "$TABLE_EXISTS" = "t" ]; then
echo "Schema already present — skipping migration."
exit 0
fi

echo "Applying migrations..."
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \
-f /docker-entrypoint-initdb.d/migrations/20260318000000_init_gateway.up.sql

echo "Applying seeds..."
export POSTGRES_USER POSTGRES_DB
bash /docker-entrypoint-initdb.d/apply-seeds.sh /docker-entrypoint-initdb.d/seeds

echo "=== Database Init Script Completed ==="
echo "=== Database Init Script Completed (seeds run on gateway startup) ==="
1 change: 1 addition & 0 deletions database/migrations/20260318000000_init_gateway.down.sql
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ DROP TABLE IF EXISTS workspace_access_audits CASCADE;
DROP TABLE IF EXISTS workspace_settings CASCADE;
DROP TABLE IF EXISTS template_components CASCADE;
DROP TABLE IF EXISTS templates CASCADE;
DROP TABLE IF EXISTS message_request_payloads CASCADE;
DROP TABLE IF EXISTS message_request_logs CASCADE;
DROP TABLE IF EXISTS api_keys CASCADE;
DROP TABLE IF EXISTS integrations CASCADE;
Expand Down
Loading
Loading