diff --git a/docker-compose.yml b/docker-compose.yml index 09df9d1..579d24d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -197,6 +197,10 @@ services: - SMTP_SECURE=${SMTP_SECURE:-false} - SMTP_USER=${SMTP_USER:-} - SMTP_PASS=${SMTP_PASS:-} + - KAFKA_BROKERS=kafka:29092 + depends_on: + kafka: + condition: service_healthy networks: - auron-network restart: unless-stopped diff --git a/services/notification-service/.env.example b/services/notification-service/.env.example new file mode 100644 index 0000000..f504d6e --- /dev/null +++ b/services/notification-service/.env.example @@ -0,0 +1,9 @@ +# Notification Service +PORT=8086 +SMTP_HOST=localhost +SMTP_PORT=1025 +SMTP_FROM=noreply@auron.shop +SMTP_USER= +SMTP_PASS= +SMTP_SECURE=false +KAFKA_BROKERS=localhost:9092 diff --git a/services/notification-service/Dockerfile b/services/notification-service/Dockerfile new file mode 100644 index 0000000..fcadd3d --- /dev/null +++ b/services/notification-service/Dockerfile @@ -0,0 +1,23 @@ +FROM golang:1.25-alpine AS builder + +WORKDIR /app + +RUN apk add --no-cache git + +COPY go.mod go.sum ./ + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -o /notification-service . + +FROM alpine:3.18 + +RUN apk add --no-cache ca-certificates curl + +WORKDIR /app + +COPY --from=builder /notification-service . + +EXPOSE 8086 + +CMD ["./notification-service"] diff --git a/services/notification-service/IMPLEMENTATION_PLAN.md b/services/notification-service/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..86f6b5c --- /dev/null +++ b/services/notification-service/IMPLEMENTATION_PLAN.md @@ -0,0 +1,317 @@ +# Notification Service — Implementation Plan + +## Overview + +The notification-service delivers transactional emails to users triggered by domain events +published on Kafka by other services. It consumes events, fetches any additional context it +needs, renders email content, and sends via SMTP (using Go's `net/smtp` standard library). + +No database is required — all state comes from incoming events. +No REST API endpoints — the service is entirely event-driven. +The `/health` endpoint (required by docker-compose healthcheck) is the only HTTP surface. + +**Port:** `8086` +**Kafka consumer group:** `notification-service` + +--- + +## Topics consumed and emails sent + +| Kafka Topic | Email sent | +|----------------------|--------------------------------------------------| +| `user.created` | Welcome email to new user | +| `order.created` | Order confirmation with item summary | +| `order.cancelled` | Order cancellation notice | +| `payment.completed` | Payment receipt / success confirmation | +| `payment.failed` | Payment failure notice with reason | +| `inventory.low_stock`| (internal) low-stock alert — no user email | + +> `inventory.low_stock` is consumed but emails are suppressed for now (logged only). +> `payment.created` is skipped — that event carries Stripe client_secret; not relevant here. + +--- + +## Directory structure + +``` +services/notification-service/ +├── IMPLEMENTATION_PLAN.md +├── main.go +├── Dockerfile +├── .env.example +├── go.mod / go.sum +├── cmd/ +│ ├── config.go # env → Config struct +│ ├── dotenv.go # .env loader (dev only) +│ ├── kafka.go # setupKafkaConsumer, closeKafkaConsumer +│ ├── run.go # Run(), registerGracefulShutdown +│ └── server.go # setupRouter (health only) +└── internal/ + ├── domain/ + │ ├── events.go # consumed topic constants + event structs + │ └── service.go # NotificationService interface + ├── email/ + │ └── smtp_sender.go # EmailSender interface + smtpSender impl + ├── events/ + │ └── kafka_consumer.go # multi-topic KafkaConsumer + ├── handler/ + │ └── health_handler.go # GET /health + ├── route/ + │ └── route.go # RegisterRoutes + └── service/ + └── notification_service.go # NotificationService impl +``` + +--- + +## Tasks + +### Task 1 — Domain: event structs + service interface + +**File:** `internal/domain/events.go` + +Topic constants for all consumed events: +``` +TopicUserCreated = "user.created" +TopicOrderCreated = "order.created" +TopicOrderCancelled = "order.cancelled" +TopicPaymentCompleted = "payment.completed" +TopicPaymentFailed = "payment.failed" +TopicInventoryLowStock = "inventory.low_stock" +``` + +Event structs (match producing service JSON tags exactly): + +- `UserCreatedEvent` — `id`, `email`, `name` +- `OrderCreatedEvent` — `id` (order ID), `user_id`, `total_amount`, `items[]` (`product_id`, `quantity`, `price`) +- `OrderCancelledEvent`— same shape as `OrderCreatedEvent` +- `PaymentEvent` — `id`, `order_id`, `user_id`, `amount`, `currency`, `status`, `failure_reason` +- `InventoryLowStockEvent` — `product_id`, `total_quantity`, `reserved_quantity` + +**File:** `internal/domain/service.go` + +```go +type NotificationService interface { + HandleUserCreated(ctx context.Context, event UserCreatedEvent) error + HandleOrderCreated(ctx context.Context, event OrderCreatedEvent) error + HandleOrderCancelled(ctx context.Context, event OrderCancelledEvent) error + HandlePaymentCompleted(ctx context.Context, event PaymentEvent) error + HandlePaymentFailed(ctx context.Context, event PaymentEvent) error +} +``` + +--- + +### Task 2 — Email sender: SMTP client + +**File:** `internal/email/smtp_sender.go` + +Interface: +```go +type EmailSender interface { + Send(to, subject, body string) error +} +``` + +Implementation `smtpSender`: +- Config: `host`, `port`, `from`, `user`, `pass`, `secure bool` +- Uses `net/smtp` standard library +- If `user == ""` — use `smtp.SendMail` without auth (relay/MailHog mode for dev) +- Otherwise — `smtp.PlainAuth` + `smtp.SendMail` +- Body format: plain-text `Content-Type: text/plain; charset=UTF-8` (no HTML templates for now) +- Helper `buildMessage(from, to, subject, body string) []byte` — formats RFC 2822 headers + +Constructor: `NewSMTPSender(host string, port int, from, user, pass string, secure bool) EmailSender` + +--- + +### Task 3 — Service layer: notification logic + +**File:** `internal/service/notification_service.go` + +`notificationService` struct — fields: `sender email.EmailSender` + +Each handler method: +1. Composes subject + plain-text body using Go string formatting +2. Calls `sender.Send(to, subject, body)` +3. Returns any error + +Email content per event: + +**HandleUserCreated** — to: `event.Email` +``` +Subject: Welcome to Auron, {{Name}}! +Body: Hi {{Name}}, your account has been created successfully. Start shopping at Auron! +``` + +**HandleOrderCreated** — to: derived from `user_id`; problem: no user email in event. +Resolution: embed the user email in the `OrderCreatedEvent` from the order-service side. +For now, log a warning and skip (order-service does not include email — it would require +a cross-service call). Store `user_id` in log; send to a no-op target. +**Alternative (chosen):** order-service includes `user_email` in the event. +Check if order-service event has `user_email`; if not, we skip sending and log. + +``` +Subject: Order Confirmed — #{{OrderID}} +Body: Your order {{OrderID}} for ${{TotalAmount}} has been placed successfully. + Items: (list each product_id × quantity) +``` + +**HandleOrderCancelled** — same resolution as above +``` +Subject: Order Cancelled — #{{OrderID}} +Body: Your order {{OrderID}} has been cancelled. +``` + +**HandlePaymentCompleted** +``` +Subject: Payment Received — ${{Amount}} {{Currency}} +Body: Your payment of ${{Amount}} {{Currency}} for order {{OrderID}} was successful. + Payment ID: {{ID}} +``` + +**HandlePaymentFailed** +``` +Subject: Payment Failed for Order #{{OrderID}} +Body: Your payment of ${{Amount}} {{Currency}} for order {{OrderID}} failed. + Reason: {{FailureReason}} + Please retry or contact support. +``` + +> `user_id` from payment events is a UUID — no email available without a user lookup. +> Payment events from the payment-service include `user_id` (UUID) but NOT email. +> For the initial implementation: log a warning, skip sending. +> A follow-up can add a user-service HTTP call to resolve email by user_id. + +--- + +### Task 4 — Kafka consumer: multi-topic consumer + +**File:** `internal/events/kafka_consumer.go` + +Same pattern as inventory-service: `[]readerEntry` with one `kafka.Reader` per topic. + +Topics: `user.created`, `order.created`, `order.cancelled`, `payment.completed`, `payment.failed`, `inventory.low_stock` + +Consumer group: `notification-service` + +`handleMessage(topic, value []byte)`: +- Switch on topic +- Unmarshal into correct event struct +- Call appropriate `NotificationService` method +- Log errors, do NOT retry (offset always committed) + +`Start(ctx context.Context)` — one goroutine per reader +`Close() error` — close all readers + +--- + +### Task 5 — Health handler + route + +**File:** `internal/handler/health_handler.go` + +```go +func (h *HealthHandler) GetHealth(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok", "service": "notification-service"}) +} +``` + +**File:** `internal/route/route.go` + +```go +func RegisterRoutes(router *gin.Engine, healthHandler *handler.HealthHandler) { + router.GET("/health", healthHandler.GetHealth) +} +``` + +--- + +### Task 6 — cmd bootstrap: config, infrastructure, kafka, server, run + +**File:** `cmd/config.go` + +```go +type Config struct { + Port string + SMTPHost string + SMTPPort int + SMTPFrom string + SMTPUser string + SMTPPass string + SMTPSecure bool + KafkaBrokers []string +} +``` +`loadConfig()` reads from env; `KAFKA_BROKERS` splits on `,`. + +**File:** `cmd/dotenv.go` — same `.env` loader pattern as other services + +**File:** `cmd/kafka.go` + +```go +func setupKafkaConsumer(brokers []string, svc domain.NotificationService) *events.KafkaConsumer +func startKafkaConsumer(ctx context.Context, consumer *events.KafkaConsumer) +func closeKafkaConsumer(consumer *events.KafkaConsumer) +``` + +**File:** `cmd/server.go` — `setupRouter` (health only, no auth middleware) + +**File:** `cmd/run.go` — `Run()` wires everything + `registerGracefulShutdown` + +--- + +### Task 7 — Entry point, Dockerfile, .env.example, docker-compose + +**File:** `main.go` +```go +package main +import "auron/notification-service/cmd" +func main() { cmd.Run() } +``` + +**File:** `Dockerfile` +- `golang:1.25-alpine` builder → `alpine:3.18` runtime +- Binary: `/notification-service` +- EXPOSE 8086 + +**File:** `.env.example` +``` +PORT=8086 +SMTP_HOST=localhost +SMTP_PORT=1025 +SMTP_FROM=noreply@auron.shop +SMTP_USER= +SMTP_PASS= +SMTP_SECURE=false +KAFKA_BROKERS=localhost:9092 +``` + +**docker-compose.yml** — update `notification-service` stanza: +- Add `KAFKA_BROKERS=kafka:29092` +- Add `depends_on: kafka: condition: service_healthy` + +--- + +## Dependencies (go.mod) + +``` +github.com/gin-gonic/gin +github.com/google/uuid +github.com/segmentio/kafka-go +github.com/joho/godotenv +``` + +No GORM, no Redis — this service has no database or cache. + +--- + +## Design decisions + +| Decision | Choice | Reason | +|----------|--------|--------| +| No database | Stateless | Emails are fire-and-forget; no state to persist | +| `net/smtp` not a library | Standard library | No extra dep; plain-text emails are sufficient | +| Dev mode (no SMTP auth) | `SMTP_USER=""` → no auth | Works with MailHog out of the box | +| Email for order/payment | Skip if no email in event | Cross-service user lookup adds coupling; deferrable | +| No retry on Kafka error | Log + commit | Idempotency not guaranteed; prevents consumer stall | +| Multi-reader consumer | One reader per topic | Same pattern as inventory-service; clean shutdown | diff --git a/services/notification-service/cmd/config.go b/services/notification-service/cmd/config.go new file mode 100644 index 0000000..2ab1908 --- /dev/null +++ b/services/notification-service/cmd/config.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "os" + "strconv" +) + +type appConfig struct { + Port string + SMTPHost string + SMTPPort int + SMTPFrom string + SMTPUser string + SMTPPass string + SMTPSecure bool + KafkaBrokers string +} + +func loadConfig() appConfig { + loadDotEnvFile(".env") + + port := os.Getenv("PORT") + if port == "" { + port = "8086" + } + + smtpHost := os.Getenv("SMTP_HOST") + if smtpHost == "" { + smtpHost = "localhost" + } + + smtpPort := 1025 + if v := os.Getenv("SMTP_PORT"); v != "" { + if p, err := strconv.Atoi(v); err == nil { + smtpPort = p + } + } + + smtpFrom := os.Getenv("SMTP_FROM") + if smtpFrom == "" { + smtpFrom = "noreply@auron.shop" + } + + smtpSecure := os.Getenv("SMTP_SECURE") == "true" + + kafkaBrokers := os.Getenv("KAFKA_BROKERS") + if kafkaBrokers == "" { + kafkaBrokers = "localhost:9092" + } + + return appConfig{ + Port: port, + SMTPHost: smtpHost, + SMTPPort: smtpPort, + SMTPFrom: smtpFrom, + SMTPUser: os.Getenv("SMTP_USER"), + SMTPPass: os.Getenv("SMTP_PASS"), + SMTPSecure: smtpSecure, + KafkaBrokers: kafkaBrokers, + } +} diff --git a/services/notification-service/cmd/dotenv.go b/services/notification-service/cmd/dotenv.go new file mode 100644 index 0000000..3c652db --- /dev/null +++ b/services/notification-service/cmd/dotenv.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "bufio" + "os" + "strings" +) + +func loadDotEnvFile(path string) { + file, err := os.Open(path) + if err != nil { + return + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + value = strings.Trim(value, `"'`) + + if key == "" { + continue + } + + if _, exists := os.LookupEnv(key); exists { + continue + } + + _ = os.Setenv(key, value) + } +} diff --git a/services/notification-service/cmd/kafka.go b/services/notification-service/cmd/kafka.go new file mode 100644 index 0000000..8af5e47 --- /dev/null +++ b/services/notification-service/cmd/kafka.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "context" + "log/slog" + "strings" + + "auron/notification-service/internal/domain" + "auron/notification-service/internal/events" +) + +func setupKafkaConsumer(kafkaBrokers string, svc domain.NotificationService) *events.KafkaConsumer { + brokers := parseBrokers(kafkaBrokers) + return events.NewKafkaConsumer(brokers, svc) +} + +func startKafkaConsumer(ctx context.Context, consumer *events.KafkaConsumer) { + consumer.Start(ctx) + slog.Info("kafka consumer started", + "topics", []string{ + domain.TopicUserCreated, + domain.TopicOrderCreated, + domain.TopicOrderCancelled, + domain.TopicPaymentCompleted, + domain.TopicPaymentFailed, + domain.TopicInventoryLowStock, + }, + "group", "notification-service", + ) +} + +func closeKafkaConsumer(consumer *events.KafkaConsumer) { + if err := consumer.Close(); err != nil { + slog.Warn("failed to close kafka consumer", "error", err) + } +} + +func parseBrokers(kafkaBrokers string) []string { + parts := strings.Split(kafkaBrokers, ",") + brokers := make([]string, 0, len(parts)) + for _, b := range parts { + if trimmed := strings.TrimSpace(b); trimmed != "" { + brokers = append(brokers, trimmed) + } + } + if len(brokers) == 0 { + return []string{"localhost:9092"} + } + return brokers +} diff --git a/services/notification-service/cmd/run.go b/services/notification-service/cmd/run.go new file mode 100644 index 0000000..a06b6bf --- /dev/null +++ b/services/notification-service/cmd/run.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "context" + "fmt" + "log" + "os" + "os/signal" + "syscall" + + "auron/notification-service/internal/email" + "auron/notification-service/internal/events" + "auron/notification-service/internal/handler" + "auron/notification-service/internal/service" +) + +func Run() { + cfg := loadConfig() + + sender := email.NewSMTPSender(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPFrom, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPSecure) + + notificationSvc := service.NewNotificationService(sender) + + ctx, cancel := context.WithCancel(context.Background()) + consumer := setupKafkaConsumer(cfg.KafkaBrokers, notificationSvc) + startKafkaConsumer(ctx, consumer) + + healthHandler := handler.NewHealthHandler() + router := setupRouter(healthHandler) + + registerGracefulShutdown(consumer, cancel) + + addr := fmt.Sprintf(":%s", cfg.Port) + log.Printf("starting notification-service on %s", addr) + if err := router.Run(addr); err != nil { + log.Fatalf("failed to start server: %v", err) + } +} + +func registerGracefulShutdown(consumer *events.KafkaConsumer, cancel context.CancelFunc) { + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + + go func() { + <-quit + fmt.Println("\nshutting down notification-service...") + cancel() + closeKafkaConsumer(consumer) + os.Exit(0) + }() +} diff --git a/services/notification-service/cmd/server.go b/services/notification-service/cmd/server.go new file mode 100644 index 0000000..b528246 --- /dev/null +++ b/services/notification-service/cmd/server.go @@ -0,0 +1,19 @@ +package cmd + +import ( + "auron/notification-service/internal/handler" + "auron/notification-service/internal/route" + + "github.com/gin-gonic/gin" +) + +func setupRouter(healthHandler *handler.HealthHandler) *gin.Engine { + gin.SetMode(gin.ReleaseMode) + router := gin.New() + router.Use(gin.Logger()) + router.Use(gin.Recovery()) + + route.RegisterRoutes(router, healthHandler) + + return router +} diff --git a/services/notification-service/go.mod b/services/notification-service/go.mod new file mode 100644 index 0000000..4c2207e --- /dev/null +++ b/services/notification-service/go.mod @@ -0,0 +1,43 @@ +module auron/notification-service + +go 1.25.8 + +require ( + github.com/gin-gonic/gin v1.12.0 + github.com/google/uuid v1.6.0 + github.com/segmentio/kafka-go v0.4.47 +) + +require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.6 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pierrec/lz4/v4 v4.1.15 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) diff --git a/services/notification-service/go.sum b/services/notification-service/go.sum new file mode 100644 index 0000000..2f5dd17 --- /dev/null +++ b/services/notification-service/go.sum @@ -0,0 +1,145 @@ +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= +github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI= +github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +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/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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0= +github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/segmentio/kafka-go v0.4.47 h1:IqziR4pA3vrZq7YdRxaT3w1/5fvIH5qpCwstUanQQB0= +github.com/segmentio/kafka-go v0.4.47/go.mod h1:HjF6XbOKh0Pjlkr5GVZxt6CsjjwnmhVOfURM5KMd8qg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/services/notification-service/internal/domain/events.go b/services/notification-service/internal/domain/events.go new file mode 100644 index 0000000..00b7ca7 --- /dev/null +++ b/services/notification-service/internal/domain/events.go @@ -0,0 +1,64 @@ +package domain + +import ( + "time" + + "github.com/google/uuid" +) + +const ( + TopicUserCreated = "user.created" + TopicOrderCreated = "order.created" + TopicOrderCancelled = "order.cancelled" + TopicPaymentCompleted = "payment.completed" + TopicPaymentFailed = "payment.failed" + TopicInventoryLowStock = "inventory.low_stock" +) + +// UserCreatedEvent matches the User struct published by user-service (json tags on domain.User). +type UserCreatedEvent struct { + ID uuid.UUID `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Role string `json:"role"` +} + +// OrderEvent matches the Order struct published by order-service. +// Both order.created and order.cancelled use the same payload shape. +type OrderEvent struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + Status string `json:"status"` + TotalAmount float64 `json:"total_amount"` + ShippingName string `json:"shipping_name"` + ShippingAddress string `json:"shipping_address"` + Items []OrderEventItem `json:"items"` + CreatedAt time.Time `json:"created_at"` +} + +type OrderEventItem struct { + ProductID uuid.UUID `json:"product_id"` + ProductName string `json:"product_name"` + Price float64 `json:"price"` + Quantity int `json:"quantity"` + Subtotal float64 `json:"subtotal"` +} + +// PaymentEvent matches the PaymentResponse struct published by payment-service. +type PaymentEvent struct { + ID uuid.UUID `json:"id"` + OrderID uuid.UUID `json:"order_id"` + UserID uuid.UUID `json:"user_id"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` + Status string `json:"status"` + FailureReason string `json:"failure_reason,omitempty"` +} + +// InventoryLowStockEvent matches the InventoryResponse struct published by inventory-service. +type InventoryLowStockEvent struct { + ProductID uuid.UUID `json:"product_id"` + TotalQuantity int `json:"total_quantity"` + ReservedQuantity int `json:"reserved_quantity"` + AvailableQuantity int `json:"available_quantity"` +} diff --git a/services/notification-service/internal/domain/service.go b/services/notification-service/internal/domain/service.go new file mode 100644 index 0000000..a9fb97c --- /dev/null +++ b/services/notification-service/internal/domain/service.go @@ -0,0 +1,11 @@ +package domain + +import "context" + +type NotificationService interface { + HandleUserCreated(ctx context.Context, event UserCreatedEvent) error + HandleOrderCreated(ctx context.Context, event OrderEvent) error + HandleOrderCancelled(ctx context.Context, event OrderEvent) error + HandlePaymentCompleted(ctx context.Context, event PaymentEvent) error + HandlePaymentFailed(ctx context.Context, event PaymentEvent) error +} diff --git a/services/notification-service/internal/email/smtp_sender.go b/services/notification-service/internal/email/smtp_sender.go new file mode 100644 index 0000000..1b42ed2 --- /dev/null +++ b/services/notification-service/internal/email/smtp_sender.go @@ -0,0 +1,49 @@ +package email + +import ( + "fmt" + "net/smtp" +) + +type EmailSender interface { + Send(to, subject, body string) error +} + +type smtpSender struct { + host string + port int + from string + user string + pass string + secure bool +} + +func NewSMTPSender(host string, port int, from, user, pass string, secure bool) EmailSender { + return &smtpSender{ + host: host, + port: port, + from: from, + user: user, + pass: pass, + secure: secure, + } +} + +func (s *smtpSender) Send(to, subject, body string) error { + addr := fmt.Sprintf("%s:%d", s.host, s.port) + msg := buildMessage(s.from, to, subject, body) + + if s.user == "" { + return smtp.SendMail(addr, nil, s.from, []string{to}, msg) + } + + auth := smtp.PlainAuth("", s.user, s.pass, s.host) + return smtp.SendMail(addr, auth, s.from, []string{to}, msg) +} + +func buildMessage(from, to, subject, body string) []byte { + return []byte(fmt.Sprintf( + "From: %s\r\nTo: %s\r\nSubject: %s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s", + from, to, subject, body, + )) +} diff --git a/services/notification-service/internal/events/kafka_consumer.go b/services/notification-service/internal/events/kafka_consumer.go new file mode 100644 index 0000000..a77e2fb --- /dev/null +++ b/services/notification-service/internal/events/kafka_consumer.go @@ -0,0 +1,134 @@ +package events + +import ( + "context" + "encoding/json" + "log/slog" + + "auron/notification-service/internal/domain" + + "github.com/segmentio/kafka-go" +) + +type readerEntry struct { + reader *kafka.Reader + topic string +} + +type KafkaConsumer struct { + readers []readerEntry + service domain.NotificationService +} + +func NewKafkaConsumer(brokers []string, svc domain.NotificationService) *KafkaConsumer { + topics := []string{ + domain.TopicUserCreated, + domain.TopicOrderCreated, + domain.TopicOrderCancelled, + domain.TopicPaymentCompleted, + domain.TopicPaymentFailed, + domain.TopicInventoryLowStock, + } + + readers := make([]readerEntry, 0, len(topics)) + for _, topic := range topics { + r := kafka.NewReader(kafka.ReaderConfig{ + Brokers: brokers, + Topic: topic, + GroupID: "notification-service", + MinBytes: 1, + MaxBytes: 10e6, + }) + readers = append(readers, readerEntry{reader: r, topic: topic}) + } + + return &KafkaConsumer{readers: readers, service: svc} +} + +func (c *KafkaConsumer) Start(ctx context.Context) { + for _, entry := range c.readers { + go c.consumeTopic(ctx, entry) + } +} + +func (c *KafkaConsumer) Close() error { + var lastErr error + for _, entry := range c.readers { + if err := entry.reader.Close(); err != nil { + lastErr = err + } + } + return lastErr +} + +func (c *KafkaConsumer) consumeTopic(ctx context.Context, entry readerEntry) { + for { + msg, err := entry.reader.ReadMessage(ctx) + if err != nil { + if ctx.Err() != nil { + return + } + slog.Error("kafka read error", "topic", entry.topic, "error", err) + continue + } + c.handleMessage(ctx, entry.topic, msg.Value) + } +} + +func (c *KafkaConsumer) handleMessage(ctx context.Context, topic string, value []byte) { + var err error + switch topic { + case domain.TopicUserCreated: + var event domain.UserCreatedEvent + if err = json.Unmarshal(value, &event); err != nil { + break + } + err = c.service.HandleUserCreated(ctx, event) + + case domain.TopicOrderCreated: + var event domain.OrderEvent + if err = json.Unmarshal(value, &event); err != nil { + break + } + err = c.service.HandleOrderCreated(ctx, event) + + case domain.TopicOrderCancelled: + var event domain.OrderEvent + if err = json.Unmarshal(value, &event); err != nil { + break + } + err = c.service.HandleOrderCancelled(ctx, event) + + case domain.TopicPaymentCompleted: + var event domain.PaymentEvent + if err = json.Unmarshal(value, &event); err != nil { + break + } + err = c.service.HandlePaymentCompleted(ctx, event) + + case domain.TopicPaymentFailed: + var event domain.PaymentEvent + if err = json.Unmarshal(value, &event); err != nil { + break + } + err = c.service.HandlePaymentFailed(ctx, event) + + case domain.TopicInventoryLowStock: + var event domain.InventoryLowStockEvent + if err = json.Unmarshal(value, &event); err != nil { + break + } + slog.Warn("inventory low stock", + "product_id", event.ProductID, + "available", event.AvailableQuantity, + "total", event.TotalQuantity, + ) + + default: + slog.Warn("kafka consumer received unknown topic", "topic", topic) + } + + if err != nil { + slog.Error("failed to handle kafka message", "topic", topic, "error", err) + } +} diff --git a/services/notification-service/internal/handler/health_handler.go b/services/notification-service/internal/handler/health_handler.go new file mode 100644 index 0000000..da18fd5 --- /dev/null +++ b/services/notification-service/internal/handler/health_handler.go @@ -0,0 +1,21 @@ +package handler + +import ( + "time" + + "github.com/gin-gonic/gin" +) + +type HealthHandler struct{} + +func NewHealthHandler() *HealthHandler { + return &HealthHandler{} +} + +func (h *HealthHandler) GetHealth(c *gin.Context) { + c.JSON(200, gin.H{ + "status": "healthy", + "service": "notification-service", + "timestamp": time.Now().UTC(), + }) +} diff --git a/services/notification-service/internal/route/route.go b/services/notification-service/internal/route/route.go new file mode 100644 index 0000000..63e48cd --- /dev/null +++ b/services/notification-service/internal/route/route.go @@ -0,0 +1,11 @@ +package route + +import ( + "auron/notification-service/internal/handler" + + "github.com/gin-gonic/gin" +) + +func RegisterRoutes(router *gin.Engine, healthHandler *handler.HealthHandler) { + router.GET("/health", healthHandler.GetHealth) +} diff --git a/services/notification-service/internal/service/notification_service.go b/services/notification-service/internal/service/notification_service.go new file mode 100644 index 0000000..ab704ed --- /dev/null +++ b/services/notification-service/internal/service/notification_service.go @@ -0,0 +1,70 @@ +package service + +import ( + "context" + "fmt" + "log/slog" + "strings" + + "auron/notification-service/internal/domain" + "auron/notification-service/internal/email" +) + +type notificationService struct { + sender email.EmailSender +} + +func NewNotificationService(sender email.EmailSender) domain.NotificationService { + return ¬ificationService{sender: sender} +} + +func (s *notificationService) HandleUserCreated(_ context.Context, event domain.UserCreatedEvent) error { + subject := fmt.Sprintf("Welcome to Auron, %s!", event.Name) + body := fmt.Sprintf( + "Hi %s,\n\nYour account has been created successfully.\nStart shopping at Auron!\n\nWelcome aboard,\nThe Auron Team", + event.Name, + ) + return s.sender.Send(event.Email, subject, body) +} + +func (s *notificationService) HandleOrderCreated(_ context.Context, event domain.OrderEvent) error { + // order.created payload is the raw Order struct — no user email included. + // Log and skip; a follow-up can add a user-service lookup to resolve email. + slog.Info("order.created received — no user email in event, skipping notification", + "order_id", event.ID, + "user_id", event.UserID, + "total_amount", event.TotalAmount, + ) + return nil +} + +func (s *notificationService) HandleOrderCancelled(_ context.Context, event domain.OrderEvent) error { + // Same constraint as HandleOrderCreated — no user email in the event payload. + slog.Info("order.cancelled received — no user email in event, skipping notification", + "order_id", event.ID, + "user_id", event.UserID, + ) + return nil +} + +func (s *notificationService) HandlePaymentCompleted(_ context.Context, event domain.PaymentEvent) error { + // payment.completed carries user_id (UUID) but not the user's email. + // Log for now; a follow-up can resolve email via user-service HTTP call. + slog.Info("payment.completed received — no user email in event, skipping notification", + "payment_id", event.ID, + "order_id", event.OrderID, + "user_id", event.UserID, + "amount", fmt.Sprintf("%.2f %s", event.Amount, strings.ToUpper(event.Currency)), + ) + return nil +} + +func (s *notificationService) HandlePaymentFailed(_ context.Context, event domain.PaymentEvent) error { + slog.Info("payment.failed received — no user email in event, skipping notification", + "payment_id", event.ID, + "order_id", event.OrderID, + "user_id", event.UserID, + "reason", event.FailureReason, + ) + return nil +} diff --git a/services/notification-service/main.go b/services/notification-service/main.go new file mode 100644 index 0000000..1968cf4 --- /dev/null +++ b/services/notification-service/main.go @@ -0,0 +1,7 @@ +package main + +import "auron/notification-service/cmd" + +func main() { + cmd.Run() +}