diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bfcddef --- /dev/null +++ b/.dockerignore @@ -0,0 +1,39 @@ +# Git +.git +.gitignore + +# IDE +.idea +.vscode +*.swp +*.swo + +# Build artifacts +bin/ +*.o +*.a +*.so + +# Test files +*_test.go +testdata/ + +# Documentation +*.md +docs/ + +# CI/CD +.github/ +.gitlab-ci.yml + +# Kubernetes +k8s/ +*.yaml +!config.yaml + +# Development +.env +.env.local + +# macOS +.DS_Store diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..ede7042 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/notifier.iml b/.idea/notifier.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/notifier.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..6c35dc2 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,320 @@ +# Notifier Microservice Architecture + +## Overview + +The Notifier microservice is designed to provide a flexible, scalable notification delivery system that supports multiple notification channels (SMTP, Slack, Ntfy, Stdout) with both REST and gRPC APIs. + +## Architecture Principles + +- **Clean Architecture**: Domain-driven design with clear separation of concerns +- **Interface-based Design**: All core components use interfaces for maximum flexibility +- **Pluggable Notifiers**: Easy to add new notification providers +- **Queue Abstraction**: Supports both local and distributed queues (Kafka) +- **Configuration-driven**: Viper-based configuration with environment variable support +- **Cloud-native**: Containerized with Kubernetes support + +## Core Components + +### 1. Domain Layer (`internal/domain/`) + +The domain layer defines the core business logic and interfaces: + +#### `notification.go` +- **Notification**: Core notification entity with metadata, status tracking, and retry logic +- **Priority**: Enumeration for notification urgency (Low, Normal, High, Critical) +- **NotificationType**: Supported notification channels (Email, Slack, Ntfy, Stdout) +- **NotificationStatus**: Lifecycle states (Pending, Queued, Processing, Sent, Failed, Retrying) +- **NotificationResult**: Outcome of notification delivery attempts +- **NotificationFilter**: Query interface for retrieving notifications + +#### `notifier.go` +- **Notifier**: Core interface that all notification implementations must satisfy +- **NotifierFactory**: Factory pattern for creating notifier instances +- **NotificationService**: High-level service interface for notification operations +- **NotificationStats**: Statistics and metrics about notification processing + +#### `queue.go` +- **Queue**: Interface for notification queue implementations +- **QueueMessage**: Wrapper around notifications with queue-specific metadata +- **QueueConfig**: Configuration for queue implementations +- **LocalQueueConfig**: In-memory queue configuration +- **KafkaQueueConfig**: Distributed Kafka queue configuration + +### 2. Queue Implementation (`internal/queue/`) + +#### `local.go` +- In-memory queue implementation using Go channels +- Optional disk persistence for durability +- Thread-safe with mutex protection +- Supports enqueue, dequeue, ack, and nack operations +- Configurable buffer size and retry behavior + +### 3. Notifier Implementations (`internal/notifier/`) + +#### `notifier.go` +- **Factory**: Manages and creates notifier instances +- **BaseNotifier**: Common functionality shared by all notifiers +- Validation and context checking utilities + +#### `stdout.go` +- Simple stdout notifier for debugging and development +- Prints notifications to console with formatted output + +#### `smtp.go` +- Email notifications via SMTP +- Supports TLS/SSL +- Configurable SMTP server, port, and authentication +- RFC-compliant email message formatting + +#### `ntfy.go` +- Integration with ntfy.sh push notification service +- Supports custom ntfy servers +- Priority mapping and metadata support +- Rich notification features (tags, click actions, attachments) + +#### `slack.go` +- Slack webhook integration +- Channel-specific webhook support +- Rich message formatting with blocks +- Priority indicators and custom branding + +### 4. Configuration (`internal/config/`) + +#### `config.go` +- Viper-based configuration management +- Support for YAML config files and environment variables +- Default value handling +- Comprehensive validation +- Hierarchical configuration structure: + - Server settings (ports, host, mode) + - Queue configuration + - Notifier credentials + - Logging settings + - Metrics and observability + - Health check configuration + +### 5. API Layer + +#### gRPC API (`api/grpc/`) + +**`notifier.proto`** +- Protocol buffer definitions for the gRPC service +- Operations: + - `SendNotification`: Send single notification + - `SendBatchNotifications`: Send multiple notifications + - `GetNotification`: Retrieve notification by ID + - `ListNotifications`: Query notifications with filters + - `CancelNotification`: Cancel pending notification + - `RetryNotification`: Retry failed notification + - `GetStats`: Retrieve service statistics + - `HealthCheck`: Service health verification + +#### REST API (`api/rest/`) + +**`handlers.go`** +- HTTP handlers for REST endpoints +- Request validation and error handling +- JSON serialization/deserialization + +**`router.go`** +- Gorilla mux router configuration +- Middleware for logging and CORS +- Route definitions matching gRPC operations + +**`types.go`** +- REST API request/response types +- Domain model conversions +- Validation logic + +### 6. Entry Points (`cmd/`) + +#### `grpcserver/main.go` +- Standalone gRPC server +- Service initialization and dependency injection +- Graceful shutdown handling + +#### `restserver/main.go` +- Standalone REST server +- HTTP server configuration +- Graceful shutdown handling + +## Data Flow + +### Sending a Notification + +``` +Client Request (REST/gRPC) + ↓ +API Handler + ↓ +NotificationService.Send() + ↓ +Queue.Enqueue() + ↓ +[Notification queued] + ↓ +Worker dequeues (Queue.Dequeue()) + ↓ +NotifierFactory.Create() + ↓ +Notifier.Send() + ↓ +Provider API (SMTP/Slack/Ntfy/Stdout) + ↓ +Queue.Ack() or Queue.Nack() + ↓ +Update notification status + ↓ +[Notification sent or failed] +``` + +## Queue Strategies + +### Local Queue +- In-memory implementation using Go channels +- Fast and simple for single-instance deployments +- Optional disk persistence for durability +- Suitable for development and small-scale production + +### Kafka Queue (Future) +- Distributed queue for multi-instance deployments +- Guarantees delivery across service restarts +- Horizontal scalability +- Exactly-once semantics with idempotence +- Suitable for high-throughput production environments + +## Notification Lifecycle + +1. **Pending**: Notification created but not yet queued +2. **Queued**: Added to queue, waiting for processing +3. **Processing**: Worker has dequeued and is sending +4. **Sent**: Successfully delivered to provider +5. **Failed**: Delivery failed after max retries +6. **Retrying**: Temporarily failed, will retry + +## Retry Strategy + +- Configurable max retries (default: 3) +- Pluggable backoff strategies: + - **Exponential**: 2^n delay between retries + - **Linear**: Fixed increment between retries + - **Fixed**: Constant delay between retries + +## Configuration Management + +### Hierarchy (highest to lowest priority) +1. Environment variables (prefixed with `NOTIFIER_`) +2. Configuration file (config.yaml) +3. Default values + +### Example Environment Variables +```bash +NOTIFIER_SERVER_GRPC_PORT=50051 +NOTIFIER_SERVER_REST_PORT=8080 +NOTIFIER_QUEUE_TYPE=local +NOTIFIER_NOTIFIERS_SMTP_HOST=smtp.gmail.com +NOTIFIER_NOTIFIERS_SLACK_WEBHOOK_URL=https://hooks.slack.com/... +``` + +## Deployment Strategies + +### Docker Compose +- Single-host deployment +- All components in one compose file +- Optional Kafka, Prometheus, and Grafana services +- Volume mounts for configuration and persistence + +### Kubernetes +- Multi-instance deployment with HPA +- Separate services for REST and gRPC +- ConfigMaps for configuration +- Secrets for credentials +- Ingress for external access +- Health checks and readiness probes +- Resource limits and requests + +### Scaling Considerations + +#### Horizontal Scaling +- Multiple instances can run concurrently +- Use Kafka queue for distributed message processing +- Stateless design allows easy scaling + +#### Vertical Scaling +- Increase worker count per instance +- Tune queue buffer sizes +- Adjust resource limits + +## Observability + +### Metrics (Prometheus) +- Total notifications sent/failed +- Notifications by type and status +- Average latency +- Queue size +- Worker utilization + +### Health Checks +- Dedicated health endpoint +- Component-level health reporting +- Kubernetes liveness/readiness probes + +### Logging +- Structured JSON logging +- Configurable log levels +- Request/response logging +- Error tracking + +## Security + +### Authentication +- API keys for REST endpoints (to be implemented) +- mTLS for gRPC (to be implemented) +- Kubernetes RBAC for service account + +### Authorization +- Per-notifier credential management +- Secrets stored in Kubernetes Secrets or external secret manager +- No credentials in configuration files + +### Network Security +- Non-root container user +- Read-only root filesystem where possible +- Minimal container image (Alpine-based) +- Security context restrictions + +## Extension Points + +### Adding a New Notifier + +1. Create new file in `internal/notifier/` +2. Implement the `domain.Notifier` interface: + - `Send(ctx, notification) -> result` + - `Type() -> NotificationType` + - `Validate(notification) -> error` + - `Close() -> error` +3. Add configuration struct to `internal/config/` +4. Register in factory during initialization +5. Update protobuf and REST API types +6. Add configuration example to `config.yaml` + +### Adding a New Queue Implementation + +1. Create new file in `internal/queue/` +2. Implement the `domain.Queue` interface +3. Add configuration to `domain.QueueConfig` +4. Update queue factory/initialization logic +5. Add configuration example + +## Next Steps for Implementation + +1. **Generate Protocol Buffers**: Run `make proto-gen` to generate gRPC code +2. **Implement Service Layer**: Create the main service that ties everything together +3. **Implement Server Main Functions**: Wire up dependencies in `cmd/` +4. **Add Tests**: Unit tests for notifiers, integration tests for queue +5. **Add Kafka Queue**: Implement distributed queue using Kafka +6. **Add Metrics**: Prometheus instrumentation +7. **Add Authentication**: API key or OAuth support +8. **Add Rate Limiting**: Prevent abuse and manage provider quotas +9. **Add Notification Templates**: Support for templated messages +10. **Add Webhooks**: Allow callbacks on notification status changes diff --git a/Dockerfile b/Dockerfile index e69de29..872f2d1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -0,0 +1,55 @@ +# Build stage +FROM golang:1.23.2-alpine AS builder + +# Install build dependencies +RUN apk add --no-cache git make + +# Set working directory +WORKDIR /build + +# Copy go mod files +COPY go.mod go.sum ./ + +# Download dependencies +RUN go mod download + +# Copy source code +COPY . . + +# Build binaries +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o grpcserver ./cmd/grpcserver +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o restserver ./cmd/restserver + +# Runtime stage +FROM alpine:latest + +# Install runtime dependencies +RUN apk --no-cache add ca-certificates tzdata + +# Create non-root user +RUN addgroup -g 1000 notifier && \ + adduser -D -u 1000 -G notifier notifier + +# Set working directory +WORKDIR /app + +# Copy binaries from builder +COPY --from=builder /build/grpcserver /app/ +COPY --from=builder /build/restserver /app/ + +# Copy default config (can be overridden with volume mount) +COPY config.yaml /app/config.yaml + +# Create directory for queue persistence +RUN mkdir -p /var/lib/notifier && \ + chown -R notifier:notifier /var/lib/notifier + +# Change to non-root user +USER notifier + +# Expose ports +EXPOSE 8080 50051 9090 8081 + +# Default to running both servers +# Can be overridden with docker run command +CMD ["sh", "-c", "/app/grpcserver & /app/restserver"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..631216a --- /dev/null +++ b/Makefile @@ -0,0 +1,98 @@ +.PHONY: proto proto-gen proto-clean deps build run-grpc run-rest run-both test lint docker-build docker-run clean + +# Variables +PROTO_DIR=api/grpc +PROTO_FILE=$(PROTO_DIR)/notifier.proto +PROTO_OUT=$(PROTO_DIR)/pb +GO_MODULE=$(shell head -n 1 go.mod | awk '{print $$2}') + +# Generate protobuf code +proto-gen: + @echo "Generating protobuf code..." + @mkdir -p $(PROTO_OUT) + protoc --go_out=$(PROTO_OUT) --go_opt=paths=source_relative \ + --go-grpc_out=$(PROTO_OUT) --go-grpc_opt=paths=source_relative \ + $(PROTO_FILE) + @echo "Protobuf code generated successfully" + +# Clean generated protobuf code +proto-clean: + @echo "Cleaning generated protobuf code..." + @rm -rf $(PROTO_OUT) + @echo "Cleaned" + +# Install dependencies +deps: + @echo "Installing dependencies..." + go mod download + go mod tidy + @echo "Dependencies installed" + +# Install protoc plugins +proto-deps: + @echo "Installing protoc plugins..." + go install google.golang.org/protobuf/cmd/protoc-gen-go@latest + go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest + @echo "Protoc plugins installed" + +# Build binaries +build: + @echo "Building binaries..." + @mkdir -p bin + go build -o bin/grpcserver ./cmd/grpcserver + go build -o bin/restserver ./cmd/restserver + @echo "Binaries built successfully" + +# Run gRPC server +run-grpc: + @echo "Running gRPC server..." + go run ./cmd/grpcserver/main.go + +# Run REST server +run-rest: + @echo "Running REST server..." + go run ./cmd/restserver/main.go + +# Run tests +test: + @echo "Running tests..." + go test -v -race -cover ./... + +# Run linter +lint: + @echo "Running linter..." + golangci-lint run ./... + +# Build Docker image +docker-build: + @echo "Building Docker image..." + docker build -t notifier:latest . + @echo "Docker image built successfully" + +# Run Docker container +docker-run: + @echo "Running Docker container..." + docker run -p 8080:8080 -p 50051:50051 -v $(PWD)/config.yaml:/app/config.yaml notifier:latest + +# Clean build artifacts +clean: + @echo "Cleaning build artifacts..." + @rm -rf bin/ + @rm -rf $(PROTO_OUT) + @echo "Cleaned" + +# Help +help: + @echo "Available targets:" + @echo " proto-gen - Generate protobuf code" + @echo " proto-clean - Clean generated protobuf code" + @echo " proto-deps - Install protoc plugins" + @echo " deps - Install Go dependencies" + @echo " build - Build binaries" + @echo " run-grpc - Run gRPC server" + @echo " run-rest - Run REST server" + @echo " test - Run tests" + @echo " lint - Run linter" + @echo " docker-build - Build Docker image" + @echo " docker-run - Run Docker container" + @echo " clean - Clean build artifacts" diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 0000000..bde2bc3 --- /dev/null +++ b/QUICKSTART.md @@ -0,0 +1,329 @@ +# Quick Start Guide + +## Prerequisites + +1. Go 1.23.2 or higher installed +2. Dependencies installed: `go mod tidy` + +## Running the REST Server + +### Option 1: Using go run +```bash +go run ./cmd/restserver/main.go +``` + +### Option 2: Build and run +```bash +go build -o bin/restserver ./cmd/restserver +./bin/restserver +``` + +### Option 3: Using Make +```bash +make build +make run-rest +``` + +The server will start on `http://localhost:8080` by default. + +## Testing with cURL + +### 1. Health Check +```bash +curl http://localhost:8080/health +``` + +Expected response: +```json +{ + "status": "healthy", + "service": "notifier", + "time": "2025-10-16T21:05:27Z" +} +``` + +### 2. Send a Simple Notification +```bash +curl -X POST http://localhost:8080/api/v1/notifications \ + -H "Content-Type: application/json" \ + -d '{ + "type": "stdout", + "subject": "Hello World", + "body": "This is a test notification!", + "recipients": ["console"] + }' +``` + +Expected response: +```json +{ + "result": { + "notification_id": "abc123...", + "success": true, + "message": "notification queued successfully", + "sent_at": "2025-10-16T21:05:27Z" + } +} +``` + +The notification will be printed to the server's stdout. + +### 3. Send with Priority +```bash +curl -X POST http://localhost:8080/api/v1/notifications \ + -H "Content-Type: application/json" \ + -d '{ + "type": "stdout", + "priority": 4, + "subject": "CRITICAL Alert", + "body": "This is a critical notification!", + "recipients": ["console"], + "max_retries": 5 + }' +``` + +Priority levels: +- `0` - Low +- `1` - Normal (default) +- `2` - High +- `3` - Critical + +### 4. Send Batch Notifications +```bash +curl -X POST http://localhost:8080/api/v1/notifications/batch \ + -H "Content-Type: application/json" \ + -d '{ + "notifications": [ + { + "type": "stdout", + "subject": "Notification 1", + "body": "First notification", + "recipients": ["console"] + }, + { + "type": "stdout", + "subject": "Notification 2", + "body": "Second notification", + "recipients": ["console"] + } + ] + }' +``` + +### 5. Get Notification Statistics +```bash +curl http://localhost:8080/api/v1/stats +``` + +Expected response: +```json +{ + "total_sent": 5, + "total_failed": 0, + "total_pending": 0, + "total_queued": 0, + "by_type": { + "stdout": 5 + }, + "by_status": { + "sent": 5 + }, + "average_latency_ms": 0 +} +``` + +### 6. List Notifications +```bash +# List all notifications +curl http://localhost:8080/api/v1/notifications + +# List with filters +curl "http://localhost:8080/api/v1/notifications?type=stdout&status=sent&limit=10" +``` + +### 7. Get Specific Notification +```bash +curl http://localhost:8080/api/v1/notifications/{notification-id} +``` + +## Testing with Other Notifiers + +### SMTP (Email) +Update `config.yaml`: +```yaml +notifiers: + smtp: + host: "smtp.gmail.com" + port: 587 + username: "your-email@gmail.com" + password: "your-app-password" + from: "notifications@yourservice.com" + use_tls: true +``` + +Then send: +```bash +curl -X POST http://localhost:8080/api/v1/notifications \ + -H "Content-Type: application/json" \ + -d '{ + "type": "email", + "subject": "Test Email", + "body": "This is a test email!", + "recipients": ["recipient@example.com"] + }' +``` + +### Slack +Update `config.yaml`: +```yaml +notifiers: + slack: + webhook_url: "https://hooks.slack.com/services/YOUR/WEBHOOK/URL" + username: "Notifier Bot" + icon_emoji: ":bell:" +``` + +Then send: +```bash +curl -X POST http://localhost:8080/api/v1/notifications \ + -H "Content-Type: application/json" \ + -d '{ + "type": "slack", + "subject": "Deployment Alert", + "body": "Application deployed successfully to production!", + "recipients": ["#alerts"] + }' +``` + +### Ntfy +Update `config.yaml`: +```yaml +notifiers: + ntfy: + server_url: "https://ntfy.sh" +``` + +Then send: +```bash +curl -X POST http://localhost:8080/api/v1/notifications \ + -H "Content-Type: application/json" \ + -d '{ + "type": "ntfy", + "subject": "Mobile Alert", + "body": "This will appear on your phone!", + "recipients": ["mytopic"], + "metadata": { + "tags": ["warning", "skull"] + } + }' +``` + +## Configuration + +### Using Environment Variables +```bash +export NOTIFIER_SERVER_REST_PORT=9000 +export NOTIFIER_QUEUE_WORKER_COUNT=20 +export NOTIFIER_NOTIFIERS_SMTP_HOST=smtp.gmail.com +export NOTIFIER_NOTIFIERS_SMTP_PASSWORD=secret + +./bin/restserver +``` + +### Using config.yaml +Create or modify `config.yaml` in the project root: +```yaml +server: + rest_port: 8080 + host: "0.0.0.0" + +queue: + type: "local" + worker_count: 10 + +notifiers: + stdout: true +``` + +## Docker Deployment + +### Build Docker Image +```bash +docker build -t notifier:latest . +``` + +### Run with Docker +```bash +docker run -p 8080:8080 \ + -v $(pwd)/config.yaml:/app/config.yaml \ + notifier:latest +``` + +### Using Docker Compose +```bash +docker-compose up +``` + +## Kubernetes Deployment + +### Deploy to Kubernetes +```bash +# Apply all resources +kubectl apply -f k8s/ + +# Check status +kubectl get pods -l app=notifier +kubectl get svc -l app=notifier + +# View logs +kubectl logs -f -l app=notifier + +# Port forward to access locally +kubectl port-forward svc/notifier-rest 8080:8080 +``` + +## Next Steps + +1. **Add Authentication**: Implement API key or OAuth authentication +2. **Add Email Templates**: Support for templated notifications +3. **Add Webhooks**: Get callbacks when notifications are sent/failed +4. **Add Persistence**: Store notifications in a database +5. **Add Kafka Queue**: For distributed processing +6. **Add Metrics**: Instrument with Prometheus metrics +7. **Add Tests**: Write unit and integration tests + +## Troubleshooting + +### Server won't start +- Check if port 8080 is already in use: `lsof -i :8080` +- Check config.yaml syntax +- Verify all dependencies are installed: `go mod tidy` + +### Notifications not sending +- Check server logs for errors +- Verify the notifier is enabled in config.yaml +- For SMTP: Verify credentials and allow less secure apps +- For Slack: Verify webhook URL is correct +- For Ntfy: Ensure topic name is valid + +### Queue filling up +- Increase worker count in config.yaml +- Check if notifiers are failing +- Review retry configuration + +## API Reference + +Full API documentation available in `ARCHITECTURE.md`. + +### Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| GET | /health | Health check | +| POST | /api/v1/notifications | Send notification | +| POST | /api/v1/notifications/batch | Send batch | +| GET | /api/v1/notifications | List notifications | +| GET | /api/v1/notifications/{id} | Get notification | +| DELETE | /api/v1/notifications/{id} | Cancel notification | +| POST | /api/v1/notifications/{id}/retry | Retry notification | +| GET | /api/v1/stats | Get statistics | diff --git a/README.md b/README.md index f679d23..a042f07 100644 --- a/README.md +++ b/README.md @@ -1,153 +1,623 @@ +# Notifier Microservice -# Notifier Module +A production-ready notification delivery microservice that supports multiple notification channels (Email, Slack, Ntfy, Stdout) with both REST and gRPC APIs running simultaneously. -The **Notifier** module provides a flexible notification system that supports multiple notification modes (e.g., SMTP, SMS, Slack, Ntfy, and Stdout). It can operate as a standalone gRPC or REST microservice, or be used as an imported module in other projects. +[![Go Version](https://img.shields.io/badge/go-1.23.2-blue.svg)](https://golang.org) +[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) + +## Overview + +Notifier is a cloud-native microservice designed to handle all your application's notification needs through a single, unified API. Send emails, Slack messages, push notifications, and more with a consistent interface and robust delivery guarantees. + +Perfect for: +- Microservices architectures needing centralized notifications +- Applications requiring multiple notification channels +- Systems needing reliable async notification delivery +- Teams wanting to standardize notification handling ## Features -- **Multi-Mode Notification**: Supports different notification methods including SMTP email, SMS, Slack, Ntfy, and standard output. -- **Extensible Design**: Add new notification modes by implementing the `Notifier` interface. -- **gRPC and REST APIs**: Accessible through both gRPC and RESTful APIs, allowing easy integration into various systems. -- **Configuration Management**: Easily configurable for different environments and API keys. +### Core Capabilities +- 🔔 **Multi-Channel Support**: Email (SMTP), Slack, Ntfy.sh, and Stdout +- 🚀 **Dual API**: REST and gRPC running simultaneously in one process +- 📦 **Queue-Based**: Async processing with configurable worker pools +- 🔄 **Retry Logic**: Exponential backoff with configurable attempts +- ⚡ **Priority Levels**: Low, Normal, High, and Critical +- 📊 **Batch Operations**: Send multiple notifications efficiently +- 🎯 **Status Tracking**: Monitor notification lifecycle and delivery + +### Production Features +- ⚙️ **Configuration**: Viper-based with environment variable support +- 🐳 **Containerized**: Multi-stage Docker builds with non-root user +- ☸️ **Kubernetes Ready**: Complete manifests with HPA, health checks, and RBAC +- 🔒 **Secure**: Token-based auth for ntfy, TLS support, secret management +- 📈 **Observable**: Health endpoints, metrics support, structured logging +- 🔌 **Extensible**: Clean interfaces for adding new notifiers + +## Quick Start + +### 1. Install and Run + +```bash +# Clone and install dependencies +git clone https://github.com/igodwin/notifier +cd notifier +go mod tidy + +# Build and run +go build -o bin/server ./cmd/server +./bin/server +``` + +Server starts with: +- **REST API** on `http://localhost:8080` +- **gRPC API** on `localhost:50051` +- **Stdout notifier** enabled by default + +### 2. Send Your First Notification + +```bash +curl -X POST http://localhost:8080/api/v1/notifications \ + -H "Content-Type: application/json" \ + -d '{ + "type": "stdout", + "subject": "Hello from Notifier!", + "body": "Your first notification", + "recipients": ["console"] + }' +``` + +The notification appears immediately in the server output. ✅ + +### 3. Check Status + +```bash +# Health check +curl http://localhost:8080/health + +# Statistics +curl http://localhost:8080/api/v1/stats +``` + +## Configuration + +### Basic Setup + +Create `config.yaml` in the project root: + +```yaml +server: + mode: "both" # Options: both, rest, grpc + rest_port: 8080 + grpc_port: 50051 + host: "0.0.0.0" + +queue: + type: "local" # Local in-memory queue + worker_count: 10 # Concurrent workers + retry_attempts: 3 + retry_backoff: "exponential" + +notifiers: + stdout: true # Always enabled for testing +``` + +### Email Notifications (SMTP) + +```yaml +notifiers: + smtp: + host: "smtp.gmail.com" + port: 587 + username: "your-email@gmail.com" + password: "your-app-password" + from: "notifications@yourservice.com" + use_tls: true +``` + +**Usage:** +```bash +curl -X POST http://localhost:8080/api/v1/notifications \ + -H "Content-Type: application/json" \ + -d '{ + "type": "email", + "subject": "Welcome!", + "body": "Thanks for signing up", + "recipients": ["user@example.com"] + }' +``` + +### Slack Notifications + +```yaml +notifiers: + slack: + webhook_url: "https://hooks.slack.com/services/YOUR/WEBHOOK/URL" + username: "Notifier Bot" + icon_emoji: ":bell:" +``` + +**Usage:** +```bash +curl -X POST http://localhost:8080/api/v1/notifications \ + -H "Content-Type: application/json" \ + -d '{ + "type": "slack", + "subject": "Deployment Complete", + "body": "v2.0 deployed to production", + "recipients": ["#alerts"] + }' +``` + +### Ntfy Push Notifications + +```yaml +notifiers: + ntfy: + server_url: "https://ntfy.sh" + token: "tk_your_access_token" # Optional, for private topics + default_topic: "myapp-alerts" +``` + +**Usage:** +```bash +curl -X POST http://localhost:8080/api/v1/notifications \ + -H "Content-Type: application/json" \ + -d '{ + "type": "ntfy", + "priority": 3, + "subject": "Critical Alert", + "body": "Server CPU at 95%", + "recipients": ["alerts"], + "metadata": { + "tags": ["warning", "rotating_light"], + "click": "https://dashboard.example.com" + } + }' +``` + +See [docs/NTFY_GUIDE.md](docs/NTFY_GUIDE.md) for advanced ntfy features (action buttons, attachments, delays, etc.). + +### Environment Variables + +Override any config with environment variables: + +```bash +export NOTIFIER_SERVER_MODE=both +export NOTIFIER_SERVER_REST_PORT=8080 +export NOTIFIER_QUEUE_WORKER_COUNT=20 +export NOTIFIER_NOTIFIERS_SMTP_HOST=smtp.gmail.com +export NOTIFIER_NOTIFIERS_SMTP_PASSWORD=secret +export NOTIFIER_NOTIFIERS_NTFY_TOKEN=tk_your_token +``` + +Format: `NOTIFIER_
_` (use `_` for nested keys) + +## API Reference + +### REST Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/health` | Health check | +| `POST` | `/api/v1/notifications` | Send single notification | +| `POST` | `/api/v1/notifications/batch` | Send multiple notifications | +| `GET` | `/api/v1/notifications` | List notifications (with filters) | +| `GET` | `/api/v1/notifications/{id}` | Get notification by ID | +| `DELETE` | `/api/v1/notifications/{id}` | Cancel pending notification | +| `POST` | `/api/v1/notifications/{id}/retry` | Retry failed notification | +| `GET` | `/api/v1/stats` | Get service statistics | + +### Request Format + +```json +{ + "type": "email|slack|ntfy|stdout", + "priority": 0-3, + "subject": "Notification title", + "body": "Notification body", + "recipients": ["email@example.com", "#channel", "topic"], + "metadata": { + "key": "value" + }, + "max_retries": 3 +} +``` + +### Response Format + +```json +{ + "result": { + "notification_id": "uuid", + "success": true, + "message": "notification queued successfully", + "sent_at": "2025-10-16T21:05:27Z" + } +} +``` + +### Priority Levels + +- `0` - Low (background notifications) +- `1` - Normal (default) +- `2` - High (important updates) +- `3` - Critical (urgent alerts) + +### Batch Operations + +```bash +curl -X POST http://localhost:8080/api/v1/notifications/batch \ + -H "Content-Type: application/json" \ + -d '{ + "notifications": [ + {"type": "email", "subject": "Welcome", ...}, + {"type": "slack", "subject": "Alert", ...} + ] + }' +``` + +### Filtering Notifications + +```bash +# Get all sent email notifications +curl "http://localhost:8080/api/v1/notifications?type=email&status=sent&limit=10" + +# Get recent failures +curl "http://localhost:8080/api/v1/notifications?status=failed&limit=20" +``` + +## gRPC API + +The gRPC service mirrors the REST API with full feature parity. See [api/grpc/notifier.proto](api/grpc/notifier.proto) for definitions. + +**Generate Go code:** +```bash +make proto-gen +# or +protoc --go_out=. --go-grpc_out=. api/grpc/notifier.proto +``` + +**Note:** gRPC server is running but handler implementation is pending. Protobuf definitions are complete. + +## Deployment + +### Docker + +**Build:** +```bash +docker build -t notifier:latest . +``` + +**Run:** +```bash +docker run -d \ + --name notifier \ + -p 8080:8080 \ + -p 50051:50051 \ + -v $(pwd)/config.yaml:/app/config.yaml:ro \ + notifier:latest +``` + +**Docker Compose:** +```bash +docker-compose up -d +``` + +Includes optional services: Kafka, Prometheus, Grafana (commented out by default) + +### Kubernetes + +**Deploy:** +```bash +kubectl apply -f k8s/ +``` + +**Included resources:** +- Deployment (3 replicas with rolling updates) +- Services (separate for REST and gRPC) +- ConfigMap (configuration management) +- HPA (auto-scaling 3-10 pods based on CPU/memory) +- Ingress (external access with TLS) +- Secret template (for credentials) + +**Access locally:** +```bash +kubectl port-forward svc/notifier-rest 8080:8080 +kubectl port-forward svc/notifier-grpc 50051:50051 +``` + +**Using Kustomize:** +```bash +kubectl apply -k k8s/ +``` + +## Architecture + +### High-Level Design + +``` +┌──────────────┐ ┌──────────────┐ +│ REST Client │────▶│ REST API │ +└──────────────┘ │ (port 8080) │ + └──────┬───────┘ +┌──────────────┐ │ +│ gRPC Client │────▶│ gRPC API │ +└──────────────┘ │ (port 50051) │ + └──────┬───────┘ + ▼ + ┌──────────────────┐ + │ Notification │ + │ Service │ + └────────┬─────────┘ + ▼ + ┌──────────────────┐ + │ Queue (Local) │◀──┐ + └────────┬─────────┘ │ + ▼ │ + ┌──────────────────┐ │ + │ Worker Pool │───┘ + │ (10 workers) │ + └────────┬─────────┘ + ▼ + ┌─────────────────┼─────────────────┐ + ▼ ▼ ▼ + ┌────────┐ ┌──────────┐ ┌──────────┐ + │ SMTP │ │ Slack │ │ Ntfy │ + │Notifier│ │ Notifier │ │ Notifier │ + └────────┘ └──────────┘ └──────────┘ +``` + +### Key Components + +- **API Layer**: REST (Gorilla mux) and gRPC (Protocol Buffers) +- **Service Layer**: Business logic, validation, orchestration +- **Queue**: In-memory with optional disk persistence (Kafka planned) +- **Workers**: Concurrent processors with retry logic +- **Notifiers**: Pluggable providers implementing `domain.Notifier` +- **Config**: Viper-based with file + env var support + +See [ARCHITECTURE.md](ARCHITECTURE.md) for detailed design documentation. ## Project Structure -```plaintext +``` notifier/ ├── api/ -│ ├── grpc/ # gRPC service definitions and generated code -│ └── rest/ # REST API handlers and router +│ ├── grpc/ +│ │ └── notifier.proto # gRPC service definition +│ └── rest/ +│ ├── handlers.go # HTTP handlers +│ ├── router.go # Route configuration +│ └── types.go # Request/response types ├── cmd/ -│ ├── grpcserver/ # Entrypoint for running the gRPC server -│ └── restserver/ # Entrypoint for running the REST server +│ ├── server/main.go # Combined server (recommended) +│ ├── grpcserver/main.go # gRPC only +│ └── restserver/main.go # REST only ├── internal/ -│ ├── config/ # Configuration loading -│ └── notifier/ # Core notification logic with multiple implementations -├── pkg/ -│ ├── grpcclient/ # gRPC client for interacting with the notifier service -│ └── restclient/ # REST client for interacting with the notifier service -├── Dockerfile # Docker configuration for deploying as a microservice -├── LICENSE # License file -├── README.md # Documentation -└── go.mod # Defines the module's dependencies, module path, and Go version +│ ├── config/ +│ │ └── config.go # Configuration management +│ ├── domain/ +│ │ ├── notification.go # Core types +│ │ ├── notifier.go # Notifier interface +│ │ └── queue.go # Queue interface +│ ├── notifier/ +│ │ ├── notifier.go # Factory & base +│ │ ├── smtp.go # Email notifier +│ │ ├── slack.go # Slack notifier +│ │ ├── ntfy.go # Ntfy notifier +│ │ └── stdout.go # Stdout notifier +│ ├── queue/ +│ │ └── local.go # In-memory queue +│ └── service/ +│ └── service.go # Business logic +├── k8s/ +│ ├── deployment.yaml +│ ├── service.yaml +│ ├── configmap.yaml +│ ├── ingress.yaml +│ ├── hpa.yaml +│ └── kustomization.yaml +├── docs/ +│ └── NTFY_GUIDE.md # Ntfy integration guide +├── config.yaml # Default configuration +├── docker-compose.yaml +├── Dockerfile +├── Makefile +├── QUICKSTART.md # API examples +├── ARCHITECTURE.md # Design documentation +└── README.md # This file ``` -## Getting Started +## Development -### Prerequisites - -- **Go**: Install Go 1.18 or higher. -- **Protocol Buffers**: Required if you want to regenerate gRPC code. -- **Docker** (optional): For containerized deployment. - -### Installation - -To install the notifier module as a dependency in another Go project, run: +### Build Commands ```bash -go get github.com/igodwin/notifier +make build # Build binaries +make run-rest # Run REST server +make run-grpc # Run gRPC server (when implemented) +make test # Run tests +make lint # Run linter +make proto-gen # Generate protobuf code +make docker-build # Build Docker image +make clean # Clean artifacts ``` -### Running the Service +### Adding a New Notifier -#### gRPC Server - -To start the gRPC server: - -```bash -cd cmd/grpcserver -go run main.go -``` - -#### REST Server - -To start the REST server: - -```bash -cd cmd/restserver -go run main.go -``` - -### Configuration - -Configure notification modes via environment variables or a configuration file (e.g., `config.yaml`). Configuration settings may include: - -- **SMTP**: SMTP server details, port, credentials. -- **SMS**: SMS provider API keys. -- **Slack**: Slack webhook URLs. -- **Ntfy**: Ntfy service URL and options. - -For example, using a `config.yaml`: - -```yaml -smtp: - server: "smtp.example.com" - port: 587 - username: "user@example.com" - password: "password" - -slack: - webhook_url: "https://hooks.slack.com/services/..." -``` - -### Usage - -#### Using gRPC API - -1. Define a gRPC client using the `pkg/grpcclient` package. -2. Call `SendNotification` to send a message through a chosen notification method. +1. Create `internal/notifier/mynotifier.go` +2. Implement `domain.Notifier` interface +3. Add config struct to `internal/config/config.go` +4. Register in `cmd/server/main.go` +5. Update `config.yaml` with example config +6. Add tests Example: - ```go -client, err := grpcclient.NewClient("localhost:50051") -if err != nil { - log.Fatalf("Failed to create gRPC client: %v", err) +type MyNotifier struct { + BaseNotifier + config *MyNotifierConfig } -resp, err := client.SendNotification("recipient@example.com", "Hello via gRPC!") -if err != nil { - log.Fatalf("Failed to send notification: %v", err) +func (m *MyNotifier) Send(ctx context.Context, n *domain.Notification) (*domain.NotificationResult, error) { + // Implementation } ``` -#### Using REST API +See [ARCHITECTURE.md](ARCHITECTURE.md) for detailed guide. -1. Define a REST client using the `pkg/restclient` package. -2. Call `SendNotification` via REST. +## Documentation -Example: +- [QUICKSTART.md](QUICKSTART.md) - Quick start guide with examples +- [ARCHITECTURE.md](ARCHITECTURE.md) - Architecture and design details +- [docs/NTFY_GUIDE.md](docs/NTFY_GUIDE.md) - Ntfy integration guide +- [api/grpc/notifier.proto](api/grpc/notifier.proto) - gRPC API definition -```go -client := restclient.NewClient("http://localhost:8080") -err := client.SendNotification("recipient@example.com", "Hello via REST!") -if err != nil { - log.Fatalf("Failed to send notification: %v", err) +## Testing + +```bash +# Run all tests +go test ./... + +# Run with coverage +go test -cover ./... + +# Run specific package +go test ./internal/notifier/... +``` + +**Current Status:** Core implementation complete, tests pending. + +## Monitoring & Operations + +### Health Checks + +```bash +curl http://localhost:8080/health +``` + +Returns: +```json +{ + "status": "healthy", + "service": "notifier", + "time": "2025-10-16T21:05:27Z" } ``` -### Implementing New Notification Modes +### Statistics -To add a new notification method, implement the `Notifier` interface in `internal/notifier/`: +```bash +curl http://localhost:8080/api/v1/stats +``` -```go -type Notifier interface { - Send(notification Notification) error +Returns: +```json +{ + "total_sent": 1234, + "total_failed": 5, + "total_pending": 0, + "total_queued": 2, + "by_type": { + "email": 800, + "slack": 400, + "ntfy": 34 + }, + "by_status": { + "sent": 1234, + "failed": 5 + } } ``` -Add the new mode (e.g., `sms.go`) with the `Send` method to implement custom logic. +### Graceful Shutdown + +The server handles `SIGINT` and `SIGTERM` gracefully: +- Stops accepting new requests +- Completes in-flight notifications +- Drains the queue +- Closes all connections +- 30-second timeout + +## Roadmap + +### Completed ✅ +- [x] Core notification system +- [x] REST API (fully functional) +- [x] gRPC API (protobuf defined) +- [x] Local queue with workers +- [x] Multiple notifiers (SMTP, Slack, Ntfy, Stdout) +- [x] Priority and retry logic +- [x] Batch operations +- [x] Docker support +- [x] Kubernetes manifests with HPA +- [x] Health checks and stats +- [x] Configuration management + +### Planned 🚧 +- [ ] gRPC handler implementation +- [ ] Kafka queue adapter +- [ ] Database persistence (PostgreSQL) +- [ ] Notification templates +- [ ] Webhook callbacks +- [ ] Authentication/Authorization (API keys, OAuth) +- [ ] Rate limiting (per client, per notifier) +- [ ] Prometheus metrics +- [ ] OpenTelemetry tracing +- [ ] Comprehensive test suite +- [ ] Circuit breakers for notifiers +- [ ] Dead letter queue +- [ ] Admin dashboard ## Contributing -Contributions are welcome! To contribute, fork the repository, make your changes, and submit a pull request. +Contributions are welcome! Please: -1. Fork the repository. -2. Create a feature branch (`git checkout -b feature/NewMode`). -3. Commit your changes (`git commit -am 'Add new notification mode'`). -4. Push to the branch (`git push origin feature/NewMode`). -5. Create a new Pull Request. +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Write tests for your changes +4. Ensure tests pass (`go test ./...`) +5. Commit your changes (`git commit -m 'Add amazing feature'`) +6. Push to the branch (`git push origin feature/amazing-feature`) +7. Open a Pull Request + +Please follow Go best practices and maintain test coverage. + +## Security + +### Reporting Vulnerabilities + +Please report security vulnerabilities privately via GitHub Security Advisories. + +### Best Practices + +- Store credentials in environment variables or secrets managers +- Use TLS for production deployments +- Enable authentication for ntfy private topics +- Rotate tokens regularly +- Follow principle of least privilege for K8s RBAC +- Keep dependencies updated ## License -This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. +This project is licensed under the MIT License - see [LICENSE](LICENSE) for details. + +## Support & Community + +- **Issues**: [GitHub Issues](https://github.com/igodwin/notifier/issues) +- **Discussions**: [GitHub Discussions](https://github.com/igodwin/notifier/discussions) +- **Documentation**: See `docs/` directory +- **Examples**: See [QUICKSTART.md](QUICKSTART.md) + +## Acknowledgments + +Built with: +- [Gorilla Mux](https://github.com/gorilla/mux) - HTTP routing +- [Viper](https://github.com/spf13/viper) - Configuration +- [gRPC-Go](https://github.com/grpc/grpc-go) - RPC framework +- [Ntfy](https://ntfy.sh) - Push notifications + +--- + +**Made with ❤️ for reliable notifications** diff --git a/api/grpc/notifier.proto b/api/grpc/notifier.proto index e69de29..4f89f35 100644 --- a/api/grpc/notifier.proto +++ b/api/grpc/notifier.proto @@ -0,0 +1,196 @@ +syntax = "proto3"; + +package notifier.v1; + +option go_package = "github.com/igodwin/notifier/api/grpc/pb"; + +import "google/protobuf/timestamp.proto"; + +// NotifierService handles notification operations +service NotifierService { + // SendNotification sends a single notification + rpc SendNotification(SendNotificationRequest) returns (SendNotificationResponse); + + // SendBatchNotifications sends multiple notifications + rpc SendBatchNotifications(SendBatchNotificationsRequest) returns (SendBatchNotificationsResponse); + + // GetNotification retrieves a notification by ID + rpc GetNotification(GetNotificationRequest) returns (GetNotificationResponse); + + // ListNotifications retrieves notifications matching a filter + rpc ListNotifications(ListNotificationsRequest) returns (ListNotificationsResponse); + + // CancelNotification cancels a pending notification + rpc CancelNotification(CancelNotificationRequest) returns (CancelNotificationResponse); + + // RetryNotification retries a failed notification + rpc RetryNotification(RetryNotificationRequest) returns (RetryNotificationResponse); + + // GetStats returns notification statistics + rpc GetStats(GetStatsRequest) returns (GetStatsResponse); + + // HealthCheck verifies the service is operational + rpc HealthCheck(HealthCheckRequest) returns (HealthCheckResponse); +} + +// NotificationType defines the channel for notification delivery +enum NotificationType { + NOTIFICATION_TYPE_UNSPECIFIED = 0; + NOTIFICATION_TYPE_EMAIL = 1; + NOTIFICATION_TYPE_SLACK = 2; + NOTIFICATION_TYPE_NTFY = 3; + NOTIFICATION_TYPE_STDOUT = 4; +} + +// Priority defines the urgency level +enum Priority { + PRIORITY_UNSPECIFIED = 0; + PRIORITY_LOW = 1; + PRIORITY_NORMAL = 2; + PRIORITY_HIGH = 3; + PRIORITY_CRITICAL = 4; +} + +// NotificationStatus represents the state of a notification +enum NotificationStatus { + NOTIFICATION_STATUS_UNSPECIFIED = 0; + NOTIFICATION_STATUS_PENDING = 1; + NOTIFICATION_STATUS_QUEUED = 2; + NOTIFICATION_STATUS_PROCESSING = 3; + NOTIFICATION_STATUS_SENT = 4; + NOTIFICATION_STATUS_FAILED = 5; + NOTIFICATION_STATUS_RETRYING = 6; +} + +// Notification represents a notification message +message Notification { + string id = 1; + NotificationType type = 2; + Priority priority = 3; + NotificationStatus status = 4; + string subject = 5; + string body = 6; + repeated string recipients = 7; + map metadata = 8; + google.protobuf.Timestamp created_at = 9; + google.protobuf.Timestamp scheduled_for = 10; + google.protobuf.Timestamp sent_at = 11; + int32 retry_count = 12; + int32 max_retries = 13; + string last_error = 14; +} + +// NotificationResult represents the outcome of sending a notification +message NotificationResult { + string notification_id = 1; + bool success = 2; + string message = 3; + string error = 4; + google.protobuf.Timestamp sent_at = 5; + map provider_response = 6; +} + +// SendNotificationRequest sends a single notification +message SendNotificationRequest { + NotificationType type = 1; + Priority priority = 2; + string subject = 3; + string body = 4; + repeated string recipients = 5; + map metadata = 6; + google.protobuf.Timestamp scheduled_for = 7; + int32 max_retries = 8; +} + +// SendNotificationResponse returns the result of sending a notification +message SendNotificationResponse { + NotificationResult result = 1; +} + +// SendBatchNotificationsRequest sends multiple notifications +message SendBatchNotificationsRequest { + repeated SendNotificationRequest notifications = 1; +} + +// SendBatchNotificationsResponse returns the results of sending multiple notifications +message SendBatchNotificationsResponse { + repeated NotificationResult results = 1; +} + +// GetNotificationRequest retrieves a notification by ID +message GetNotificationRequest { + string id = 1; +} + +// GetNotificationResponse returns a notification +message GetNotificationResponse { + Notification notification = 1; +} + +// NotificationFilter is used for querying notifications +message NotificationFilter { + repeated string ids = 1; + repeated NotificationType types = 2; + repeated NotificationStatus statuses = 3; + repeated string recipients = 4; + google.protobuf.Timestamp created_after = 5; + google.protobuf.Timestamp created_before = 6; + int32 limit = 7; + int32 offset = 8; +} + +// ListNotificationsRequest retrieves notifications matching a filter +message ListNotificationsRequest { + NotificationFilter filter = 1; +} + +// ListNotificationsResponse returns a list of notifications +message ListNotificationsResponse { + repeated Notification notifications = 1; + int64 total = 2; +} + +// CancelNotificationRequest cancels a pending notification +message CancelNotificationRequest { + string id = 1; +} + +// CancelNotificationResponse returns the result of canceling a notification +message CancelNotificationResponse { + bool success = 1; + string message = 2; +} + +// RetryNotificationRequest retries a failed notification +message RetryNotificationRequest { + string id = 1; +} + +// RetryNotificationResponse returns the result of retrying a notification +message RetryNotificationResponse { + NotificationResult result = 1; +} + +// GetStatsRequest requests notification statistics +message GetStatsRequest {} + +// GetStatsResponse returns notification statistics +message GetStatsResponse { + int64 total_sent = 1; + int64 total_failed = 2; + int64 total_pending = 3; + int64 total_queued = 4; + map by_type = 5; + map by_status = 6; + double average_latency_ms = 7; +} + +// HealthCheckRequest requests health status +message HealthCheckRequest {} + +// HealthCheckResponse returns health status +message HealthCheckResponse { + bool healthy = 1; + string status = 2; + map components = 3; +} diff --git a/api/grpc/pb/api/grpc/notifier.pb.go b/api/grpc/pb/api/grpc/notifier.pb.go new file mode 100644 index 0000000..7ca86da --- /dev/null +++ b/api/grpc/pb/api/grpc/notifier.pb.go @@ -0,0 +1,1639 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.10 +// protoc v6.33.0 +// source: api/grpc/notifier.proto + +package pb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// NotificationType defines the channel for notification delivery +type NotificationType int32 + +const ( + NotificationType_NOTIFICATION_TYPE_UNSPECIFIED NotificationType = 0 + NotificationType_NOTIFICATION_TYPE_EMAIL NotificationType = 1 + NotificationType_NOTIFICATION_TYPE_SLACK NotificationType = 2 + NotificationType_NOTIFICATION_TYPE_NTFY NotificationType = 3 + NotificationType_NOTIFICATION_TYPE_STDOUT NotificationType = 4 +) + +// Enum value maps for NotificationType. +var ( + NotificationType_name = map[int32]string{ + 0: "NOTIFICATION_TYPE_UNSPECIFIED", + 1: "NOTIFICATION_TYPE_EMAIL", + 2: "NOTIFICATION_TYPE_SLACK", + 3: "NOTIFICATION_TYPE_NTFY", + 4: "NOTIFICATION_TYPE_STDOUT", + } + NotificationType_value = map[string]int32{ + "NOTIFICATION_TYPE_UNSPECIFIED": 0, + "NOTIFICATION_TYPE_EMAIL": 1, + "NOTIFICATION_TYPE_SLACK": 2, + "NOTIFICATION_TYPE_NTFY": 3, + "NOTIFICATION_TYPE_STDOUT": 4, + } +) + +func (x NotificationType) Enum() *NotificationType { + p := new(NotificationType) + *p = x + return p +} + +func (x NotificationType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (NotificationType) Descriptor() protoreflect.EnumDescriptor { + return file_api_grpc_notifier_proto_enumTypes[0].Descriptor() +} + +func (NotificationType) Type() protoreflect.EnumType { + return &file_api_grpc_notifier_proto_enumTypes[0] +} + +func (x NotificationType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use NotificationType.Descriptor instead. +func (NotificationType) EnumDescriptor() ([]byte, []int) { + return file_api_grpc_notifier_proto_rawDescGZIP(), []int{0} +} + +// Priority defines the urgency level +type Priority int32 + +const ( + Priority_PRIORITY_UNSPECIFIED Priority = 0 + Priority_PRIORITY_LOW Priority = 1 + Priority_PRIORITY_NORMAL Priority = 2 + Priority_PRIORITY_HIGH Priority = 3 + Priority_PRIORITY_CRITICAL Priority = 4 +) + +// Enum value maps for Priority. +var ( + Priority_name = map[int32]string{ + 0: "PRIORITY_UNSPECIFIED", + 1: "PRIORITY_LOW", + 2: "PRIORITY_NORMAL", + 3: "PRIORITY_HIGH", + 4: "PRIORITY_CRITICAL", + } + Priority_value = map[string]int32{ + "PRIORITY_UNSPECIFIED": 0, + "PRIORITY_LOW": 1, + "PRIORITY_NORMAL": 2, + "PRIORITY_HIGH": 3, + "PRIORITY_CRITICAL": 4, + } +) + +func (x Priority) Enum() *Priority { + p := new(Priority) + *p = x + return p +} + +func (x Priority) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Priority) Descriptor() protoreflect.EnumDescriptor { + return file_api_grpc_notifier_proto_enumTypes[1].Descriptor() +} + +func (Priority) Type() protoreflect.EnumType { + return &file_api_grpc_notifier_proto_enumTypes[1] +} + +func (x Priority) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Priority.Descriptor instead. +func (Priority) EnumDescriptor() ([]byte, []int) { + return file_api_grpc_notifier_proto_rawDescGZIP(), []int{1} +} + +// NotificationStatus represents the state of a notification +type NotificationStatus int32 + +const ( + NotificationStatus_NOTIFICATION_STATUS_UNSPECIFIED NotificationStatus = 0 + NotificationStatus_NOTIFICATION_STATUS_PENDING NotificationStatus = 1 + NotificationStatus_NOTIFICATION_STATUS_QUEUED NotificationStatus = 2 + NotificationStatus_NOTIFICATION_STATUS_PROCESSING NotificationStatus = 3 + NotificationStatus_NOTIFICATION_STATUS_SENT NotificationStatus = 4 + NotificationStatus_NOTIFICATION_STATUS_FAILED NotificationStatus = 5 + NotificationStatus_NOTIFICATION_STATUS_RETRYING NotificationStatus = 6 +) + +// Enum value maps for NotificationStatus. +var ( + NotificationStatus_name = map[int32]string{ + 0: "NOTIFICATION_STATUS_UNSPECIFIED", + 1: "NOTIFICATION_STATUS_PENDING", + 2: "NOTIFICATION_STATUS_QUEUED", + 3: "NOTIFICATION_STATUS_PROCESSING", + 4: "NOTIFICATION_STATUS_SENT", + 5: "NOTIFICATION_STATUS_FAILED", + 6: "NOTIFICATION_STATUS_RETRYING", + } + NotificationStatus_value = map[string]int32{ + "NOTIFICATION_STATUS_UNSPECIFIED": 0, + "NOTIFICATION_STATUS_PENDING": 1, + "NOTIFICATION_STATUS_QUEUED": 2, + "NOTIFICATION_STATUS_PROCESSING": 3, + "NOTIFICATION_STATUS_SENT": 4, + "NOTIFICATION_STATUS_FAILED": 5, + "NOTIFICATION_STATUS_RETRYING": 6, + } +) + +func (x NotificationStatus) Enum() *NotificationStatus { + p := new(NotificationStatus) + *p = x + return p +} + +func (x NotificationStatus) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (NotificationStatus) Descriptor() protoreflect.EnumDescriptor { + return file_api_grpc_notifier_proto_enumTypes[2].Descriptor() +} + +func (NotificationStatus) Type() protoreflect.EnumType { + return &file_api_grpc_notifier_proto_enumTypes[2] +} + +func (x NotificationStatus) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use NotificationStatus.Descriptor instead. +func (NotificationStatus) EnumDescriptor() ([]byte, []int) { + return file_api_grpc_notifier_proto_rawDescGZIP(), []int{2} +} + +// Notification represents a notification message +type Notification struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Type NotificationType `protobuf:"varint,2,opt,name=type,proto3,enum=notifier.v1.NotificationType" json:"type,omitempty"` + Priority Priority `protobuf:"varint,3,opt,name=priority,proto3,enum=notifier.v1.Priority" json:"priority,omitempty"` + Status NotificationStatus `protobuf:"varint,4,opt,name=status,proto3,enum=notifier.v1.NotificationStatus" json:"status,omitempty"` + Subject string `protobuf:"bytes,5,opt,name=subject,proto3" json:"subject,omitempty"` + Body string `protobuf:"bytes,6,opt,name=body,proto3" json:"body,omitempty"` + Recipients []string `protobuf:"bytes,7,rep,name=recipients,proto3" json:"recipients,omitempty"` + Metadata map[string]string `protobuf:"bytes,8,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + CreatedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + ScheduledFor *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=scheduled_for,json=scheduledFor,proto3" json:"scheduled_for,omitempty"` + SentAt *timestamppb.Timestamp `protobuf:"bytes,11,opt,name=sent_at,json=sentAt,proto3" json:"sent_at,omitempty"` + RetryCount int32 `protobuf:"varint,12,opt,name=retry_count,json=retryCount,proto3" json:"retry_count,omitempty"` + MaxRetries int32 `protobuf:"varint,13,opt,name=max_retries,json=maxRetries,proto3" json:"max_retries,omitempty"` + LastError string `protobuf:"bytes,14,opt,name=last_error,json=lastError,proto3" json:"last_error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Notification) Reset() { + *x = Notification{} + mi := &file_api_grpc_notifier_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Notification) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Notification) ProtoMessage() {} + +func (x *Notification) ProtoReflect() protoreflect.Message { + mi := &file_api_grpc_notifier_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Notification.ProtoReflect.Descriptor instead. +func (*Notification) Descriptor() ([]byte, []int) { + return file_api_grpc_notifier_proto_rawDescGZIP(), []int{0} +} + +func (x *Notification) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Notification) GetType() NotificationType { + if x != nil { + return x.Type + } + return NotificationType_NOTIFICATION_TYPE_UNSPECIFIED +} + +func (x *Notification) GetPriority() Priority { + if x != nil { + return x.Priority + } + return Priority_PRIORITY_UNSPECIFIED +} + +func (x *Notification) GetStatus() NotificationStatus { + if x != nil { + return x.Status + } + return NotificationStatus_NOTIFICATION_STATUS_UNSPECIFIED +} + +func (x *Notification) GetSubject() string { + if x != nil { + return x.Subject + } + return "" +} + +func (x *Notification) GetBody() string { + if x != nil { + return x.Body + } + return "" +} + +func (x *Notification) GetRecipients() []string { + if x != nil { + return x.Recipients + } + return nil +} + +func (x *Notification) GetMetadata() map[string]string { + if x != nil { + return x.Metadata + } + return nil +} + +func (x *Notification) GetCreatedAt() *timestamppb.Timestamp { + if x != nil { + return x.CreatedAt + } + return nil +} + +func (x *Notification) GetScheduledFor() *timestamppb.Timestamp { + if x != nil { + return x.ScheduledFor + } + return nil +} + +func (x *Notification) GetSentAt() *timestamppb.Timestamp { + if x != nil { + return x.SentAt + } + return nil +} + +func (x *Notification) GetRetryCount() int32 { + if x != nil { + return x.RetryCount + } + return 0 +} + +func (x *Notification) GetMaxRetries() int32 { + if x != nil { + return x.MaxRetries + } + return 0 +} + +func (x *Notification) GetLastError() string { + if x != nil { + return x.LastError + } + return "" +} + +// NotificationResult represents the outcome of sending a notification +type NotificationResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + NotificationId string `protobuf:"bytes,1,opt,name=notification_id,json=notificationId,proto3" json:"notification_id,omitempty"` + Success bool `protobuf:"varint,2,opt,name=success,proto3" json:"success,omitempty"` + Message string `protobuf:"bytes,3,opt,name=message,proto3" json:"message,omitempty"` + Error string `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"` + SentAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=sent_at,json=sentAt,proto3" json:"sent_at,omitempty"` + ProviderResponse map[string]string `protobuf:"bytes,6,rep,name=provider_response,json=providerResponse,proto3" json:"provider_response,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NotificationResult) Reset() { + *x = NotificationResult{} + mi := &file_api_grpc_notifier_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NotificationResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NotificationResult) ProtoMessage() {} + +func (x *NotificationResult) ProtoReflect() protoreflect.Message { + mi := &file_api_grpc_notifier_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NotificationResult.ProtoReflect.Descriptor instead. +func (*NotificationResult) Descriptor() ([]byte, []int) { + return file_api_grpc_notifier_proto_rawDescGZIP(), []int{1} +} + +func (x *NotificationResult) GetNotificationId() string { + if x != nil { + return x.NotificationId + } + return "" +} + +func (x *NotificationResult) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +func (x *NotificationResult) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *NotificationResult) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *NotificationResult) GetSentAt() *timestamppb.Timestamp { + if x != nil { + return x.SentAt + } + return nil +} + +func (x *NotificationResult) GetProviderResponse() map[string]string { + if x != nil { + return x.ProviderResponse + } + return nil +} + +// SendNotificationRequest sends a single notification +type SendNotificationRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Type NotificationType `protobuf:"varint,1,opt,name=type,proto3,enum=notifier.v1.NotificationType" json:"type,omitempty"` + Priority Priority `protobuf:"varint,2,opt,name=priority,proto3,enum=notifier.v1.Priority" json:"priority,omitempty"` + Subject string `protobuf:"bytes,3,opt,name=subject,proto3" json:"subject,omitempty"` + Body string `protobuf:"bytes,4,opt,name=body,proto3" json:"body,omitempty"` + Recipients []string `protobuf:"bytes,5,rep,name=recipients,proto3" json:"recipients,omitempty"` + Metadata map[string]string `protobuf:"bytes,6,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + ScheduledFor *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=scheduled_for,json=scheduledFor,proto3" json:"scheduled_for,omitempty"` + MaxRetries int32 `protobuf:"varint,8,opt,name=max_retries,json=maxRetries,proto3" json:"max_retries,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendNotificationRequest) Reset() { + *x = SendNotificationRequest{} + mi := &file_api_grpc_notifier_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendNotificationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendNotificationRequest) ProtoMessage() {} + +func (x *SendNotificationRequest) ProtoReflect() protoreflect.Message { + mi := &file_api_grpc_notifier_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendNotificationRequest.ProtoReflect.Descriptor instead. +func (*SendNotificationRequest) Descriptor() ([]byte, []int) { + return file_api_grpc_notifier_proto_rawDescGZIP(), []int{2} +} + +func (x *SendNotificationRequest) GetType() NotificationType { + if x != nil { + return x.Type + } + return NotificationType_NOTIFICATION_TYPE_UNSPECIFIED +} + +func (x *SendNotificationRequest) GetPriority() Priority { + if x != nil { + return x.Priority + } + return Priority_PRIORITY_UNSPECIFIED +} + +func (x *SendNotificationRequest) GetSubject() string { + if x != nil { + return x.Subject + } + return "" +} + +func (x *SendNotificationRequest) GetBody() string { + if x != nil { + return x.Body + } + return "" +} + +func (x *SendNotificationRequest) GetRecipients() []string { + if x != nil { + return x.Recipients + } + return nil +} + +func (x *SendNotificationRequest) GetMetadata() map[string]string { + if x != nil { + return x.Metadata + } + return nil +} + +func (x *SendNotificationRequest) GetScheduledFor() *timestamppb.Timestamp { + if x != nil { + return x.ScheduledFor + } + return nil +} + +func (x *SendNotificationRequest) GetMaxRetries() int32 { + if x != nil { + return x.MaxRetries + } + return 0 +} + +// SendNotificationResponse returns the result of sending a notification +type SendNotificationResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Result *NotificationResult `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendNotificationResponse) Reset() { + *x = SendNotificationResponse{} + mi := &file_api_grpc_notifier_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendNotificationResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendNotificationResponse) ProtoMessage() {} + +func (x *SendNotificationResponse) ProtoReflect() protoreflect.Message { + mi := &file_api_grpc_notifier_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendNotificationResponse.ProtoReflect.Descriptor instead. +func (*SendNotificationResponse) Descriptor() ([]byte, []int) { + return file_api_grpc_notifier_proto_rawDescGZIP(), []int{3} +} + +func (x *SendNotificationResponse) GetResult() *NotificationResult { + if x != nil { + return x.Result + } + return nil +} + +// SendBatchNotificationsRequest sends multiple notifications +type SendBatchNotificationsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Notifications []*SendNotificationRequest `protobuf:"bytes,1,rep,name=notifications,proto3" json:"notifications,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendBatchNotificationsRequest) Reset() { + *x = SendBatchNotificationsRequest{} + mi := &file_api_grpc_notifier_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendBatchNotificationsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendBatchNotificationsRequest) ProtoMessage() {} + +func (x *SendBatchNotificationsRequest) ProtoReflect() protoreflect.Message { + mi := &file_api_grpc_notifier_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendBatchNotificationsRequest.ProtoReflect.Descriptor instead. +func (*SendBatchNotificationsRequest) Descriptor() ([]byte, []int) { + return file_api_grpc_notifier_proto_rawDescGZIP(), []int{4} +} + +func (x *SendBatchNotificationsRequest) GetNotifications() []*SendNotificationRequest { + if x != nil { + return x.Notifications + } + return nil +} + +// SendBatchNotificationsResponse returns the results of sending multiple notifications +type SendBatchNotificationsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Results []*NotificationResult `protobuf:"bytes,1,rep,name=results,proto3" json:"results,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendBatchNotificationsResponse) Reset() { + *x = SendBatchNotificationsResponse{} + mi := &file_api_grpc_notifier_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendBatchNotificationsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendBatchNotificationsResponse) ProtoMessage() {} + +func (x *SendBatchNotificationsResponse) ProtoReflect() protoreflect.Message { + mi := &file_api_grpc_notifier_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendBatchNotificationsResponse.ProtoReflect.Descriptor instead. +func (*SendBatchNotificationsResponse) Descriptor() ([]byte, []int) { + return file_api_grpc_notifier_proto_rawDescGZIP(), []int{5} +} + +func (x *SendBatchNotificationsResponse) GetResults() []*NotificationResult { + if x != nil { + return x.Results + } + return nil +} + +// GetNotificationRequest retrieves a notification by ID +type GetNotificationRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetNotificationRequest) Reset() { + *x = GetNotificationRequest{} + mi := &file_api_grpc_notifier_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetNotificationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetNotificationRequest) ProtoMessage() {} + +func (x *GetNotificationRequest) ProtoReflect() protoreflect.Message { + mi := &file_api_grpc_notifier_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetNotificationRequest.ProtoReflect.Descriptor instead. +func (*GetNotificationRequest) Descriptor() ([]byte, []int) { + return file_api_grpc_notifier_proto_rawDescGZIP(), []int{6} +} + +func (x *GetNotificationRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +// GetNotificationResponse returns a notification +type GetNotificationResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Notification *Notification `protobuf:"bytes,1,opt,name=notification,proto3" json:"notification,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetNotificationResponse) Reset() { + *x = GetNotificationResponse{} + mi := &file_api_grpc_notifier_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetNotificationResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetNotificationResponse) ProtoMessage() {} + +func (x *GetNotificationResponse) ProtoReflect() protoreflect.Message { + mi := &file_api_grpc_notifier_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetNotificationResponse.ProtoReflect.Descriptor instead. +func (*GetNotificationResponse) Descriptor() ([]byte, []int) { + return file_api_grpc_notifier_proto_rawDescGZIP(), []int{7} +} + +func (x *GetNotificationResponse) GetNotification() *Notification { + if x != nil { + return x.Notification + } + return nil +} + +// NotificationFilter is used for querying notifications +type NotificationFilter struct { + state protoimpl.MessageState `protogen:"open.v1"` + Ids []string `protobuf:"bytes,1,rep,name=ids,proto3" json:"ids,omitempty"` + Types []NotificationType `protobuf:"varint,2,rep,packed,name=types,proto3,enum=notifier.v1.NotificationType" json:"types,omitempty"` + Statuses []NotificationStatus `protobuf:"varint,3,rep,packed,name=statuses,proto3,enum=notifier.v1.NotificationStatus" json:"statuses,omitempty"` + Recipients []string `protobuf:"bytes,4,rep,name=recipients,proto3" json:"recipients,omitempty"` + CreatedAfter *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=created_after,json=createdAfter,proto3" json:"created_after,omitempty"` + CreatedBefore *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=created_before,json=createdBefore,proto3" json:"created_before,omitempty"` + Limit int32 `protobuf:"varint,7,opt,name=limit,proto3" json:"limit,omitempty"` + Offset int32 `protobuf:"varint,8,opt,name=offset,proto3" json:"offset,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NotificationFilter) Reset() { + *x = NotificationFilter{} + mi := &file_api_grpc_notifier_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NotificationFilter) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NotificationFilter) ProtoMessage() {} + +func (x *NotificationFilter) ProtoReflect() protoreflect.Message { + mi := &file_api_grpc_notifier_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NotificationFilter.ProtoReflect.Descriptor instead. +func (*NotificationFilter) Descriptor() ([]byte, []int) { + return file_api_grpc_notifier_proto_rawDescGZIP(), []int{8} +} + +func (x *NotificationFilter) GetIds() []string { + if x != nil { + return x.Ids + } + return nil +} + +func (x *NotificationFilter) GetTypes() []NotificationType { + if x != nil { + return x.Types + } + return nil +} + +func (x *NotificationFilter) GetStatuses() []NotificationStatus { + if x != nil { + return x.Statuses + } + return nil +} + +func (x *NotificationFilter) GetRecipients() []string { + if x != nil { + return x.Recipients + } + return nil +} + +func (x *NotificationFilter) GetCreatedAfter() *timestamppb.Timestamp { + if x != nil { + return x.CreatedAfter + } + return nil +} + +func (x *NotificationFilter) GetCreatedBefore() *timestamppb.Timestamp { + if x != nil { + return x.CreatedBefore + } + return nil +} + +func (x *NotificationFilter) GetLimit() int32 { + if x != nil { + return x.Limit + } + return 0 +} + +func (x *NotificationFilter) GetOffset() int32 { + if x != nil { + return x.Offset + } + return 0 +} + +// ListNotificationsRequest retrieves notifications matching a filter +type ListNotificationsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Filter *NotificationFilter `protobuf:"bytes,1,opt,name=filter,proto3" json:"filter,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListNotificationsRequest) Reset() { + *x = ListNotificationsRequest{} + mi := &file_api_grpc_notifier_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListNotificationsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListNotificationsRequest) ProtoMessage() {} + +func (x *ListNotificationsRequest) ProtoReflect() protoreflect.Message { + mi := &file_api_grpc_notifier_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListNotificationsRequest.ProtoReflect.Descriptor instead. +func (*ListNotificationsRequest) Descriptor() ([]byte, []int) { + return file_api_grpc_notifier_proto_rawDescGZIP(), []int{9} +} + +func (x *ListNotificationsRequest) GetFilter() *NotificationFilter { + if x != nil { + return x.Filter + } + return nil +} + +// ListNotificationsResponse returns a list of notifications +type ListNotificationsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Notifications []*Notification `protobuf:"bytes,1,rep,name=notifications,proto3" json:"notifications,omitempty"` + Total int64 `protobuf:"varint,2,opt,name=total,proto3" json:"total,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListNotificationsResponse) Reset() { + *x = ListNotificationsResponse{} + mi := &file_api_grpc_notifier_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListNotificationsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListNotificationsResponse) ProtoMessage() {} + +func (x *ListNotificationsResponse) ProtoReflect() protoreflect.Message { + mi := &file_api_grpc_notifier_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListNotificationsResponse.ProtoReflect.Descriptor instead. +func (*ListNotificationsResponse) Descriptor() ([]byte, []int) { + return file_api_grpc_notifier_proto_rawDescGZIP(), []int{10} +} + +func (x *ListNotificationsResponse) GetNotifications() []*Notification { + if x != nil { + return x.Notifications + } + return nil +} + +func (x *ListNotificationsResponse) GetTotal() int64 { + if x != nil { + return x.Total + } + return 0 +} + +// CancelNotificationRequest cancels a pending notification +type CancelNotificationRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CancelNotificationRequest) Reset() { + *x = CancelNotificationRequest{} + mi := &file_api_grpc_notifier_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CancelNotificationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CancelNotificationRequest) ProtoMessage() {} + +func (x *CancelNotificationRequest) ProtoReflect() protoreflect.Message { + mi := &file_api_grpc_notifier_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CancelNotificationRequest.ProtoReflect.Descriptor instead. +func (*CancelNotificationRequest) Descriptor() ([]byte, []int) { + return file_api_grpc_notifier_proto_rawDescGZIP(), []int{11} +} + +func (x *CancelNotificationRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +// CancelNotificationResponse returns the result of canceling a notification +type CancelNotificationResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CancelNotificationResponse) Reset() { + *x = CancelNotificationResponse{} + mi := &file_api_grpc_notifier_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CancelNotificationResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CancelNotificationResponse) ProtoMessage() {} + +func (x *CancelNotificationResponse) ProtoReflect() protoreflect.Message { + mi := &file_api_grpc_notifier_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CancelNotificationResponse.ProtoReflect.Descriptor instead. +func (*CancelNotificationResponse) Descriptor() ([]byte, []int) { + return file_api_grpc_notifier_proto_rawDescGZIP(), []int{12} +} + +func (x *CancelNotificationResponse) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +func (x *CancelNotificationResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +// RetryNotificationRequest retries a failed notification +type RetryNotificationRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RetryNotificationRequest) Reset() { + *x = RetryNotificationRequest{} + mi := &file_api_grpc_notifier_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RetryNotificationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RetryNotificationRequest) ProtoMessage() {} + +func (x *RetryNotificationRequest) ProtoReflect() protoreflect.Message { + mi := &file_api_grpc_notifier_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RetryNotificationRequest.ProtoReflect.Descriptor instead. +func (*RetryNotificationRequest) Descriptor() ([]byte, []int) { + return file_api_grpc_notifier_proto_rawDescGZIP(), []int{13} +} + +func (x *RetryNotificationRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +// RetryNotificationResponse returns the result of retrying a notification +type RetryNotificationResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Result *NotificationResult `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RetryNotificationResponse) Reset() { + *x = RetryNotificationResponse{} + mi := &file_api_grpc_notifier_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RetryNotificationResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RetryNotificationResponse) ProtoMessage() {} + +func (x *RetryNotificationResponse) ProtoReflect() protoreflect.Message { + mi := &file_api_grpc_notifier_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RetryNotificationResponse.ProtoReflect.Descriptor instead. +func (*RetryNotificationResponse) Descriptor() ([]byte, []int) { + return file_api_grpc_notifier_proto_rawDescGZIP(), []int{14} +} + +func (x *RetryNotificationResponse) GetResult() *NotificationResult { + if x != nil { + return x.Result + } + return nil +} + +// GetStatsRequest requests notification statistics +type GetStatsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetStatsRequest) Reset() { + *x = GetStatsRequest{} + mi := &file_api_grpc_notifier_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetStatsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetStatsRequest) ProtoMessage() {} + +func (x *GetStatsRequest) ProtoReflect() protoreflect.Message { + mi := &file_api_grpc_notifier_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetStatsRequest.ProtoReflect.Descriptor instead. +func (*GetStatsRequest) Descriptor() ([]byte, []int) { + return file_api_grpc_notifier_proto_rawDescGZIP(), []int{15} +} + +// GetStatsResponse returns notification statistics +type GetStatsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + TotalSent int64 `protobuf:"varint,1,opt,name=total_sent,json=totalSent,proto3" json:"total_sent,omitempty"` + TotalFailed int64 `protobuf:"varint,2,opt,name=total_failed,json=totalFailed,proto3" json:"total_failed,omitempty"` + TotalPending int64 `protobuf:"varint,3,opt,name=total_pending,json=totalPending,proto3" json:"total_pending,omitempty"` + TotalQueued int64 `protobuf:"varint,4,opt,name=total_queued,json=totalQueued,proto3" json:"total_queued,omitempty"` + ByType map[string]int64 `protobuf:"bytes,5,rep,name=by_type,json=byType,proto3" json:"by_type,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"varint,2,opt,name=value"` + ByStatus map[string]int64 `protobuf:"bytes,6,rep,name=by_status,json=byStatus,proto3" json:"by_status,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"varint,2,opt,name=value"` + AverageLatencyMs float64 `protobuf:"fixed64,7,opt,name=average_latency_ms,json=averageLatencyMs,proto3" json:"average_latency_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetStatsResponse) Reset() { + *x = GetStatsResponse{} + mi := &file_api_grpc_notifier_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetStatsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetStatsResponse) ProtoMessage() {} + +func (x *GetStatsResponse) ProtoReflect() protoreflect.Message { + mi := &file_api_grpc_notifier_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetStatsResponse.ProtoReflect.Descriptor instead. +func (*GetStatsResponse) Descriptor() ([]byte, []int) { + return file_api_grpc_notifier_proto_rawDescGZIP(), []int{16} +} + +func (x *GetStatsResponse) GetTotalSent() int64 { + if x != nil { + return x.TotalSent + } + return 0 +} + +func (x *GetStatsResponse) GetTotalFailed() int64 { + if x != nil { + return x.TotalFailed + } + return 0 +} + +func (x *GetStatsResponse) GetTotalPending() int64 { + if x != nil { + return x.TotalPending + } + return 0 +} + +func (x *GetStatsResponse) GetTotalQueued() int64 { + if x != nil { + return x.TotalQueued + } + return 0 +} + +func (x *GetStatsResponse) GetByType() map[string]int64 { + if x != nil { + return x.ByType + } + return nil +} + +func (x *GetStatsResponse) GetByStatus() map[string]int64 { + if x != nil { + return x.ByStatus + } + return nil +} + +func (x *GetStatsResponse) GetAverageLatencyMs() float64 { + if x != nil { + return x.AverageLatencyMs + } + return 0 +} + +// HealthCheckRequest requests health status +type HealthCheckRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HealthCheckRequest) Reset() { + *x = HealthCheckRequest{} + mi := &file_api_grpc_notifier_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HealthCheckRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HealthCheckRequest) ProtoMessage() {} + +func (x *HealthCheckRequest) ProtoReflect() protoreflect.Message { + mi := &file_api_grpc_notifier_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HealthCheckRequest.ProtoReflect.Descriptor instead. +func (*HealthCheckRequest) Descriptor() ([]byte, []int) { + return file_api_grpc_notifier_proto_rawDescGZIP(), []int{17} +} + +// HealthCheckResponse returns health status +type HealthCheckResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Healthy bool `protobuf:"varint,1,opt,name=healthy,proto3" json:"healthy,omitempty"` + Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` + Components map[string]string `protobuf:"bytes,3,rep,name=components,proto3" json:"components,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HealthCheckResponse) Reset() { + *x = HealthCheckResponse{} + mi := &file_api_grpc_notifier_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HealthCheckResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HealthCheckResponse) ProtoMessage() {} + +func (x *HealthCheckResponse) ProtoReflect() protoreflect.Message { + mi := &file_api_grpc_notifier_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HealthCheckResponse.ProtoReflect.Descriptor instead. +func (*HealthCheckResponse) Descriptor() ([]byte, []int) { + return file_api_grpc_notifier_proto_rawDescGZIP(), []int{18} +} + +func (x *HealthCheckResponse) GetHealthy() bool { + if x != nil { + return x.Healthy + } + return false +} + +func (x *HealthCheckResponse) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *HealthCheckResponse) GetComponents() map[string]string { + if x != nil { + return x.Components + } + return nil +} + +var File_api_grpc_notifier_proto protoreflect.FileDescriptor + +const file_api_grpc_notifier_proto_rawDesc = "" + + "\n" + + "\x17api/grpc/notifier.proto\x12\vnotifier.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\x9f\x05\n" + + "\fNotification\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x121\n" + + "\x04type\x18\x02 \x01(\x0e2\x1d.notifier.v1.NotificationTypeR\x04type\x121\n" + + "\bpriority\x18\x03 \x01(\x0e2\x15.notifier.v1.PriorityR\bpriority\x127\n" + + "\x06status\x18\x04 \x01(\x0e2\x1f.notifier.v1.NotificationStatusR\x06status\x12\x18\n" + + "\asubject\x18\x05 \x01(\tR\asubject\x12\x12\n" + + "\x04body\x18\x06 \x01(\tR\x04body\x12\x1e\n" + + "\n" + + "recipients\x18\a \x03(\tR\n" + + "recipients\x12C\n" + + "\bmetadata\x18\b \x03(\v2'.notifier.v1.Notification.MetadataEntryR\bmetadata\x129\n" + + "\n" + + "created_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12?\n" + + "\rscheduled_for\x18\n" + + " \x01(\v2\x1a.google.protobuf.TimestampR\fscheduledFor\x123\n" + + "\asent_at\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\x06sentAt\x12\x1f\n" + + "\vretry_count\x18\f \x01(\x05R\n" + + "retryCount\x12\x1f\n" + + "\vmax_retries\x18\r \x01(\x05R\n" + + "maxRetries\x12\x1d\n" + + "\n" + + "last_error\x18\x0e \x01(\tR\tlastError\x1a;\n" + + "\rMetadataEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xe5\x02\n" + + "\x12NotificationResult\x12'\n" + + "\x0fnotification_id\x18\x01 \x01(\tR\x0enotificationId\x12\x18\n" + + "\asuccess\x18\x02 \x01(\bR\asuccess\x12\x18\n" + + "\amessage\x18\x03 \x01(\tR\amessage\x12\x14\n" + + "\x05error\x18\x04 \x01(\tR\x05error\x123\n" + + "\asent_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\x06sentAt\x12b\n" + + "\x11provider_response\x18\x06 \x03(\v25.notifier.v1.NotificationResult.ProviderResponseEntryR\x10providerResponse\x1aC\n" + + "\x15ProviderResponseEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xbc\x03\n" + + "\x17SendNotificationRequest\x121\n" + + "\x04type\x18\x01 \x01(\x0e2\x1d.notifier.v1.NotificationTypeR\x04type\x121\n" + + "\bpriority\x18\x02 \x01(\x0e2\x15.notifier.v1.PriorityR\bpriority\x12\x18\n" + + "\asubject\x18\x03 \x01(\tR\asubject\x12\x12\n" + + "\x04body\x18\x04 \x01(\tR\x04body\x12\x1e\n" + + "\n" + + "recipients\x18\x05 \x03(\tR\n" + + "recipients\x12N\n" + + "\bmetadata\x18\x06 \x03(\v22.notifier.v1.SendNotificationRequest.MetadataEntryR\bmetadata\x12?\n" + + "\rscheduled_for\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\fscheduledFor\x12\x1f\n" + + "\vmax_retries\x18\b \x01(\x05R\n" + + "maxRetries\x1a;\n" + + "\rMetadataEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"S\n" + + "\x18SendNotificationResponse\x127\n" + + "\x06result\x18\x01 \x01(\v2\x1f.notifier.v1.NotificationResultR\x06result\"k\n" + + "\x1dSendBatchNotificationsRequest\x12J\n" + + "\rnotifications\x18\x01 \x03(\v2$.notifier.v1.SendNotificationRequestR\rnotifications\"[\n" + + "\x1eSendBatchNotificationsResponse\x129\n" + + "\aresults\x18\x01 \x03(\v2\x1f.notifier.v1.NotificationResultR\aresults\"(\n" + + "\x16GetNotificationRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"X\n" + + "\x17GetNotificationResponse\x12=\n" + + "\fnotification\x18\x01 \x01(\v2\x19.notifier.v1.NotificationR\fnotification\"\xea\x02\n" + + "\x12NotificationFilter\x12\x10\n" + + "\x03ids\x18\x01 \x03(\tR\x03ids\x123\n" + + "\x05types\x18\x02 \x03(\x0e2\x1d.notifier.v1.NotificationTypeR\x05types\x12;\n" + + "\bstatuses\x18\x03 \x03(\x0e2\x1f.notifier.v1.NotificationStatusR\bstatuses\x12\x1e\n" + + "\n" + + "recipients\x18\x04 \x03(\tR\n" + + "recipients\x12?\n" + + "\rcreated_after\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\fcreatedAfter\x12A\n" + + "\x0ecreated_before\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\rcreatedBefore\x12\x14\n" + + "\x05limit\x18\a \x01(\x05R\x05limit\x12\x16\n" + + "\x06offset\x18\b \x01(\x05R\x06offset\"S\n" + + "\x18ListNotificationsRequest\x127\n" + + "\x06filter\x18\x01 \x01(\v2\x1f.notifier.v1.NotificationFilterR\x06filter\"r\n" + + "\x19ListNotificationsResponse\x12?\n" + + "\rnotifications\x18\x01 \x03(\v2\x19.notifier.v1.NotificationR\rnotifications\x12\x14\n" + + "\x05total\x18\x02 \x01(\x03R\x05total\"+\n" + + "\x19CancelNotificationRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"P\n" + + "\x1aCancelNotificationResponse\x12\x18\n" + + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\"*\n" + + "\x18RetryNotificationRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"T\n" + + "\x19RetryNotificationResponse\x127\n" + + "\x06result\x18\x01 \x01(\v2\x1f.notifier.v1.NotificationResultR\x06result\"\x11\n" + + "\x0fGetStatsRequest\"\xd0\x03\n" + + "\x10GetStatsResponse\x12\x1d\n" + + "\n" + + "total_sent\x18\x01 \x01(\x03R\ttotalSent\x12!\n" + + "\ftotal_failed\x18\x02 \x01(\x03R\vtotalFailed\x12#\n" + + "\rtotal_pending\x18\x03 \x01(\x03R\ftotalPending\x12!\n" + + "\ftotal_queued\x18\x04 \x01(\x03R\vtotalQueued\x12B\n" + + "\aby_type\x18\x05 \x03(\v2).notifier.v1.GetStatsResponse.ByTypeEntryR\x06byType\x12H\n" + + "\tby_status\x18\x06 \x03(\v2+.notifier.v1.GetStatsResponse.ByStatusEntryR\bbyStatus\x12,\n" + + "\x12average_latency_ms\x18\a \x01(\x01R\x10averageLatencyMs\x1a9\n" + + "\vByTypeEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\x03R\x05value:\x028\x01\x1a;\n" + + "\rByStatusEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\x03R\x05value:\x028\x01\"\x14\n" + + "\x12HealthCheckRequest\"\xd8\x01\n" + + "\x13HealthCheckResponse\x12\x18\n" + + "\ahealthy\x18\x01 \x01(\bR\ahealthy\x12\x16\n" + + "\x06status\x18\x02 \x01(\tR\x06status\x12P\n" + + "\n" + + "components\x18\x03 \x03(\v20.notifier.v1.HealthCheckResponse.ComponentsEntryR\n" + + "components\x1a=\n" + + "\x0fComponentsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01*\xa9\x01\n" + + "\x10NotificationType\x12!\n" + + "\x1dNOTIFICATION_TYPE_UNSPECIFIED\x10\x00\x12\x1b\n" + + "\x17NOTIFICATION_TYPE_EMAIL\x10\x01\x12\x1b\n" + + "\x17NOTIFICATION_TYPE_SLACK\x10\x02\x12\x1a\n" + + "\x16NOTIFICATION_TYPE_NTFY\x10\x03\x12\x1c\n" + + "\x18NOTIFICATION_TYPE_STDOUT\x10\x04*u\n" + + "\bPriority\x12\x18\n" + + "\x14PRIORITY_UNSPECIFIED\x10\x00\x12\x10\n" + + "\fPRIORITY_LOW\x10\x01\x12\x13\n" + + "\x0fPRIORITY_NORMAL\x10\x02\x12\x11\n" + + "\rPRIORITY_HIGH\x10\x03\x12\x15\n" + + "\x11PRIORITY_CRITICAL\x10\x04*\xfe\x01\n" + + "\x12NotificationStatus\x12#\n" + + "\x1fNOTIFICATION_STATUS_UNSPECIFIED\x10\x00\x12\x1f\n" + + "\x1bNOTIFICATION_STATUS_PENDING\x10\x01\x12\x1e\n" + + "\x1aNOTIFICATION_STATUS_QUEUED\x10\x02\x12\"\n" + + "\x1eNOTIFICATION_STATUS_PROCESSING\x10\x03\x12\x1c\n" + + "\x18NOTIFICATION_STATUS_SENT\x10\x04\x12\x1e\n" + + "\x1aNOTIFICATION_STATUS_FAILED\x10\x05\x12 \n" + + "\x1cNOTIFICATION_STATUS_RETRYING\x10\x062\x8d\x06\n" + + "\x0fNotifierService\x12_\n" + + "\x10SendNotification\x12$.notifier.v1.SendNotificationRequest\x1a%.notifier.v1.SendNotificationResponse\x12q\n" + + "\x16SendBatchNotifications\x12*.notifier.v1.SendBatchNotificationsRequest\x1a+.notifier.v1.SendBatchNotificationsResponse\x12\\\n" + + "\x0fGetNotification\x12#.notifier.v1.GetNotificationRequest\x1a$.notifier.v1.GetNotificationResponse\x12b\n" + + "\x11ListNotifications\x12%.notifier.v1.ListNotificationsRequest\x1a&.notifier.v1.ListNotificationsResponse\x12e\n" + + "\x12CancelNotification\x12&.notifier.v1.CancelNotificationRequest\x1a'.notifier.v1.CancelNotificationResponse\x12b\n" + + "\x11RetryNotification\x12%.notifier.v1.RetryNotificationRequest\x1a&.notifier.v1.RetryNotificationResponse\x12G\n" + + "\bGetStats\x12\x1c.notifier.v1.GetStatsRequest\x1a\x1d.notifier.v1.GetStatsResponse\x12P\n" + + "\vHealthCheck\x12\x1f.notifier.v1.HealthCheckRequest\x1a .notifier.v1.HealthCheckResponseB)Z'github.com/igodwin/notifier/api/grpc/pbb\x06proto3" + +var ( + file_api_grpc_notifier_proto_rawDescOnce sync.Once + file_api_grpc_notifier_proto_rawDescData []byte +) + +func file_api_grpc_notifier_proto_rawDescGZIP() []byte { + file_api_grpc_notifier_proto_rawDescOnce.Do(func() { + file_api_grpc_notifier_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_api_grpc_notifier_proto_rawDesc), len(file_api_grpc_notifier_proto_rawDesc))) + }) + return file_api_grpc_notifier_proto_rawDescData +} + +var file_api_grpc_notifier_proto_enumTypes = make([]protoimpl.EnumInfo, 3) +var file_api_grpc_notifier_proto_msgTypes = make([]protoimpl.MessageInfo, 25) +var file_api_grpc_notifier_proto_goTypes = []any{ + (NotificationType)(0), // 0: notifier.v1.NotificationType + (Priority)(0), // 1: notifier.v1.Priority + (NotificationStatus)(0), // 2: notifier.v1.NotificationStatus + (*Notification)(nil), // 3: notifier.v1.Notification + (*NotificationResult)(nil), // 4: notifier.v1.NotificationResult + (*SendNotificationRequest)(nil), // 5: notifier.v1.SendNotificationRequest + (*SendNotificationResponse)(nil), // 6: notifier.v1.SendNotificationResponse + (*SendBatchNotificationsRequest)(nil), // 7: notifier.v1.SendBatchNotificationsRequest + (*SendBatchNotificationsResponse)(nil), // 8: notifier.v1.SendBatchNotificationsResponse + (*GetNotificationRequest)(nil), // 9: notifier.v1.GetNotificationRequest + (*GetNotificationResponse)(nil), // 10: notifier.v1.GetNotificationResponse + (*NotificationFilter)(nil), // 11: notifier.v1.NotificationFilter + (*ListNotificationsRequest)(nil), // 12: notifier.v1.ListNotificationsRequest + (*ListNotificationsResponse)(nil), // 13: notifier.v1.ListNotificationsResponse + (*CancelNotificationRequest)(nil), // 14: notifier.v1.CancelNotificationRequest + (*CancelNotificationResponse)(nil), // 15: notifier.v1.CancelNotificationResponse + (*RetryNotificationRequest)(nil), // 16: notifier.v1.RetryNotificationRequest + (*RetryNotificationResponse)(nil), // 17: notifier.v1.RetryNotificationResponse + (*GetStatsRequest)(nil), // 18: notifier.v1.GetStatsRequest + (*GetStatsResponse)(nil), // 19: notifier.v1.GetStatsResponse + (*HealthCheckRequest)(nil), // 20: notifier.v1.HealthCheckRequest + (*HealthCheckResponse)(nil), // 21: notifier.v1.HealthCheckResponse + nil, // 22: notifier.v1.Notification.MetadataEntry + nil, // 23: notifier.v1.NotificationResult.ProviderResponseEntry + nil, // 24: notifier.v1.SendNotificationRequest.MetadataEntry + nil, // 25: notifier.v1.GetStatsResponse.ByTypeEntry + nil, // 26: notifier.v1.GetStatsResponse.ByStatusEntry + nil, // 27: notifier.v1.HealthCheckResponse.ComponentsEntry + (*timestamppb.Timestamp)(nil), // 28: google.protobuf.Timestamp +} +var file_api_grpc_notifier_proto_depIdxs = []int32{ + 0, // 0: notifier.v1.Notification.type:type_name -> notifier.v1.NotificationType + 1, // 1: notifier.v1.Notification.priority:type_name -> notifier.v1.Priority + 2, // 2: notifier.v1.Notification.status:type_name -> notifier.v1.NotificationStatus + 22, // 3: notifier.v1.Notification.metadata:type_name -> notifier.v1.Notification.MetadataEntry + 28, // 4: notifier.v1.Notification.created_at:type_name -> google.protobuf.Timestamp + 28, // 5: notifier.v1.Notification.scheduled_for:type_name -> google.protobuf.Timestamp + 28, // 6: notifier.v1.Notification.sent_at:type_name -> google.protobuf.Timestamp + 28, // 7: notifier.v1.NotificationResult.sent_at:type_name -> google.protobuf.Timestamp + 23, // 8: notifier.v1.NotificationResult.provider_response:type_name -> notifier.v1.NotificationResult.ProviderResponseEntry + 0, // 9: notifier.v1.SendNotificationRequest.type:type_name -> notifier.v1.NotificationType + 1, // 10: notifier.v1.SendNotificationRequest.priority:type_name -> notifier.v1.Priority + 24, // 11: notifier.v1.SendNotificationRequest.metadata:type_name -> notifier.v1.SendNotificationRequest.MetadataEntry + 28, // 12: notifier.v1.SendNotificationRequest.scheduled_for:type_name -> google.protobuf.Timestamp + 4, // 13: notifier.v1.SendNotificationResponse.result:type_name -> notifier.v1.NotificationResult + 5, // 14: notifier.v1.SendBatchNotificationsRequest.notifications:type_name -> notifier.v1.SendNotificationRequest + 4, // 15: notifier.v1.SendBatchNotificationsResponse.results:type_name -> notifier.v1.NotificationResult + 3, // 16: notifier.v1.GetNotificationResponse.notification:type_name -> notifier.v1.Notification + 0, // 17: notifier.v1.NotificationFilter.types:type_name -> notifier.v1.NotificationType + 2, // 18: notifier.v1.NotificationFilter.statuses:type_name -> notifier.v1.NotificationStatus + 28, // 19: notifier.v1.NotificationFilter.created_after:type_name -> google.protobuf.Timestamp + 28, // 20: notifier.v1.NotificationFilter.created_before:type_name -> google.protobuf.Timestamp + 11, // 21: notifier.v1.ListNotificationsRequest.filter:type_name -> notifier.v1.NotificationFilter + 3, // 22: notifier.v1.ListNotificationsResponse.notifications:type_name -> notifier.v1.Notification + 4, // 23: notifier.v1.RetryNotificationResponse.result:type_name -> notifier.v1.NotificationResult + 25, // 24: notifier.v1.GetStatsResponse.by_type:type_name -> notifier.v1.GetStatsResponse.ByTypeEntry + 26, // 25: notifier.v1.GetStatsResponse.by_status:type_name -> notifier.v1.GetStatsResponse.ByStatusEntry + 27, // 26: notifier.v1.HealthCheckResponse.components:type_name -> notifier.v1.HealthCheckResponse.ComponentsEntry + 5, // 27: notifier.v1.NotifierService.SendNotification:input_type -> notifier.v1.SendNotificationRequest + 7, // 28: notifier.v1.NotifierService.SendBatchNotifications:input_type -> notifier.v1.SendBatchNotificationsRequest + 9, // 29: notifier.v1.NotifierService.GetNotification:input_type -> notifier.v1.GetNotificationRequest + 12, // 30: notifier.v1.NotifierService.ListNotifications:input_type -> notifier.v1.ListNotificationsRequest + 14, // 31: notifier.v1.NotifierService.CancelNotification:input_type -> notifier.v1.CancelNotificationRequest + 16, // 32: notifier.v1.NotifierService.RetryNotification:input_type -> notifier.v1.RetryNotificationRequest + 18, // 33: notifier.v1.NotifierService.GetStats:input_type -> notifier.v1.GetStatsRequest + 20, // 34: notifier.v1.NotifierService.HealthCheck:input_type -> notifier.v1.HealthCheckRequest + 6, // 35: notifier.v1.NotifierService.SendNotification:output_type -> notifier.v1.SendNotificationResponse + 8, // 36: notifier.v1.NotifierService.SendBatchNotifications:output_type -> notifier.v1.SendBatchNotificationsResponse + 10, // 37: notifier.v1.NotifierService.GetNotification:output_type -> notifier.v1.GetNotificationResponse + 13, // 38: notifier.v1.NotifierService.ListNotifications:output_type -> notifier.v1.ListNotificationsResponse + 15, // 39: notifier.v1.NotifierService.CancelNotification:output_type -> notifier.v1.CancelNotificationResponse + 17, // 40: notifier.v1.NotifierService.RetryNotification:output_type -> notifier.v1.RetryNotificationResponse + 19, // 41: notifier.v1.NotifierService.GetStats:output_type -> notifier.v1.GetStatsResponse + 21, // 42: notifier.v1.NotifierService.HealthCheck:output_type -> notifier.v1.HealthCheckResponse + 35, // [35:43] is the sub-list for method output_type + 27, // [27:35] is the sub-list for method input_type + 27, // [27:27] is the sub-list for extension type_name + 27, // [27:27] is the sub-list for extension extendee + 0, // [0:27] is the sub-list for field type_name +} + +func init() { file_api_grpc_notifier_proto_init() } +func file_api_grpc_notifier_proto_init() { + if File_api_grpc_notifier_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_grpc_notifier_proto_rawDesc), len(file_api_grpc_notifier_proto_rawDesc)), + NumEnums: 3, + NumMessages: 25, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_api_grpc_notifier_proto_goTypes, + DependencyIndexes: file_api_grpc_notifier_proto_depIdxs, + EnumInfos: file_api_grpc_notifier_proto_enumTypes, + MessageInfos: file_api_grpc_notifier_proto_msgTypes, + }.Build() + File_api_grpc_notifier_proto = out.File + file_api_grpc_notifier_proto_goTypes = nil + file_api_grpc_notifier_proto_depIdxs = nil +} diff --git a/api/grpc/pb/api/grpc/notifier_grpc.pb.go b/api/grpc/pb/api/grpc/notifier_grpc.pb.go new file mode 100644 index 0000000..d386292 --- /dev/null +++ b/api/grpc/pb/api/grpc/notifier_grpc.pb.go @@ -0,0 +1,407 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc v6.33.0 +// source: api/grpc/notifier.proto + +package pb + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + NotifierService_SendNotification_FullMethodName = "/notifier.v1.NotifierService/SendNotification" + NotifierService_SendBatchNotifications_FullMethodName = "/notifier.v1.NotifierService/SendBatchNotifications" + NotifierService_GetNotification_FullMethodName = "/notifier.v1.NotifierService/GetNotification" + NotifierService_ListNotifications_FullMethodName = "/notifier.v1.NotifierService/ListNotifications" + NotifierService_CancelNotification_FullMethodName = "/notifier.v1.NotifierService/CancelNotification" + NotifierService_RetryNotification_FullMethodName = "/notifier.v1.NotifierService/RetryNotification" + NotifierService_GetStats_FullMethodName = "/notifier.v1.NotifierService/GetStats" + NotifierService_HealthCheck_FullMethodName = "/notifier.v1.NotifierService/HealthCheck" +) + +// NotifierServiceClient is the client API for NotifierService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// NotifierService handles notification operations +type NotifierServiceClient interface { + // SendNotification sends a single notification + SendNotification(ctx context.Context, in *SendNotificationRequest, opts ...grpc.CallOption) (*SendNotificationResponse, error) + // SendBatchNotifications sends multiple notifications + SendBatchNotifications(ctx context.Context, in *SendBatchNotificationsRequest, opts ...grpc.CallOption) (*SendBatchNotificationsResponse, error) + // GetNotification retrieves a notification by ID + GetNotification(ctx context.Context, in *GetNotificationRequest, opts ...grpc.CallOption) (*GetNotificationResponse, error) + // ListNotifications retrieves notifications matching a filter + ListNotifications(ctx context.Context, in *ListNotificationsRequest, opts ...grpc.CallOption) (*ListNotificationsResponse, error) + // CancelNotification cancels a pending notification + CancelNotification(ctx context.Context, in *CancelNotificationRequest, opts ...grpc.CallOption) (*CancelNotificationResponse, error) + // RetryNotification retries a failed notification + RetryNotification(ctx context.Context, in *RetryNotificationRequest, opts ...grpc.CallOption) (*RetryNotificationResponse, error) + // GetStats returns notification statistics + GetStats(ctx context.Context, in *GetStatsRequest, opts ...grpc.CallOption) (*GetStatsResponse, error) + // HealthCheck verifies the service is operational + HealthCheck(ctx context.Context, in *HealthCheckRequest, opts ...grpc.CallOption) (*HealthCheckResponse, error) +} + +type notifierServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewNotifierServiceClient(cc grpc.ClientConnInterface) NotifierServiceClient { + return ¬ifierServiceClient{cc} +} + +func (c *notifierServiceClient) SendNotification(ctx context.Context, in *SendNotificationRequest, opts ...grpc.CallOption) (*SendNotificationResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SendNotificationResponse) + err := c.cc.Invoke(ctx, NotifierService_SendNotification_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *notifierServiceClient) SendBatchNotifications(ctx context.Context, in *SendBatchNotificationsRequest, opts ...grpc.CallOption) (*SendBatchNotificationsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SendBatchNotificationsResponse) + err := c.cc.Invoke(ctx, NotifierService_SendBatchNotifications_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *notifierServiceClient) GetNotification(ctx context.Context, in *GetNotificationRequest, opts ...grpc.CallOption) (*GetNotificationResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetNotificationResponse) + err := c.cc.Invoke(ctx, NotifierService_GetNotification_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *notifierServiceClient) ListNotifications(ctx context.Context, in *ListNotificationsRequest, opts ...grpc.CallOption) (*ListNotificationsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListNotificationsResponse) + err := c.cc.Invoke(ctx, NotifierService_ListNotifications_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *notifierServiceClient) CancelNotification(ctx context.Context, in *CancelNotificationRequest, opts ...grpc.CallOption) (*CancelNotificationResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CancelNotificationResponse) + err := c.cc.Invoke(ctx, NotifierService_CancelNotification_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *notifierServiceClient) RetryNotification(ctx context.Context, in *RetryNotificationRequest, opts ...grpc.CallOption) (*RetryNotificationResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RetryNotificationResponse) + err := c.cc.Invoke(ctx, NotifierService_RetryNotification_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *notifierServiceClient) GetStats(ctx context.Context, in *GetStatsRequest, opts ...grpc.CallOption) (*GetStatsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetStatsResponse) + err := c.cc.Invoke(ctx, NotifierService_GetStats_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *notifierServiceClient) HealthCheck(ctx context.Context, in *HealthCheckRequest, opts ...grpc.CallOption) (*HealthCheckResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(HealthCheckResponse) + err := c.cc.Invoke(ctx, NotifierService_HealthCheck_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// NotifierServiceServer is the server API for NotifierService service. +// All implementations must embed UnimplementedNotifierServiceServer +// for forward compatibility. +// +// NotifierService handles notification operations +type NotifierServiceServer interface { + // SendNotification sends a single notification + SendNotification(context.Context, *SendNotificationRequest) (*SendNotificationResponse, error) + // SendBatchNotifications sends multiple notifications + SendBatchNotifications(context.Context, *SendBatchNotificationsRequest) (*SendBatchNotificationsResponse, error) + // GetNotification retrieves a notification by ID + GetNotification(context.Context, *GetNotificationRequest) (*GetNotificationResponse, error) + // ListNotifications retrieves notifications matching a filter + ListNotifications(context.Context, *ListNotificationsRequest) (*ListNotificationsResponse, error) + // CancelNotification cancels a pending notification + CancelNotification(context.Context, *CancelNotificationRequest) (*CancelNotificationResponse, error) + // RetryNotification retries a failed notification + RetryNotification(context.Context, *RetryNotificationRequest) (*RetryNotificationResponse, error) + // GetStats returns notification statistics + GetStats(context.Context, *GetStatsRequest) (*GetStatsResponse, error) + // HealthCheck verifies the service is operational + HealthCheck(context.Context, *HealthCheckRequest) (*HealthCheckResponse, error) + mustEmbedUnimplementedNotifierServiceServer() +} + +// UnimplementedNotifierServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedNotifierServiceServer struct{} + +func (UnimplementedNotifierServiceServer) SendNotification(context.Context, *SendNotificationRequest) (*SendNotificationResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method SendNotification not implemented") +} +func (UnimplementedNotifierServiceServer) SendBatchNotifications(context.Context, *SendBatchNotificationsRequest) (*SendBatchNotificationsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method SendBatchNotifications not implemented") +} +func (UnimplementedNotifierServiceServer) GetNotification(context.Context, *GetNotificationRequest) (*GetNotificationResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetNotification not implemented") +} +func (UnimplementedNotifierServiceServer) ListNotifications(context.Context, *ListNotificationsRequest) (*ListNotificationsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListNotifications not implemented") +} +func (UnimplementedNotifierServiceServer) CancelNotification(context.Context, *CancelNotificationRequest) (*CancelNotificationResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method CancelNotification not implemented") +} +func (UnimplementedNotifierServiceServer) RetryNotification(context.Context, *RetryNotificationRequest) (*RetryNotificationResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method RetryNotification not implemented") +} +func (UnimplementedNotifierServiceServer) GetStats(context.Context, *GetStatsRequest) (*GetStatsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetStats not implemented") +} +func (UnimplementedNotifierServiceServer) HealthCheck(context.Context, *HealthCheckRequest) (*HealthCheckResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method HealthCheck not implemented") +} +func (UnimplementedNotifierServiceServer) mustEmbedUnimplementedNotifierServiceServer() {} +func (UnimplementedNotifierServiceServer) testEmbeddedByValue() {} + +// UnsafeNotifierServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to NotifierServiceServer will +// result in compilation errors. +type UnsafeNotifierServiceServer interface { + mustEmbedUnimplementedNotifierServiceServer() +} + +func RegisterNotifierServiceServer(s grpc.ServiceRegistrar, srv NotifierServiceServer) { + // If the following call pancis, it indicates UnimplementedNotifierServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&NotifierService_ServiceDesc, srv) +} + +func _NotifierService_SendNotification_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SendNotificationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(NotifierServiceServer).SendNotification(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: NotifierService_SendNotification_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(NotifierServiceServer).SendNotification(ctx, req.(*SendNotificationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _NotifierService_SendBatchNotifications_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SendBatchNotificationsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(NotifierServiceServer).SendBatchNotifications(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: NotifierService_SendBatchNotifications_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(NotifierServiceServer).SendBatchNotifications(ctx, req.(*SendBatchNotificationsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _NotifierService_GetNotification_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetNotificationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(NotifierServiceServer).GetNotification(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: NotifierService_GetNotification_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(NotifierServiceServer).GetNotification(ctx, req.(*GetNotificationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _NotifierService_ListNotifications_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListNotificationsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(NotifierServiceServer).ListNotifications(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: NotifierService_ListNotifications_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(NotifierServiceServer).ListNotifications(ctx, req.(*ListNotificationsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _NotifierService_CancelNotification_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CancelNotificationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(NotifierServiceServer).CancelNotification(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: NotifierService_CancelNotification_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(NotifierServiceServer).CancelNotification(ctx, req.(*CancelNotificationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _NotifierService_RetryNotification_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RetryNotificationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(NotifierServiceServer).RetryNotification(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: NotifierService_RetryNotification_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(NotifierServiceServer).RetryNotification(ctx, req.(*RetryNotificationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _NotifierService_GetStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetStatsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(NotifierServiceServer).GetStats(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: NotifierService_GetStats_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(NotifierServiceServer).GetStats(ctx, req.(*GetStatsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _NotifierService_HealthCheck_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(HealthCheckRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(NotifierServiceServer).HealthCheck(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: NotifierService_HealthCheck_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(NotifierServiceServer).HealthCheck(ctx, req.(*HealthCheckRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// NotifierService_ServiceDesc is the grpc.ServiceDesc for NotifierService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var NotifierService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "notifier.v1.NotifierService", + HandlerType: (*NotifierServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "SendNotification", + Handler: _NotifierService_SendNotification_Handler, + }, + { + MethodName: "SendBatchNotifications", + Handler: _NotifierService_SendBatchNotifications_Handler, + }, + { + MethodName: "GetNotification", + Handler: _NotifierService_GetNotification_Handler, + }, + { + MethodName: "ListNotifications", + Handler: _NotifierService_ListNotifications_Handler, + }, + { + MethodName: "CancelNotification", + Handler: _NotifierService_CancelNotification_Handler, + }, + { + MethodName: "RetryNotification", + Handler: _NotifierService_RetryNotification_Handler, + }, + { + MethodName: "GetStats", + Handler: _NotifierService_GetStats_Handler, + }, + { + MethodName: "HealthCheck", + Handler: _NotifierService_HealthCheck_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "api/grpc/notifier.proto", +} diff --git a/api/rest/handlers.go b/api/rest/handlers.go index e69de29..f3a3058 100644 --- a/api/rest/handlers.go +++ b/api/rest/handlers.go @@ -0,0 +1,239 @@ +package rest + +import ( + "encoding/json" + "net/http" + "strconv" + "time" + + "github.com/gorilla/mux" + "github.com/igodwin/notifier/internal/domain" +) + +// Handler handles REST API requests +type Handler struct { + service domain.NotificationService +} + +// NewHandler creates a new REST handler +func NewHandler(service domain.NotificationService) *Handler { + return &Handler{ + service: service, + } +} + +// SendNotification handles POST /api/v1/notifications +func (h *Handler) SendNotification(w http.ResponseWriter, r *http.Request) { + var req SendNotificationRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "invalid request body", err) + return + } + + // Validate request + if err := req.Validate(); err != nil { + respondError(w, http.StatusBadRequest, "validation failed", err) + return + } + + // Convert to domain notification + notification := req.ToNotification() + + // Send notification + result, err := h.service.Send(r.Context(), notification) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to send notification", err) + return + } + + respondJSON(w, http.StatusAccepted, SendNotificationResponse{ + Result: NotificationResultFromDomain(result), + }) +} + +// SendBatchNotifications handles POST /api/v1/notifications/batch +func (h *Handler) SendBatchNotifications(w http.ResponseWriter, r *http.Request) { + var req SendBatchNotificationsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "invalid request body", err) + return + } + + // Validate and convert to domain notifications + notifications := make([]*domain.Notification, 0, len(req.Notifications)) + for _, notifReq := range req.Notifications { + if err := notifReq.Validate(); err != nil { + respondError(w, http.StatusBadRequest, "validation failed", err) + return + } + notifications = append(notifications, notifReq.ToNotification()) + } + + // Send batch + results, err := h.service.SendBatch(r.Context(), notifications) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to send batch notifications", err) + return + } + + // Convert results + apiResults := make([]NotificationResult, 0, len(results)) + for _, result := range results { + apiResults = append(apiResults, NotificationResultFromDomain(result)) + } + + respondJSON(w, http.StatusAccepted, SendBatchNotificationsResponse{ + Results: apiResults, + }) +} + +// GetNotification handles GET /api/v1/notifications/{id} +func (h *Handler) GetNotification(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + notification, err := h.service.GetNotification(r.Context(), id) + if err != nil { + respondError(w, http.StatusNotFound, "notification not found", err) + return + } + + respondJSON(w, http.StatusOK, NotificationFromDomain(notification)) +} + +// ListNotifications handles GET /api/v1/notifications +func (h *Handler) ListNotifications(w http.ResponseWriter, r *http.Request) { + filter := parseNotificationFilter(r) + + notifications, err := h.service.ListNotifications(r.Context(), filter) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to list notifications", err) + return + } + + // Convert to API format + apiNotifications := make([]Notification, 0, len(notifications)) + for _, notif := range notifications { + apiNotifications = append(apiNotifications, NotificationFromDomain(notif)) + } + + respondJSON(w, http.StatusOK, ListNotificationsResponse{ + Notifications: apiNotifications, + Total: int64(len(apiNotifications)), + }) +} + +// CancelNotification handles DELETE /api/v1/notifications/{id} +func (h *Handler) CancelNotification(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + if err := h.service.CancelNotification(r.Context(), id); err != nil { + respondError(w, http.StatusInternalServerError, "failed to cancel notification", err) + return + } + + respondJSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + "message": "notification canceled successfully", + }) +} + +// RetryNotification handles POST /api/v1/notifications/{id}/retry +func (h *Handler) RetryNotification(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + result, err := h.service.RetryNotification(r.Context(), id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to retry notification", err) + return + } + + respondJSON(w, http.StatusOK, RetryNotificationResponse{ + Result: NotificationResultFromDomain(result), + }) +} + +// GetStats handles GET /api/v1/stats +func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) { + stats, err := h.service.GetStats(r.Context()) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to get stats", err) + return + } + + respondJSON(w, http.StatusOK, stats) +} + +// HealthCheck handles GET /health +func (h *Handler) HealthCheck(w http.ResponseWriter, r *http.Request) { + respondJSON(w, http.StatusOK, map[string]interface{}{ + "status": "healthy", + "service": "notifier", + "time": time.Now().UTC(), + }) +} + +// parseNotificationFilter parses query parameters into a NotificationFilter +func parseNotificationFilter(r *http.Request) *domain.NotificationFilter { + query := r.URL.Query() + filter := &domain.NotificationFilter{} + + // Parse limit + if limitStr := query.Get("limit"); limitStr != "" { + if limit, err := strconv.Atoi(limitStr); err == nil { + filter.Limit = limit + } + } + + // Parse offset + if offsetStr := query.Get("offset"); offsetStr != "" { + if offset, err := strconv.Atoi(offsetStr); err == nil { + filter.Offset = offset + } + } + + // Parse types + if types := query["type"]; len(types) > 0 { + for _, t := range types { + filter.Types = append(filter.Types, domain.NotificationType(t)) + } + } + + // Parse statuses + if statuses := query["status"]; len(statuses) > 0 { + for _, s := range statuses { + filter.Statuses = append(filter.Statuses, domain.NotificationStatus(s)) + } + } + + // Parse recipients + if recipients := query["recipient"]; len(recipients) > 0 { + filter.Recipients = recipients + } + + return filter +} + +// respondJSON sends a JSON response +func respondJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +// respondError sends an error response +func respondError(w http.ResponseWriter, status int, message string, err error) { + errMsg := message + if err != nil { + errMsg = message + ": " + err.Error() + } + + respondJSON(w, status, map[string]interface{}{ + "error": message, + "details": errMsg, + }) +} diff --git a/api/rest/router.go b/api/rest/router.go index e69de29..d575772 100644 --- a/api/rest/router.go +++ b/api/rest/router.go @@ -0,0 +1,61 @@ +package rest + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/igodwin/notifier/internal/domain" +) + +// NewRouter creates a new HTTP router with all routes configured +func NewRouter(service domain.NotificationService) *mux.Router { + handler := NewHandler(service) + router := mux.NewRouter() + + // API v1 routes + v1 := router.PathPrefix("/api/v1").Subrouter() + + // Notification routes + v1.HandleFunc("/notifications", handler.SendNotification).Methods(http.MethodPost) + v1.HandleFunc("/notifications/batch", handler.SendBatchNotifications).Methods(http.MethodPost) + v1.HandleFunc("/notifications", handler.ListNotifications).Methods(http.MethodGet) + v1.HandleFunc("/notifications/{id}", handler.GetNotification).Methods(http.MethodGet) + v1.HandleFunc("/notifications/{id}", handler.CancelNotification).Methods(http.MethodDelete) + v1.HandleFunc("/notifications/{id}/retry", handler.RetryNotification).Methods(http.MethodPost) + + // Stats route + v1.HandleFunc("/stats", handler.GetStats).Methods(http.MethodGet) + + // Health check route + router.HandleFunc("/health", handler.HealthCheck).Methods(http.MethodGet) + + // Middleware + router.Use(loggingMiddleware) + router.Use(corsMiddleware) + + return router +} + +// loggingMiddleware logs incoming requests +func loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // You can add structured logging here + next.ServeHTTP(w, r) + }) +} + +// corsMiddleware adds CORS headers +func corsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/api/rest/types.go b/api/rest/types.go new file mode 100644 index 0000000..bef6590 --- /dev/null +++ b/api/rest/types.go @@ -0,0 +1,147 @@ +package rest + +import ( + "fmt" + "time" + + "github.com/google/uuid" + "github.com/igodwin/notifier/internal/domain" +) + +// SendNotificationRequest is the REST API request for sending a notification +type SendNotificationRequest struct { + Type string `json:"type"` + Priority int `json:"priority,omitempty"` + Subject string `json:"subject"` + Body string `json:"body"` + Recipients []string `json:"recipients"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + ScheduledFor *time.Time `json:"scheduled_for,omitempty"` + MaxRetries int `json:"max_retries,omitempty"` +} + +// Validate validates the request +func (r *SendNotificationRequest) Validate() error { + if r.Type == "" { + return fmt.Errorf("type is required") + } + + if len(r.Recipients) == 0 { + return fmt.Errorf("at least one recipient is required") + } + + if r.Body == "" { + return fmt.Errorf("body is required") + } + + return nil +} + +// ToNotification converts the request to a domain notification +func (r *SendNotificationRequest) ToNotification() *domain.Notification { + maxRetries := r.MaxRetries + if maxRetries == 0 { + maxRetries = 3 // Default + } + + return &domain.Notification{ + ID: uuid.New().String(), + Type: domain.NotificationType(r.Type), + Priority: domain.Priority(r.Priority), + Status: domain.StatusPending, + Subject: r.Subject, + Body: r.Body, + Recipients: r.Recipients, + Metadata: r.Metadata, + CreatedAt: time.Now(), + ScheduledFor: r.ScheduledFor, + MaxRetries: maxRetries, + RetryCount: 0, + } +} + +// SendNotificationResponse is the REST API response for sending a notification +type SendNotificationResponse struct { + Result NotificationResult `json:"result"` +} + +// SendBatchNotificationsRequest is the REST API request for sending multiple notifications +type SendBatchNotificationsRequest struct { + Notifications []SendNotificationRequest `json:"notifications"` +} + +// SendBatchNotificationsResponse is the REST API response for sending multiple notifications +type SendBatchNotificationsResponse struct { + Results []NotificationResult `json:"results"` +} + +// Notification represents a notification in the REST API +type Notification struct { + ID string `json:"id"` + Type string `json:"type"` + Priority int `json:"priority"` + Status string `json:"status"` + Subject string `json:"subject"` + Body string `json:"body"` + Recipients []string `json:"recipients"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` + ScheduledFor *time.Time `json:"scheduled_for,omitempty"` + SentAt *time.Time `json:"sent_at,omitempty"` + RetryCount int `json:"retry_count"` + MaxRetries int `json:"max_retries"` + LastError string `json:"last_error,omitempty"` +} + +// NotificationFromDomain converts a domain notification to API format +func NotificationFromDomain(n *domain.Notification) Notification { + return Notification{ + ID: n.ID, + Type: string(n.Type), + Priority: int(n.Priority), + Status: string(n.Status), + Subject: n.Subject, + Body: n.Body, + Recipients: n.Recipients, + Metadata: n.Metadata, + CreatedAt: n.CreatedAt, + ScheduledFor: n.ScheduledFor, + SentAt: n.SentAt, + RetryCount: n.RetryCount, + MaxRetries: n.MaxRetries, + LastError: n.LastError, + } +} + +// NotificationResult represents the result of a notification operation +type NotificationResult struct { + NotificationID string `json:"notification_id"` + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` + SentAt time.Time `json:"sent_at"` + ProviderResponse map[string]interface{} `json:"provider_response,omitempty"` +} + +// NotificationResultFromDomain converts a domain result to API format +func NotificationResultFromDomain(r *domain.NotificationResult) NotificationResult { + return NotificationResult{ + NotificationID: r.NotificationID, + Success: r.Success, + Message: r.Message, + Error: r.Error, + SentAt: r.SentAt, + ProviderResponse: r.ProviderResponse, + } +} + +// ListNotificationsResponse is the REST API response for listing notifications +type ListNotificationsResponse struct { + Notifications []Notification `json:"notifications"` + Total int64 `json:"total"` +} + +// RetryNotificationResponse is the REST API response for retrying a notification +type RetryNotificationResponse struct { + Result NotificationResult `json:"result"` +} diff --git a/bin/restserver b/bin/restserver new file mode 100755 index 0000000..23ac1eb Binary files /dev/null and b/bin/restserver differ diff --git a/bin/server b/bin/server new file mode 100755 index 0000000..271ba85 Binary files /dev/null and b/bin/server differ diff --git a/cmd/restserver/main.go b/cmd/restserver/main.go index e69de29..0e5aeb6 100644 --- a/cmd/restserver/main.go +++ b/cmd/restserver/main.go @@ -0,0 +1,174 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/igodwin/notifier/api/rest" + "github.com/igodwin/notifier/internal/config" + "github.com/igodwin/notifier/internal/domain" + "github.com/igodwin/notifier/internal/notifier" + "github.com/igodwin/notifier/internal/queue" + "github.com/igodwin/notifier/internal/service" +) + +func main() { + // Load configuration + cfg, err := config.Load("") + if err != nil { + log.Printf("Warning: failed to load config, using defaults: %v", err) + cfg = getDefaultConfig() + } + + log.Printf("Starting Notifier REST Server on port %d", cfg.Server.RESTPort) + + // Create context + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Initialize queue + var q domain.Queue + if cfg.Queue.Type == "local" { + q, err = queue.NewLocalQueue(cfg.Queue.Local) + if err != nil { + log.Fatalf("Failed to create queue: %v", err) + } + log.Println("Using local queue") + } else { + log.Fatalf("Queue type %s not implemented yet", cfg.Queue.Type) + } + + // Initialize notifier factory + factory := notifier.NewFactory() + + // Register notifiers based on configuration + if cfg.Notifiers.Stdout { + stdoutNotifier := notifier.NewStdoutNotifier() + if err := factory.RegisterNotifier(domain.TypeStdout, stdoutNotifier); err != nil { + log.Fatalf("Failed to register stdout notifier: %v", err) + } + log.Println("Registered stdout notifier") + } + + if cfg.Notifiers.SMTP != nil { + smtpNotifier, err := notifier.NewSMTPNotifier(cfg.Notifiers.SMTP) + if err != nil { + log.Printf("Warning: failed to create SMTP notifier: %v", err) + } else { + if err := factory.RegisterNotifier(domain.TypeEmail, smtpNotifier); err != nil { + log.Fatalf("Failed to register SMTP notifier: %v", err) + } + log.Println("Registered SMTP notifier") + } + } + + if cfg.Notifiers.Slack != nil { + slackNotifier, err := notifier.NewSlackNotifier(cfg.Notifiers.Slack) + if err != nil { + log.Printf("Warning: failed to create Slack notifier: %v", err) + } else { + if err := factory.RegisterNotifier(domain.TypeSlack, slackNotifier); err != nil { + log.Fatalf("Failed to register Slack notifier: %v", err) + } + log.Println("Registered Slack notifier") + } + } + + if cfg.Notifiers.Ntfy != nil { + ntfyNotifier, err := notifier.NewNtfyNotifier(cfg.Notifiers.Ntfy) + if err != nil { + log.Printf("Warning: failed to create Ntfy notifier: %v", err) + } else { + if err := factory.RegisterNotifier(domain.TypeNtfy, ntfyNotifier); err != nil { + log.Fatalf("Failed to register Ntfy notifier: %v", err) + } + log.Println("Registered Ntfy notifier") + } + } + + // Check if any notifiers are registered + if len(factory.SupportedTypes()) == 0 { + log.Fatal("No notifiers configured. Please enable at least one notifier in config.yaml") + } + + log.Printf("Supported notification types: %v", factory.SupportedTypes()) + + // Create notification service + svc := service.NewNotificationService(factory, q, cfg.Queue.WorkerCount) + + // Start workers + if err := svc.Start(ctx); err != nil { + log.Fatalf("Failed to start service: %v", err) + } + log.Printf("Started %d worker(s)", cfg.Queue.WorkerCount) + + // Create REST router + router := rest.NewRouter(svc) + + // Create HTTP server + addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.RESTPort) + server := &http.Server{ + Addr: addr, + Handler: router, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + // Start server in goroutine + go func() { + log.Printf("REST server listening on %s", addr) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("Failed to start server: %v", err) + } + }() + + // Wait for interrupt signal + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + <-sigChan + + log.Println("Shutting down server...") + + // Graceful shutdown + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer shutdownCancel() + + if err := server.Shutdown(shutdownCtx); err != nil { + log.Printf("Error during server shutdown: %v", err) + } + + // Stop service + if err := svc.Stop(); err != nil { + log.Printf("Error stopping service: %v", err) + } + + log.Println("Server stopped") +} + +// getDefaultConfig returns a minimal default configuration +func getDefaultConfig() *config.Config { + return &config.Config{ + Server: config.ServerConfig{ + RESTPort: 8080, + Host: "0.0.0.0", + }, + Queue: domain.QueueConfig{ + Type: "local", + WorkerCount: 5, + RetryAttempts: 3, + Local: &domain.LocalQueueConfig{ + BufferSize: 1000, + }, + }, + Notifiers: config.NotifiersConfig{ + Stdout: true, + }, + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..1a825b9 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,238 @@ +package main + +import ( + "context" + "fmt" + "log" + "net" + "net/http" + "os" + "os/signal" + "sync" + "syscall" + "time" + + "github.com/igodwin/notifier/api/rest" + "github.com/igodwin/notifier/internal/config" + "github.com/igodwin/notifier/internal/domain" + "github.com/igodwin/notifier/internal/notifier" + "github.com/igodwin/notifier/internal/queue" + "github.com/igodwin/notifier/internal/service" + "google.golang.org/grpc" + "google.golang.org/grpc/reflection" +) + +func main() { + // Load configuration + cfg, err := config.Load("") + if err != nil { + log.Printf("Warning: failed to load config, using defaults: %v", err) + cfg = getDefaultConfig() + } + + log.Printf("Starting Notifier Service in mode: %s", cfg.Server.Mode) + + // Create context + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Initialize queue + var q domain.Queue + if cfg.Queue.Type == "local" { + q, err = queue.NewLocalQueue(cfg.Queue.Local) + if err != nil { + log.Fatalf("Failed to create queue: %v", err) + } + log.Println("Using local queue") + } else { + log.Fatalf("Queue type %s not implemented yet", cfg.Queue.Type) + } + + // Initialize notifier factory and register notifiers + factory := notifier.NewFactory() + registerNotifiers(cfg, factory) + + // Check if any notifiers are registered + if len(factory.SupportedTypes()) == 0 { + log.Fatal("No notifiers configured. Please enable at least one notifier in config.yaml") + } + + log.Printf("Supported notification types: %v", factory.SupportedTypes()) + + // Create notification service + svc := service.NewNotificationService(factory, q, cfg.Queue.WorkerCount) + + // Start workers + if err := svc.Start(ctx); err != nil { + log.Fatalf("Failed to start service: %v", err) + } + log.Printf("Started %d worker(s)", cfg.Queue.WorkerCount) + + // Wait group for both servers + var wg sync.WaitGroup + + // Start gRPC server if enabled + var grpcServer *grpc.Server + if cfg.Server.Mode == "both" || cfg.Server.Mode == "grpc" { + wg.Add(1) + grpcServer = startGRPCServer(ctx, &wg, cfg, svc) + } + + // Start REST server if enabled + var restServer *http.Server + if cfg.Server.Mode == "both" || cfg.Server.Mode == "rest" { + wg.Add(1) + restServer = startRESTServer(ctx, &wg, cfg, svc) + } + + // Wait for interrupt signal + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + <-sigChan + + log.Println("Shutting down servers...") + + // Graceful shutdown + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer shutdownCancel() + + // Stop REST server + if restServer != nil { + if err := restServer.Shutdown(shutdownCtx); err != nil { + log.Printf("Error during REST server shutdown: %v", err) + } + } + + // Stop gRPC server + if grpcServer != nil { + grpcServer.GracefulStop() + } + + // Wait for servers to stop + wg.Wait() + + // Stop service + if err := svc.Stop(); err != nil { + log.Printf("Error stopping service: %v", err) + } + + log.Println("Servers stopped") +} + +func registerNotifiers(cfg *config.Config, factory *notifier.Factory) { + if cfg.Notifiers.Stdout { + stdoutNotifier := notifier.NewStdoutNotifier() + if err := factory.RegisterNotifier(domain.TypeStdout, stdoutNotifier); err != nil { + log.Fatalf("Failed to register stdout notifier: %v", err) + } + log.Println("Registered stdout notifier") + } + + if cfg.Notifiers.SMTP != nil { + smtpNotifier, err := notifier.NewSMTPNotifier(cfg.Notifiers.SMTP) + if err != nil { + log.Printf("Warning: failed to create SMTP notifier: %v", err) + } else { + if err := factory.RegisterNotifier(domain.TypeEmail, smtpNotifier); err != nil { + log.Fatalf("Failed to register SMTP notifier: %v", err) + } + log.Println("Registered SMTP notifier") + } + } + + if cfg.Notifiers.Slack != nil { + slackNotifier, err := notifier.NewSlackNotifier(cfg.Notifiers.Slack) + if err != nil { + log.Printf("Warning: failed to create Slack notifier: %v", err) + } else { + if err := factory.RegisterNotifier(domain.TypeSlack, slackNotifier); err != nil { + log.Fatalf("Failed to register Slack notifier: %v", err) + } + log.Println("Registered Slack notifier") + } + } + + if cfg.Notifiers.Ntfy != nil { + ntfyNotifier, err := notifier.NewNtfyNotifier(cfg.Notifiers.Ntfy) + if err != nil { + log.Printf("Warning: failed to create Ntfy notifier: %v", err) + } else { + if err := factory.RegisterNotifier(domain.TypeNtfy, ntfyNotifier); err != nil { + log.Fatalf("Failed to register Ntfy notifier: %v", err) + } + log.Println("Registered Ntfy notifier") + } + } +} + +func startGRPCServer(ctx context.Context, wg *sync.WaitGroup, cfg *config.Config, svc domain.NotificationService) *grpc.Server { + addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.GRPCPort) + + lis, err := net.Listen("tcp", addr) + if err != nil { + log.Fatalf("Failed to listen on %s: %v", addr, err) + } + + grpcServer := grpc.NewServer() + + // TODO: Register gRPC service implementation when protobuf is generated + // pb.RegisterNotifierServiceServer(grpcServer, grpcHandler) + + // Enable reflection for tools like grpcurl + reflection.Register(grpcServer) + + go func() { + defer wg.Done() + log.Printf("gRPC server listening on %s", addr) + if err := grpcServer.Serve(lis); err != nil { + log.Fatalf("Failed to serve gRPC: %v", err) + } + }() + + return grpcServer +} + +func startRESTServer(ctx context.Context, wg *sync.WaitGroup, cfg *config.Config, svc domain.NotificationService) *http.Server { + router := rest.NewRouter(svc) + + addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.RESTPort) + server := &http.Server{ + Addr: addr, + Handler: router, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + go func() { + defer wg.Done() + log.Printf("REST server listening on %s", addr) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("Failed to start REST server: %v", err) + } + }() + + return server +} + +func getDefaultConfig() *config.Config { + return &config.Config{ + Server: config.ServerConfig{ + GRPCPort: 50051, + RESTPort: 8080, + Host: "0.0.0.0", + Mode: "both", + }, + Queue: domain.QueueConfig{ + Type: "local", + WorkerCount: 5, + RetryAttempts: 3, + Local: &domain.LocalQueueConfig{ + BufferSize: 1000, + }, + }, + Notifiers: config.NotifiersConfig{ + Stdout: true, + }, + } +} diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..b7e741a --- /dev/null +++ b/config.yaml @@ -0,0 +1,91 @@ +# Notifier Service Configuration + +server: + grpc_port: 50051 + rest_port: 8080 + host: "0.0.0.0" + mode: "both" # Options: both, grpc, rest + +queue: + type: "local" # Options: local, kafka + max_size: 10000 + worker_count: 10 + retry_attempts: 3 + retry_backoff: "exponential" # Options: exponential, linear, fixed + + # Local queue configuration + local: + buffer_size: 1000 + persist_to_disk: false + persist_path: "/var/lib/notifier/queue.json" + + # Kafka queue configuration (when type: kafka) + # kafka: + # brokers: + # - "localhost:9092" + # topic: "notifications" + # consumer_group: "notifier-service" + # partition_count: 10 + # replication_factor: 3 + # enable_idempotence: true + # compression_type: "snappy" + +notifiers: + # Enable stdout notifier (useful for development/debugging) + stdout: true + + # SMTP email configuration + smtp: + host: "smtp.gmail.com" + port: 587 + username: "your-email@gmail.com" + password: "your-app-password" + from: "notifications@yourservice.com" + use_tls: true + + # Slack configuration + slack: + webhook_url: "https://hooks.slack.com/services/YOUR/WEBHOOK/URL" + # token: "xoxb-your-bot-token" # Alternative to webhook + # channel: "#notifications" # Default channel + username: "Notifier Bot" + icon_emoji: ":bell:" + # Channel-specific webhooks + # webhooks: + # "#alerts": "https://hooks.slack.com/services/ALERTS/WEBHOOK" + # "#monitoring": "https://hooks.slack.com/services/MONITORING/WEBHOOK" + + # Ntfy configuration + ntfy: + server_url: "https://ntfy.sh" + + # Token-based authentication (recommended for ntfy.sh) + # Supports both access tokens (tk_...) and publish tokens + # token: "tk_your_access_token" + + # Basic authentication (alternative to token) + # username: "your-username" + # password: "your-password" + + # Default topic if recipient not specified + # default_topic: "default-notifications" + + # Skip TLS verification (for self-hosted servers with self-signed certs) + # insecure_skip_verify: false + +logging: + level: "info" # Options: debug, info, warn, error + format: "json" # Options: json, text + output_path: "stdout" # Options: stdout, stderr, or file path + +metrics: + enabled: true + port: 9090 + path: "/metrics" + prometheus_enabled: true + +health_check: + enabled: true + port: 8081 + path: "/health" + interval: 30 # seconds diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..6a50503 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,95 @@ +version: '3.8' + +services: + notifier: + build: + context: . + dockerfile: Dockerfile + container_name: notifier + ports: + - "8080:8080" # REST API + - "50051:50051" # gRPC + - "9090:9090" # Metrics + - "8081:8081" # Health check + volumes: + - ./config.yaml:/app/config.yaml:ro + - notifier-data:/var/lib/notifier + environment: + - NOTIFIER_SERVER_MODE=both + - NOTIFIER_LOGGING_LEVEL=info + - NOTIFIER_LOGGING_FORMAT=json + restart: unless-stopped + networks: + - notifier-net + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8081/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + + # Optional: Kafka for distributed queue + # kafka: + # image: confluentinc/cp-kafka:latest + # container_name: kafka + # ports: + # - "9092:9092" + # environment: + # KAFKA_BROKER_ID: 1 + # KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + # KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092 + # KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + # depends_on: + # - zookeeper + # networks: + # - notifier-net + + # zookeeper: + # image: confluentinc/cp-zookeeper:latest + # container_name: zookeeper + # ports: + # - "2181:2181" + # environment: + # ZOOKEEPER_CLIENT_PORT: 2181 + # ZOOKEEPER_TICK_TIME: 2000 + # networks: + # - notifier-net + + # Optional: Prometheus for metrics collection + # prometheus: + # image: prom/prometheus:latest + # container_name: prometheus + # ports: + # - "9091:9090" + # volumes: + # - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + # - prometheus-data:/prometheus + # command: + # - '--config.file=/etc/prometheus/prometheus.yml' + # - '--storage.tsdb.path=/prometheus' + # networks: + # - notifier-net + + # Optional: Grafana for visualization + # grafana: + # image: grafana/grafana:latest + # container_name: grafana + # ports: + # - "3000:3000" + # environment: + # - GF_SECURITY_ADMIN_PASSWORD=admin + # volumes: + # - grafana-data:/var/lib/grafana + # depends_on: + # - prometheus + # networks: + # - notifier-net + +networks: + notifier-net: + driver: bridge + +volumes: + notifier-data: + # prometheus-data: + # grafana-data: diff --git a/docs/NTFY_GUIDE.md b/docs/NTFY_GUIDE.md new file mode 100644 index 0000000..8b69e53 --- /dev/null +++ b/docs/NTFY_GUIDE.md @@ -0,0 +1,479 @@ +# Ntfy Integration Guide + +This guide explains how to use ntfy.sh notifications with the Notifier service. + +## What is Ntfy? + +[ntfy](https://ntfy.sh) is a simple HTTP-based pub-sub notification service. You can send notifications to your phone, desktop, or any device that subscribes to your topics. It's perfect for: + +- Push notifications to mobile devices +- Desktop notifications +- Server alerts and monitoring +- CI/CD pipeline notifications +- IoT device notifications + +## Authentication Methods + +### 1. Token-Based Authentication (Recommended) + +Token authentication is the preferred method for ntfy.sh. + +#### Access Tokens (for authenticated topics) +```yaml +notifiers: + ntfy: + server_url: "https://ntfy.sh" + token: "tk_your_access_token" +``` + +Get an access token: +1. Go to https://ntfy.sh/account +2. Create an account or log in +3. Go to "Access Tokens" +4. Create a new token with appropriate permissions +5. Copy the token (starts with `tk_`) + +#### Publish Tokens (for specific topics) +```yaml +notifiers: + ntfy: + server_url: "https://ntfy.sh" + token: "your_publish_token" +``` + +Create a publish token: +1. Create a topic with reserved access +2. Generate a publish token for that topic +3. Use the token in your configuration + +### 2. Basic Authentication + +Alternative to token auth: + +```yaml +notifiers: + ntfy: + server_url: "https://ntfy.sh" + username: "your-username" + password: "your-password" +``` + +### 3. No Authentication (Public Topics) + +For public topics on ntfy.sh: + +```yaml +notifiers: + ntfy: + server_url: "https://ntfy.sh" + # No token, username, or password needed +``` + +## Configuration Options + +### Full Configuration Example + +```yaml +notifiers: + ntfy: + # Server URL (default: https://ntfy.sh) + server_url: "https://ntfy.sh" + + # Authentication (choose one method) + token: "tk_your_token" # Token auth (recommended) + # username: "user" # Or basic auth + # password: "pass" + + # Optional: default topic if not specified in notification + default_topic: "my-default-topic" + + # Optional: skip TLS verification (for self-hosted with self-signed certs) + insecure_skip_verify: false +``` + +### Self-Hosted Ntfy Server + +```yaml +notifiers: + ntfy: + server_url: "https://ntfy.yourcompany.com" + token: "your_custom_token" + # For self-signed certificates + insecure_skip_verify: true +``` + +## Sending Notifications + +### Basic Notification + +```bash +curl -X POST http://localhost:8080/api/v1/notifications \ + -H "Content-Type: application/json" \ + -d '{ + "type": "ntfy", + "subject": "Hello from Notifier!", + "body": "This is a test notification", + "recipients": ["my-topic"] + }' +``` + +### With Priority + +```bash +curl -X POST http://localhost:8080/api/v1/notifications \ + -H "Content-Type: application/json" \ + -d '{ + "type": "ntfy", + "priority": 4, + "subject": "CRITICAL Alert", + "body": "Something important happened!", + "recipients": ["alerts"] + }' +``` + +Priority mapping: +- `0` (Low) → ntfy priority 2 +- `1` (Normal) → ntfy priority 3 (default) +- `2` (High) → ntfy priority 4 +- `3` (Critical) → ntfy priority 5 (max) + +### With Tags (Emojis) + +```bash +curl -X POST http://localhost:8080/api/v1/notifications \ + -H "Content-Type: application/json" \ + -d '{ + "type": "ntfy", + "subject": "Deployment Complete", + "body": "Application deployed successfully", + "recipients": ["deployments"], + "metadata": { + "tags": ["rocket", "tada", "white_check_mark"] + } + }' +``` + +Common tags: +- `warning`, `rotating_light`, `skull` - Alerts +- `tada`, `rocket`, `sparkles` - Success +- `x`, `no_entry`, `stop_sign` - Errors +- `information_source`, `eyes` - Info + +### With Click Action + +Make the notification clickable: + +```bash +curl -X POST http://localhost:8080/api/v1/notifications \ + -H "Content-Type: application/json" \ + -d '{ + "type": "ntfy", + "subject": "New Pull Request", + "body": "PR #123 needs review", + "recipients": ["github-notifications"], + "metadata": { + "click": "https://github.com/your-org/your-repo/pull/123", + "tags": ["github"] + } + }' +``` + +### With Attachment + +Attach an image or file: + +```bash +curl -X POST http://localhost:8080/api/v1/notifications \ + -H "Content-Type: application/json" \ + -d '{ + "type": "ntfy", + "subject": "Server Stats", + "body": "Current server metrics", + "recipients": ["monitoring"], + "metadata": { + "attach": "https://example.com/metrics.png", + "tags": ["chart_with_upwards_trend"] + } + }' +``` + +### With Custom Icon + +```bash +curl -X POST http://localhost:8080/api/v1/notifications \ + -H "Content-Type: application/json" \ + -d '{ + "type": "ntfy", + "subject": "Custom Notification", + "body": "With a custom icon", + "recipients": ["custom-alerts"], + "metadata": { + "icon": "https://example.com/logo.png" + } + }' +``` + +### With Delayed Delivery + +Schedule notification for later: + +```bash +curl -X POST http://localhost:8080/api/v1/notifications \ + -H "Content-Type: application/json" \ + -d '{ + "type": "ntfy", + "subject": "Reminder", + "body": "Meeting in 30 minutes", + "recipients": ["reminders"], + "metadata": { + "delay": "30m", + "tags": ["alarm_clock"] + } + }' +``` + +Delay formats: +- `30s` - 30 seconds +- `5m` - 5 minutes +- `2h` - 2 hours +- `tomorrow 10am` - Tomorrow at 10 AM + +### With Action Buttons + +Add interactive buttons: + +```bash +curl -X POST http://localhost:8080/api/v1/notifications \ + -H "Content-Type: application/json" \ + -d '{ + "type": "ntfy", + "subject": "Deploy to Production?", + "body": "Version 2.0 is ready", + "recipients": ["deployments"], + "metadata": { + "actions": [ + { + "action": "view", + "label": "View Release", + "url": "https://github.com/your-org/your-repo/releases/v2.0", + "clear": true + }, + { + "action": "http", + "label": "Deploy", + "url": "https://api.yourcompany.com/deploy", + "body": "{\"version\": \"2.0\"}", + "clear": true + } + ] + } + }' +``` + +Action types: +- `view` - Open a URL +- `http` - Send HTTP request +- `broadcast` - Android broadcast intent + +### With Email Forwarding + +Forward to email (requires ntfy.sh tier 2+): + +```bash +curl -X POST http://localhost:8080/api/v1/notifications \ + -H "Content-Type: application/json" \ + -d '{ + "type": "ntfy", + "subject": "Important Alert", + "body": "This will also be sent via email", + "recipients": ["alerts"], + "metadata": { + "email": "admin@example.com", + "tags": ["email"] + } + }' +``` + +### Multiple Topics + +Send to multiple topics: + +```bash +curl -X POST http://localhost:8080/api/v1/notifications \ + -H "Content-Type: application/json" \ + -d '{ + "type": "ntfy", + "subject": "System Update", + "body": "System will restart in 5 minutes", + "recipients": ["admins", "monitoring", "alerts"], + "metadata": { + "tags": ["warning"] + } + }' +``` + +## Mobile App Setup + +### iOS +1. Download ntfy from the App Store +2. Add a topic subscription +3. Use the same topic name in your notifications + +### Android +1. Download ntfy from Google Play or F-Droid +2. Add a topic subscription +3. Configure notification settings +4. Use the same topic name in your notifications + +### Desktop (Linux/macOS/Windows) +```bash +# Install ntfy CLI +curl -sSL https://ntfy.sh/install.sh | sh + +# Subscribe to topics +ntfy subscribe mytopic +``` + +## Topic Naming Best Practices + +### Public Topics +- Use unique, hard-to-guess names +- Consider including random strings: `myapp-alerts-x7k9p2` +- Anyone who knows the name can subscribe + +### Private Topics (Recommended) +- Requires authentication +- Create reserved topics on ntfy.sh +- Use access control lists (ACLs) + +### Topic Organization +```yaml +# Example topic structure +- myapp-prod-alerts # Production alerts +- myapp-prod-info # Production info +- myapp-staging-alerts # Staging alerts +- myapp-ci-cd # CI/CD notifications +- myapp-monitoring # Monitoring metrics +``` + +## Environment Variables + +Override configuration with environment variables: + +```bash +export NOTIFIER_NOTIFIERS_NTFY_SERVER_URL=https://ntfy.yourcompany.com +export NOTIFIER_NOTIFIERS_NTFY_TOKEN=tk_your_token +export NOTIFIER_NOTIFIERS_NTFY_DEFAULT_TOPIC=default-topic +export NOTIFIER_NOTIFIERS_NTFY_INSECURE_SKIP_VERIFY=false +``` + +## Security Considerations + +### Token Security +- **Never commit tokens to version control** +- Store tokens in Kubernetes Secrets or environment variables +- Rotate tokens regularly +- Use publish tokens with limited scope when possible + +### Topic Security +- Use reserved/private topics for sensitive data +- Don't include secrets in notification bodies +- Consider encryption for highly sensitive data +- Use unique topic names to prevent enumeration + +### Self-Hosted Servers +- Use TLS with valid certificates +- Enable authentication +- Configure rate limiting +- Monitor access logs +- Keep ntfy server updated + +## Kubernetes Deployment + +### Using Secrets + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: notifier-secrets +type: Opaque +stringData: + ntfy-token: "tk_your_access_token" +``` + +### Deployment Configuration + +```yaml +env: +- name: NOTIFIER_NOTIFIERS_NTFY_TOKEN + valueFrom: + secretKeyRef: + name: notifier-secrets + key: ntfy-token +``` + +## Rate Limits + +### ntfy.sh Free Tier +- 250 messages/day per visitor +- Unlimited topics +- Message retention: 12 hours + +### ntfy.sh Tier 1 ($5/month) +- 500 messages/day +- Message retention: 1 day +- Attachment & email support + +### ntfy.sh Tier 2 ($10/month) +- 1000 messages/day +- Message retention: 7 days +- Higher attachment limits + +### Self-Hosted +- Configure your own limits +- Full control over retention +- No external dependencies + +## Troubleshooting + +### Authentication Errors +``` +Error: ntfy server returned status: 401 +``` +**Solution**: Verify token is correct and has necessary permissions + +### Topic Not Found +``` +Error: ntfy server returned status: 404 +``` +**Solution**: Check topic name spelling, ensure topic exists if using reserved topics + +### TLS Errors (Self-Hosted) +``` +Error: x509: certificate signed by unknown authority +``` +**Solution**: Either fix certificate or set `insecure_skip_verify: true` (not recommended for production) + +### Rate Limit Exceeded +``` +Error: ntfy server returned status: 429 +``` +**Solution**: Reduce message frequency or upgrade ntfy.sh tier + +### Connection Timeout +``` +Error: context deadline exceeded +``` +**Solution**: Check network connectivity, firewall rules, or server URL + +## Examples + +See [QUICKSTART.md](../QUICKSTART.md) for more examples of using ntfy with the Notifier service. + +## Resources + +- [Ntfy Documentation](https://docs.ntfy.sh) +- [Ntfy.sh Public Instance](https://ntfy.sh) +- [Self-Hosting Guide](https://docs.ntfy.sh/install/) +- [API Reference](https://docs.ntfy.sh/publish/) diff --git a/go.mod b/go.mod index 95ffb12..3d2a2a6 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,36 @@ module github.com/igodwin/notifier go 1.23.2 + +require ( + github.com/google/uuid v1.6.0 + github.com/gorilla/mux v1.8.1 + github.com/spf13/viper v1.19.0 + google.golang.org/grpc v1.62.1 + google.golang.org/protobuf v1.33.0 +) + +require ( + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/net v0.23.0 // indirect + golang.org/x/sys v0.24.0 // indirect + golang.org/x/text v0.17.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240314234333-6e1732d8331c // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8c0b93b --- /dev/null +++ b/go.sum @@ -0,0 +1,90 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= +golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240314234333-6e1732d8331c h1:lfpJ/2rWPa/kJgxyyXM8PrNnfCzcmxJ265mADgwmvLI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240314234333-6e1732d8331c/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= +google.golang.org/grpc v1.62.1 h1:B4n+nfKzOICUXMgyrNd19h/I9oH0L1pizfk1d4zSgTk= +google.golang.org/grpc v1.62.1/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +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/internal/config/config.go b/internal/config/config.go index e69de29..b7d38ae 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -0,0 +1,211 @@ +package config + +import ( + "fmt" + "strings" + + "github.com/igodwin/notifier/internal/domain" + "github.com/igodwin/notifier/internal/notifier" + "github.com/spf13/viper" +) + +// Config represents the application configuration +type Config struct { + Server ServerConfig `mapstructure:"server"` + Queue domain.QueueConfig `mapstructure:"queue"` + Notifiers NotifiersConfig `mapstructure:"notifiers"` + Logging LoggingConfig `mapstructure:"logging"` + Metrics MetricsConfig `mapstructure:"metrics"` + HealthCheck HealthCheckConfig `mapstructure:"health_check"` +} + +// ServerConfig contains server configuration +type ServerConfig struct { + GRPCPort int `mapstructure:"grpc_port"` + RESTPort int `mapstructure:"rest_port"` + Host string `mapstructure:"host"` + Mode string `mapstructure:"mode"` // "both", "grpc", "rest" +} + +// NotifiersConfig contains configuration for all notifier types +type NotifiersConfig struct { + SMTP *notifier.SMTPConfig `mapstructure:"smtp"` + Slack *notifier.SlackConfig `mapstructure:"slack"` + Ntfy *notifier.NtfyConfig `mapstructure:"ntfy"` + Stdout bool `mapstructure:"stdout"` // Enable stdout notifier +} + +// LoggingConfig contains logging configuration +type LoggingConfig struct { + Level string `mapstructure:"level"` // debug, info, warn, error + Format string `mapstructure:"format"` // json, text + OutputPath string `mapstructure:"output_path"` // stdout, stderr, or file path +} + +// MetricsConfig contains metrics/observability configuration +type MetricsConfig struct { + Enabled bool `mapstructure:"enabled"` + Port int `mapstructure:"port"` + Path string `mapstructure:"path"` + PrometheusEnabled bool `mapstructure:"prometheus_enabled"` +} + +// HealthCheckConfig contains health check configuration +type HealthCheckConfig struct { + Enabled bool `mapstructure:"enabled"` + Port int `mapstructure:"port"` + Path string `mapstructure:"path"` + Interval int `mapstructure:"interval"` // seconds +} + +// Load loads configuration from file and environment variables +func Load(configPath string) (*Config, error) { + v := viper.New() + + // Set default values + setDefaults(v) + + // Configure viper + v.SetConfigName("config") + v.SetConfigType("yaml") + + if configPath != "" { + v.AddConfigPath(configPath) + } + + // Also look in common locations + v.AddConfigPath(".") + v.AddConfigPath("./config") + v.AddConfigPath("/etc/notifier") + v.AddConfigPath("$HOME/.notifier") + + // Environment variable support + v.SetEnvPrefix("NOTIFIER") + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + v.AutomaticEnv() + + // Read config file + if err := v.ReadInConfig(); err != nil { + // Config file is optional if environment variables are set + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + } + + var config Config + if err := v.Unmarshal(&config); err != nil { + return nil, fmt.Errorf("failed to unmarshal config: %w", err) + } + + // Validate configuration + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("invalid configuration: %w", err) + } + + return &config, nil +} + +// setDefaults sets default configuration values +func setDefaults(v *viper.Viper) { + // Server defaults + v.SetDefault("server.grpc_port", 50051) + v.SetDefault("server.rest_port", 8080) + v.SetDefault("server.host", "0.0.0.0") + v.SetDefault("server.mode", "both") + + // Queue defaults + v.SetDefault("queue.type", "local") + v.SetDefault("queue.max_size", 10000) + v.SetDefault("queue.worker_count", 10) + v.SetDefault("queue.retry_attempts", 3) + v.SetDefault("queue.retry_backoff", "exponential") + + // Local queue defaults + v.SetDefault("queue.local.buffer_size", 1000) + v.SetDefault("queue.local.persist_to_disk", false) + + // Logging defaults + v.SetDefault("logging.level", "info") + v.SetDefault("logging.format", "json") + v.SetDefault("logging.output_path", "stdout") + + // Metrics defaults + v.SetDefault("metrics.enabled", true) + v.SetDefault("metrics.port", 9090) + v.SetDefault("metrics.path", "/metrics") + v.SetDefault("metrics.prometheus_enabled", true) + + // Health check defaults + v.SetDefault("health_check.enabled", true) + v.SetDefault("health_check.port", 8081) + v.SetDefault("health_check.path", "/health") + v.SetDefault("health_check.interval", 30) + + // Notifier defaults + v.SetDefault("notifiers.stdout", true) + v.SetDefault("notifiers.smtp.port", 587) + v.SetDefault("notifiers.smtp.use_tls", true) + v.SetDefault("notifiers.ntfy.server_url", "https://ntfy.sh") +} + +// Validate validates the configuration +func (c *Config) Validate() error { + // Validate server config + if c.Server.GRPCPort < 1 || c.Server.GRPCPort > 65535 { + return fmt.Errorf("invalid gRPC port: %d", c.Server.GRPCPort) + } + + if c.Server.RESTPort < 1 || c.Server.RESTPort > 65535 { + return fmt.Errorf("invalid REST port: %d", c.Server.RESTPort) + } + + validModes := map[string]bool{"both": true, "grpc": true, "rest": true} + if !validModes[c.Server.Mode] { + return fmt.Errorf("invalid server mode: %s (must be both, grpc, or rest)", c.Server.Mode) + } + + // Validate queue config + validQueueTypes := map[string]bool{"local": true, "kafka": true} + if !validQueueTypes[c.Queue.Type] { + return fmt.Errorf("invalid queue type: %s (must be local or kafka)", c.Queue.Type) + } + + if c.Queue.Type == "kafka" && c.Queue.Kafka == nil { + return fmt.Errorf("Kafka queue type selected but no Kafka configuration provided") + } + + // Validate at least one notifier is configured + if !c.HasAnyNotifier() { + return fmt.Errorf("at least one notifier must be configured") + } + + return nil +} + +// HasAnyNotifier checks if at least one notifier is configured +func (c *Config) HasAnyNotifier() bool { + return c.Notifiers.Stdout || + c.Notifiers.SMTP != nil || + c.Notifiers.Slack != nil || + c.Notifiers.Ntfy != nil +} + +// GetEnabledNotifiers returns a list of enabled notifier types +func (c *Config) GetEnabledNotifiers() []domain.NotificationType { + var enabled []domain.NotificationType + + if c.Notifiers.Stdout { + enabled = append(enabled, domain.TypeStdout) + } + if c.Notifiers.SMTP != nil { + enabled = append(enabled, domain.TypeEmail) + } + if c.Notifiers.Slack != nil { + enabled = append(enabled, domain.TypeSlack) + } + if c.Notifiers.Ntfy != nil { + enabled = append(enabled, domain.TypeNtfy) + } + + return enabled +} diff --git a/internal/domain/notification.go b/internal/domain/notification.go new file mode 100644 index 0000000..71f94fb --- /dev/null +++ b/internal/domain/notification.go @@ -0,0 +1,115 @@ +package domain + +import ( + "time" +) + +// Priority defines the urgency level of a notification +type Priority int + +const ( + PriorityLow Priority = iota + PriorityNormal + PriorityHigh + PriorityCritical +) + +// NotificationType defines the channel through which to send the notification +type NotificationType string + +const ( + TypeEmail NotificationType = "email" + TypeSlack NotificationType = "slack" + TypeNtfy NotificationType = "ntfy" + TypeStdout NotificationType = "stdout" +) + +// NotificationStatus represents the current state of a notification +type NotificationStatus string + +const ( + StatusPending NotificationStatus = "pending" + StatusQueued NotificationStatus = "queued" + StatusProcessing NotificationStatus = "processing" + StatusSent NotificationStatus = "sent" + StatusFailed NotificationStatus = "failed" + StatusRetrying NotificationStatus = "retrying" +) + +// Notification represents a notification message with metadata +type Notification struct { + // ID is a unique identifier for the notification + ID string `json:"id"` + + // Type specifies which notifier should handle this notification + Type NotificationType `json:"type"` + + // Priority determines urgency and retry behavior + Priority Priority `json:"priority"` + + // Status tracks the current state of the notification + Status NotificationStatus `json:"status"` + + // Subject is the notification subject/title (used for email, slack, ntfy) + Subject string `json:"subject"` + + // Body is the main content of the notification + Body string `json:"body"` + + // Recipients contains the target addresses (email, slack channel, ntfy topic, etc.) + Recipients []string `json:"recipients"` + + // Metadata contains additional provider-specific data + Metadata map[string]interface{} `json:"metadata,omitempty"` + + // CreatedAt is when the notification was created + CreatedAt time.Time `json:"created_at"` + + // ScheduledFor allows delayed sending (optional) + ScheduledFor *time.Time `json:"scheduled_for,omitempty"` + + // SentAt is when the notification was successfully sent + SentAt *time.Time `json:"sent_at,omitempty"` + + // RetryCount tracks how many times sending has been attempted + RetryCount int `json:"retry_count"` + + // MaxRetries defines the maximum retry attempts + MaxRetries int `json:"max_retries"` + + // LastError stores the most recent error message if failed + LastError string `json:"last_error,omitempty"` +} + +// NotificationResult represents the outcome of sending a notification +type NotificationResult struct { + // NotificationID references the original notification + NotificationID string `json:"notification_id"` + + // Success indicates if the notification was sent successfully + Success bool `json:"success"` + + // Message provides additional context about the result + Message string `json:"message,omitempty"` + + // Error contains error details if the notification failed + Error string `json:"error,omitempty"` + + // SentAt is when the notification was sent + SentAt time.Time `json:"sent_at"` + + // ProviderResponse contains raw response data from the notification provider + ProviderResponse map[string]interface{} `json:"provider_response,omitempty"` +} + +// NotificationFilter is used for querying notifications +type NotificationFilter struct { + IDs []string `json:"ids,omitempty"` + Types []NotificationType `json:"types,omitempty"` + Statuses []NotificationStatus `json:"statuses,omitempty"` + Recipients []string `json:"recipients,omitempty"` + CreatedAfter *time.Time `json:"created_after,omitempty"` + CreatedBefore *time.Time `json:"created_before,omitempty"` + Limit int `json:"limit,omitempty"` + Offset int `json:"offset,omitempty"` +} diff --git a/internal/domain/notifier.go b/internal/domain/notifier.go new file mode 100644 index 0000000..8454d5e --- /dev/null +++ b/internal/domain/notifier.go @@ -0,0 +1,67 @@ +package domain + +import ( + "context" +) + +// Notifier is the core interface that all notification implementations must satisfy +type Notifier interface { + // Send sends a notification and returns the result + Send(ctx context.Context, notification *Notification) (*NotificationResult, error) + + // Type returns the notification type this notifier handles + Type() NotificationType + + // Validate checks if a notification can be sent with this notifier + Validate(notification *Notification) error + + // Close performs cleanup when the notifier is no longer needed + Close() error +} + +// NotifierFactory creates notifier instances based on configuration +type NotifierFactory interface { + // Create creates a notifier for the given type + Create(notificationType NotificationType) (Notifier, error) + + // RegisterNotifier registers a custom notifier implementation + RegisterNotifier(notificationType NotificationType, notifier Notifier) error + + // SupportedTypes returns all supported notification types + SupportedTypes() []NotificationType +} + +// NotificationService is the high-level service interface for managing notifications +type NotificationService interface { + // Send queues a notification for delivery + Send(ctx context.Context, notification *Notification) (*NotificationResult, error) + + // SendBatch queues multiple notifications for delivery + SendBatch(ctx context.Context, notifications []*Notification) ([]*NotificationResult, error) + + // GetNotification retrieves a notification by ID + GetNotification(ctx context.Context, id string) (*Notification, error) + + // ListNotifications retrieves notifications matching the filter + ListNotifications(ctx context.Context, filter *NotificationFilter) ([]*Notification, error) + + // CancelNotification cancels a pending notification + CancelNotification(ctx context.Context, id string) error + + // RetryNotification retries a failed notification + RetryNotification(ctx context.Context, id string) (*NotificationResult, error) + + // GetStats returns notification statistics + GetStats(ctx context.Context) (*NotificationStats, error) +} + +// NotificationStats contains statistics about notification processing +type NotificationStats struct { + TotalSent int64 `json:"total_sent"` + TotalFailed int64 `json:"total_failed"` + TotalPending int64 `json:"total_pending"` + TotalQueued int64 `json:"total_queued"` + ByType map[string]int64 `json:"by_type"` + ByStatus map[string]int64 `json:"by_status"` + AverageLatency float64 `json:"average_latency_ms"` +} diff --git a/internal/domain/queue.go b/internal/domain/queue.go new file mode 100644 index 0000000..c4f697d --- /dev/null +++ b/internal/domain/queue.go @@ -0,0 +1,111 @@ +package domain + +import ( + "context" +) + +// QueueMessage wraps a notification with queue-specific metadata +type QueueMessage struct { + // ID is a unique identifier for this queue message + ID string `json:"id"` + + // Notification is the actual notification to be sent + Notification *Notification `json:"notification"` + + // Attempt is the current delivery attempt number + Attempt int `json:"attempt"` + + // EnqueuedAt is when the message was added to the queue + EnqueuedAt int64 `json:"enqueued_at"` +} + +// Queue defines the interface for a notification queue +type Queue interface { + // Enqueue adds a notification to the queue + Enqueue(ctx context.Context, notification *Notification) error + + // EnqueueBatch adds multiple notifications to the queue + EnqueueBatch(ctx context.Context, notifications []*Notification) error + + // Dequeue retrieves the next notification from the queue + // Returns nil if the queue is empty + Dequeue(ctx context.Context) (*QueueMessage, error) + + // Ack acknowledges successful processing of a message + Ack(ctx context.Context, messageID string) error + + // Nack indicates processing failure and may requeue the message + Nack(ctx context.Context, messageID string, requeue bool) error + + // Size returns the current number of messages in the queue + Size(ctx context.Context) (int64, error) + + // Purge removes all messages from the queue + Purge(ctx context.Context) error + + // Close cleanly shuts down the queue + Close() error + + // HealthCheck verifies the queue is operational + HealthCheck(ctx context.Context) error +} + +// QueueConfig contains configuration for queue implementations +type QueueConfig struct { + // Type specifies the queue implementation (local, kafka, etc.) + Type string `mapstructure:"type"` + + // MaxSize is the maximum number of messages the queue can hold + MaxSize int64 `mapstructure:"max_size"` + + // WorkerCount is the number of concurrent workers processing the queue + WorkerCount int `mapstructure:"worker_count"` + + // RetryAttempts is the number of times to retry failed notifications + RetryAttempts int `mapstructure:"retry_attempts"` + + // RetryBackoff is the backoff strategy for retries (exponential, linear, fixed) + RetryBackoff string `mapstructure:"retry_backoff"` + + // Local queue specific config + Local *LocalQueueConfig `mapstructure:"local,omitempty"` + + // Kafka specific config + Kafka *KafkaQueueConfig `mapstructure:"kafka,omitempty"` +} + +// LocalQueueConfig contains configuration for the in-memory queue +type LocalQueueConfig struct { + // BufferSize is the channel buffer size + BufferSize int `mapstructure:"buffer_size"` + + // PersistToDisk enables writing queue state to disk for recovery + PersistToDisk bool `mapstructure:"persist_to_disk"` + + // PersistPath is where to store the queue state + PersistPath string `mapstructure:"persist_path"` +} + +// KafkaQueueConfig contains configuration for Kafka queue +type KafkaQueueConfig struct { + // Brokers is the list of Kafka broker addresses + Brokers []string `mapstructure:"brokers"` + + // Topic is the Kafka topic for notifications + Topic string `mapstructure:"topic"` + + // ConsumerGroup is the Kafka consumer group ID + ConsumerGroup string `mapstructure:"consumer_group"` + + // PartitionCount is the number of partitions for the topic + PartitionCount int `mapstructure:"partition_count"` + + // ReplicationFactor is the replication factor for the topic + ReplicationFactor int `mapstructure:"replication_factor"` + + // EnableIdempotence ensures exactly-once delivery semantics + EnableIdempotence bool `mapstructure:"enable_idempotence"` + + // CompressionType defines compression (none, gzip, snappy, lz4, zstd) + CompressionType string `mapstructure:"compression_type"` +} diff --git a/internal/notifier/notifier.go b/internal/notifier/notifier.go index e69de29..7339c95 100644 --- a/internal/notifier/notifier.go +++ b/internal/notifier/notifier.go @@ -0,0 +1,107 @@ +package notifier + +import ( + "context" + "fmt" + "sync" + + "github.com/igodwin/notifier/internal/domain" +) + +// Factory creates and manages notifier instances +type Factory struct { + notifiers map[domain.NotificationType]domain.Notifier + mu sync.RWMutex +} + +// NewFactory creates a new notifier factory +func NewFactory() *Factory { + return &Factory{ + notifiers: make(map[domain.NotificationType]domain.Notifier), + } +} + +// Create creates a notifier for the given type +func (f *Factory) Create(notificationType domain.NotificationType) (domain.Notifier, error) { + f.mu.RLock() + defer f.mu.RUnlock() + + notifier, exists := f.notifiers[notificationType] + if !exists { + return nil, fmt.Errorf("unsupported notification type: %s", notificationType) + } + + return notifier, nil +} + +// RegisterNotifier registers a custom notifier implementation +func (f *Factory) RegisterNotifier(notificationType domain.NotificationType, notifier domain.Notifier) error { + f.mu.Lock() + defer f.mu.Unlock() + + if _, exists := f.notifiers[notificationType]; exists { + return fmt.Errorf("notifier already registered for type: %s", notificationType) + } + + f.notifiers[notificationType] = notifier + return nil +} + +// SupportedTypes returns all supported notification types +func (f *Factory) SupportedTypes() []domain.NotificationType { + f.mu.RLock() + defer f.mu.RUnlock() + + types := make([]domain.NotificationType, 0, len(f.notifiers)) + for t := range f.notifiers { + types = append(types, t) + } + + return types +} + +// BaseNotifier provides common functionality for all notifiers +type BaseNotifier struct { + notificationType domain.NotificationType +} + +// Type returns the notification type +func (b *BaseNotifier) Type() domain.NotificationType { + return b.notificationType +} + +// Validate performs basic validation common to all notifiers +func (b *BaseNotifier) Validate(notification *domain.Notification) error { + if notification == nil { + return fmt.Errorf("notification is nil") + } + + if len(notification.Recipients) == 0 { + return fmt.Errorf("notification has no recipients") + } + + if notification.Type != b.notificationType { + return fmt.Errorf("notification type mismatch: expected %s, got %s", b.notificationType, notification.Type) + } + + return nil +} + +// Close performs cleanup (default implementation does nothing) +func (b *BaseNotifier) Close() error { + return nil +} + +// ValidateContext checks if the context is valid +func ValidateContext(ctx context.Context) error { + if ctx == nil { + return fmt.Errorf("context is nil") + } + + select { + case <-ctx.Done(): + return ctx.Err() + default: + return nil + } +} diff --git a/internal/notifier/ntfy.go b/internal/notifier/ntfy.go index e69de29..24f5465 100644 --- a/internal/notifier/ntfy.go +++ b/internal/notifier/ntfy.go @@ -0,0 +1,261 @@ +package notifier + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/igodwin/notifier/internal/domain" +) + +// NtfyConfig contains ntfy.sh configuration +type NtfyConfig struct { + // ServerURL is the ntfy server URL (default: https://ntfy.sh) + ServerURL string `mapstructure:"server_url"` + + // Token is the access token for authentication (preferred method) + // Supports both regular tokens (tk_...) and publish tokens + Token string `mapstructure:"token"` + + // Username for basic authentication (alternative to token) + Username string `mapstructure:"username"` + + // Password for basic authentication (alternative to token) + Password string `mapstructure:"password"` + + // DefaultTopic is the default topic if not specified in notification + DefaultTopic string `mapstructure:"default_topic"` + + // InsecureSkipVerify skips TLS verification (for self-hosted servers with self-signed certs) + InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"` +} + +// NtfyNotifier sends notifications via ntfy.sh +type NtfyNotifier struct { + BaseNotifier + config *NtfyConfig + httpClient *http.Client +} + +// ntfyRequest represents the ntfy API request format +type ntfyRequest struct { + Topic string `json:"topic"` + Message string `json:"message"` + Title string `json:"title,omitempty"` + Priority int `json:"priority,omitempty"` + Tags []string `json:"tags,omitempty"` + Click string `json:"click,omitempty"` + Attach string `json:"attach,omitempty"` + Actions []ntfyAction `json:"actions,omitempty"` + Icon string `json:"icon,omitempty"` + Delay string `json:"delay,omitempty"` + Email string `json:"email,omitempty"` +} + +// ntfyAction represents an action button in ntfy +type ntfyAction struct { + Action string `json:"action"` + Label string `json:"label"` + URL string `json:"url,omitempty"` + Body string `json:"body,omitempty"` + Clear bool `json:"clear,omitempty"` +} + +// NewNtfyNotifier creates a new ntfy notifier +func NewNtfyNotifier(config *NtfyConfig) (*NtfyNotifier, error) { + if config == nil { + return nil, fmt.Errorf("ntfy config is required") + } + + if config.ServerURL == "" { + config.ServerURL = "https://ntfy.sh" // Default public ntfy server + } + + // Create HTTP client with optional TLS skip verify + httpClient := &http.Client{ + Timeout: 30 * time.Second, + } + + if config.InsecureSkipVerify { + // For self-hosted servers with self-signed certificates + transport := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + httpClient.Transport = transport + } + + return &NtfyNotifier{ + BaseNotifier: BaseNotifier{ + notificationType: domain.TypeNtfy, + }, + config: config, + httpClient: httpClient, + }, nil +} + +// Send sends a notification via ntfy +func (n *NtfyNotifier) Send(ctx context.Context, notification *domain.Notification) (*domain.NotificationResult, error) { + if err := ValidateContext(ctx); err != nil { + return nil, err + } + + if err := n.Validate(notification); err != nil { + return nil, err + } + + // For ntfy, recipients are topics + recipients := notification.Recipients + if len(recipients) == 0 && n.config.DefaultTopic != "" { + recipients = []string{n.config.DefaultTopic} + } + + for _, topic := range recipients { + req := ntfyRequest{ + Topic: topic, + Message: notification.Body, + Title: notification.Subject, + Priority: n.mapPriority(notification.Priority), + } + + // Add custom tags from metadata + if tags, ok := notification.Metadata["tags"].([]interface{}); ok { + for _, tag := range tags { + if tagStr, ok := tag.(string); ok { + req.Tags = append(req.Tags, tagStr) + } + } + } + + // Add click action from metadata + if click, ok := notification.Metadata["click"].(string); ok { + req.Click = click + } + + // Add attachment from metadata + if attach, ok := notification.Metadata["attach"].(string); ok { + req.Attach = attach + } + + // Add icon from metadata + if icon, ok := notification.Metadata["icon"].(string); ok { + req.Icon = icon + } + + // Add delay from metadata (e.g., "30s", "1m", "1h") + if delay, ok := notification.Metadata["delay"].(string); ok { + req.Delay = delay + } + + // Add email from metadata (for email notifications) + if email, ok := notification.Metadata["email"].(string); ok { + req.Email = email + } + + // Add actions from metadata + if actions, ok := notification.Metadata["actions"].([]interface{}); ok { + for _, action := range actions { + if actionMap, ok := action.(map[string]interface{}); ok { + ntfyAct := ntfyAction{} + if actionType, ok := actionMap["action"].(string); ok { + ntfyAct.Action = actionType + } + if label, ok := actionMap["label"].(string); ok { + ntfyAct.Label = label + } + if url, ok := actionMap["url"].(string); ok { + ntfyAct.URL = url + } + if body, ok := actionMap["body"].(string); ok { + ntfyAct.Body = body + } + if clear, ok := actionMap["clear"].(bool); ok { + ntfyAct.Clear = clear + } + req.Actions = append(req.Actions, ntfyAct) + } + } + } + + if err := n.sendToTopic(ctx, &req); err != nil { + return &domain.NotificationResult{ + NotificationID: notification.ID, + Success: false, + Error: err.Error(), + SentAt: time.Now(), + }, err + } + } + + return &domain.NotificationResult{ + NotificationID: notification.ID, + Success: true, + Message: fmt.Sprintf("Notification sent to %d topics", len(notification.Recipients)), + SentAt: time.Now(), + ProviderResponse: map[string]interface{}{ + "server": n.config.ServerURL, + "topics": notification.Recipients, + }, + }, nil +} + +// sendToTopic sends a notification to a specific ntfy topic +func (n *NtfyNotifier) sendToTopic(ctx context.Context, req *ntfyRequest) error { + url := fmt.Sprintf("%s", n.config.ServerURL) + + jsonData, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("failed to marshal ntfy request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + + // Add authentication if configured + if n.config.Token != "" { + httpReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", n.config.Token)) + } else if n.config.Username != "" && n.config.Password != "" { + httpReq.SetBasicAuth(n.config.Username, n.config.Password) + } + + resp, err := n.httpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("failed to send ntfy notification: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("ntfy server returned status: %d", resp.StatusCode) + } + + return nil +} + +// mapPriority maps domain priority to ntfy priority (1-5) +func (n *NtfyNotifier) mapPriority(priority domain.Priority) int { + switch priority { + case domain.PriorityLow: + return 2 + case domain.PriorityNormal: + return 3 + case domain.PriorityHigh: + return 4 + case domain.PriorityCritical: + return 5 + default: + return 3 + } +} + +// Close closes the HTTP client +func (n *NtfyNotifier) Close() error { + n.httpClient.CloseIdleConnections() + return nil +} diff --git a/internal/notifier/slack.go b/internal/notifier/slack.go new file mode 100644 index 0000000..5b48b9a --- /dev/null +++ b/internal/notifier/slack.go @@ -0,0 +1,215 @@ +package notifier + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/igodwin/notifier/internal/domain" +) + +// SlackConfig contains Slack webhook configuration +type SlackConfig struct { + WebhookURL string `mapstructure:"webhook_url"` + Token string `mapstructure:"token"` + Channel string `mapstructure:"channel"` + Username string `mapstructure:"username"` + IconEmoji string `mapstructure:"icon_emoji"` + Webhooks map[string]string `mapstructure:"webhooks"` // Channel-specific webhooks +} + +// SlackNotifier sends notifications to Slack +type SlackNotifier struct { + BaseNotifier + config *SlackConfig + httpClient *http.Client +} + +// slackMessage represents the Slack API request format +type slackMessage struct { + Channel string `json:"channel,omitempty"` + Username string `json:"username,omitempty"` + IconEmoji string `json:"icon_emoji,omitempty"` + Text string `json:"text,omitempty"` + Blocks []slackBlock `json:"blocks,omitempty"` + Markdown bool `json:"mrkdwn,omitempty"` +} + +// slackBlock represents a Slack block element +type slackBlock struct { + Type string `json:"type"` + Text *slackTextBlock `json:"text,omitempty"` +} + +// slackTextBlock represents a text element in a Slack block +type slackTextBlock struct { + Type string `json:"type"` + Text string `json:"text"` +} + +// NewSlackNotifier creates a new Slack notifier +func NewSlackNotifier(config *SlackConfig) (*SlackNotifier, error) { + if config == nil { + return nil, fmt.Errorf("Slack config is required") + } + + // Either webhook URL or token is required + if config.WebhookURL == "" && config.Token == "" && len(config.Webhooks) == 0 { + return nil, fmt.Errorf("Slack webhook URL, token, or channel webhooks are required") + } + + return &SlackNotifier{ + BaseNotifier: BaseNotifier{ + notificationType: domain.TypeSlack, + }, + config: config, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + }, nil +} + +// Send sends a notification to Slack +func (s *SlackNotifier) Send(ctx context.Context, notification *domain.Notification) (*domain.NotificationResult, error) { + if err := ValidateContext(ctx); err != nil { + return nil, err + } + + if err := s.Validate(notification); err != nil { + return nil, err + } + + // For Slack, recipients are channel names or webhook URLs + for _, recipient := range notification.Recipients { + msg := s.buildMessage(notification, recipient) + webhookURL := s.getWebhookURL(recipient) + + if err := s.sendToSlack(ctx, webhookURL, msg); err != nil { + return &domain.NotificationResult{ + NotificationID: notification.ID, + Success: false, + Error: err.Error(), + SentAt: time.Now(), + }, err + } + } + + return &domain.NotificationResult{ + NotificationID: notification.ID, + Success: true, + Message: fmt.Sprintf("Slack notification sent to %d channels", len(notification.Recipients)), + SentAt: time.Now(), + ProviderResponse: map[string]interface{}{ + "channels": notification.Recipients, + }, + }, nil +} + +// buildMessage constructs a Slack message with rich formatting +func (s *SlackNotifier) buildMessage(notification *domain.Notification, channel string) *slackMessage { + msg := &slackMessage{ + Channel: channel, + Username: s.config.Username, + IconEmoji: s.config.IconEmoji, + Markdown: true, + } + + // Use blocks for rich formatting if both subject and body exist + if notification.Subject != "" && notification.Body != "" { + msg.Blocks = []slackBlock{ + { + Type: "header", + Text: &slackTextBlock{ + Type: "plain_text", + Text: notification.Subject, + }, + }, + { + Type: "section", + Text: &slackTextBlock{ + Type: "mrkdwn", + Text: notification.Body, + }, + }, + } + } else { + // Fallback to simple text + if notification.Subject != "" { + msg.Text = fmt.Sprintf("*%s*\n%s", notification.Subject, notification.Body) + } else { + msg.Text = notification.Body + } + } + + // Add priority indicator for high priority notifications + if notification.Priority >= domain.PriorityHigh { + priorityEmoji := ":warning:" + if notification.Priority == domain.PriorityCritical { + priorityEmoji = ":rotating_light:" + } + + msg.Blocks = append([]slackBlock{ + { + Type: "context", + Text: &slackTextBlock{ + Type: "mrkdwn", + Text: fmt.Sprintf("%s *Priority: %d*", priorityEmoji, notification.Priority), + }, + }, + }, msg.Blocks...) + } + + return msg +} + +// getWebhookURL returns the webhook URL for a specific channel +func (s *SlackNotifier) getWebhookURL(channel string) string { + // Check for channel-specific webhook + if webhook, ok := s.config.Webhooks[channel]; ok { + return webhook + } + + // Fall back to default webhook URL + return s.config.WebhookURL +} + +// sendToSlack sends the message to Slack via webhook +func (s *SlackNotifier) sendToSlack(ctx context.Context, webhookURL string, msg *slackMessage) error { + jsonData, err := json.Marshal(msg) + if err != nil { + return fmt.Errorf("failed to marshal Slack message: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", webhookURL, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + // Add token authentication if configured + if s.config.Token != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.config.Token)) + } + + resp, err := s.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send Slack notification: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("Slack API returned status: %d", resp.StatusCode) + } + + return nil +} + +// Close closes the HTTP client +func (s *SlackNotifier) Close() error { + s.httpClient.CloseIdleConnections() + return nil +} diff --git a/internal/notifier/smtp.go b/internal/notifier/smtp.go index e69de29..319494f 100644 --- a/internal/notifier/smtp.go +++ b/internal/notifier/smtp.go @@ -0,0 +1,137 @@ +package notifier + +import ( + "context" + "fmt" + "net/smtp" + "strings" + "time" + + "github.com/igodwin/notifier/internal/domain" +) + +// SMTPConfig contains SMTP server configuration +type SMTPConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + Username string `mapstructure:"username"` + Password string `mapstructure:"password"` + From string `mapstructure:"from"` + UseTLS bool `mapstructure:"use_tls"` +} + +// SMTPNotifier sends notifications via email using SMTP +type SMTPNotifier struct { + BaseNotifier + config *SMTPConfig +} + +// NewSMTPNotifier creates a new SMTP notifier +func NewSMTPNotifier(config *SMTPConfig) (*SMTPNotifier, error) { + if config == nil { + return nil, fmt.Errorf("SMTP config is required") + } + + if config.Host == "" { + return nil, fmt.Errorf("SMTP host is required") + } + + if config.Port == 0 { + config.Port = 587 // Default SMTP submission port + } + + if config.From == "" { + return nil, fmt.Errorf("SMTP from address is required") + } + + return &SMTPNotifier{ + BaseNotifier: BaseNotifier{ + notificationType: domain.TypeEmail, + }, + config: config, + }, nil +} + +// Send sends a notification via email +func (s *SMTPNotifier) Send(ctx context.Context, notification *domain.Notification) (*domain.NotificationResult, error) { + if err := ValidateContext(ctx); err != nil { + return nil, err + } + + if err := s.Validate(notification); err != nil { + return nil, err + } + + // Validate email recipients + for _, recipient := range notification.Recipients { + if !strings.Contains(recipient, "@") { + return &domain.NotificationResult{ + NotificationID: notification.ID, + Success: false, + Error: fmt.Sprintf("invalid email address: %s", recipient), + SentAt: time.Now(), + }, fmt.Errorf("invalid email address: %s", recipient) + } + } + + // Build email message + message := s.buildMessage(notification) + + // Send email + addr := fmt.Sprintf("%s:%d", s.config.Host, s.config.Port) + auth := smtp.PlainAuth("", s.config.Username, s.config.Password, s.config.Host) + + err := smtp.SendMail(addr, auth, s.config.From, notification.Recipients, []byte(message)) + if err != nil { + return &domain.NotificationResult{ + NotificationID: notification.ID, + Success: false, + Error: err.Error(), + SentAt: time.Now(), + }, fmt.Errorf("failed to send email: %w", err) + } + + return &domain.NotificationResult{ + NotificationID: notification.ID, + Success: true, + Message: fmt.Sprintf("Email sent to %d recipients", len(notification.Recipients)), + SentAt: time.Now(), + ProviderResponse: map[string]interface{}{ + "smtp_server": addr, + "from": s.config.From, + "to": notification.Recipients, + }, + }, nil +} + +// buildMessage constructs the email message with headers +func (s *SMTPNotifier) buildMessage(notification *domain.Notification) string { + var builder strings.Builder + + builder.WriteString(fmt.Sprintf("From: %s\r\n", s.config.From)) + builder.WriteString(fmt.Sprintf("To: %s\r\n", strings.Join(notification.Recipients, ", "))) + builder.WriteString(fmt.Sprintf("Subject: %s\r\n", notification.Subject)) + builder.WriteString("MIME-Version: 1.0\r\n") + builder.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") + builder.WriteString("\r\n") + builder.WriteString(notification.Body) + + return builder.String() +} + +// Validate checks if the notification is valid for SMTP +func (s *SMTPNotifier) Validate(notification *domain.Notification) error { + if err := s.BaseNotifier.Validate(notification); err != nil { + return err + } + + if notification.Subject == "" { + return fmt.Errorf("email subject is required") + } + + if notification.Body == "" { + return fmt.Errorf("email body is required") + } + + return nil +} diff --git a/internal/notifier/stdout.go b/internal/notifier/stdout.go index e69de29..ed861da 100644 --- a/internal/notifier/stdout.go +++ b/internal/notifier/stdout.go @@ -0,0 +1,50 @@ +package notifier + +import ( + "context" + "fmt" + "time" + + "github.com/igodwin/notifier/internal/domain" +) + +// StdoutNotifier sends notifications to stdout (useful for debugging) +type StdoutNotifier struct { + BaseNotifier +} + +// NewStdoutNotifier creates a new stdout notifier +func NewStdoutNotifier() *StdoutNotifier { + return &StdoutNotifier{ + BaseNotifier: BaseNotifier{ + notificationType: domain.TypeStdout, + }, + } +} + +// Send sends a notification to stdout +func (s *StdoutNotifier) Send(ctx context.Context, notification *domain.Notification) (*domain.NotificationResult, error) { + if err := ValidateContext(ctx); err != nil { + return nil, err + } + + if err := s.Validate(notification); err != nil { + return nil, err + } + + fmt.Println("========================================") + fmt.Printf("Notification ID: %s\n", notification.ID) + fmt.Printf("Type: %s\n", notification.Type) + fmt.Printf("Priority: %d\n", notification.Priority) + fmt.Printf("Recipients: %v\n", notification.Recipients) + fmt.Printf("Subject: %s\n", notification.Subject) + fmt.Printf("Body:\n%s\n", notification.Body) + fmt.Println("========================================") + + return &domain.NotificationResult{ + NotificationID: notification.ID, + Success: true, + Message: "Notification printed to stdout", + SentAt: time.Now(), + }, nil +} diff --git a/internal/queue/local.go b/internal/queue/local.go new file mode 100644 index 0000000..cf4f7bd --- /dev/null +++ b/internal/queue/local.go @@ -0,0 +1,297 @@ +package queue + +import ( + "context" + "encoding/json" + "fmt" + "os" + "sync" + "time" + + "github.com/igodwin/notifier/internal/domain" + "github.com/google/uuid" +) + +// LocalQueue is an in-memory queue implementation +type LocalQueue struct { + queue chan *domain.QueueMessage + messages map[string]*domain.QueueMessage + mu sync.RWMutex + config *domain.LocalQueueConfig + persistToDisk bool + persistPath string + closed bool + closeChan chan struct{} +} + +// NewLocalQueue creates a new local queue instance +func NewLocalQueue(config *domain.LocalQueueConfig) (*LocalQueue, error) { + if config == nil { + config = &domain.LocalQueueConfig{ + BufferSize: 1000, + PersistToDisk: false, + } + } + + lq := &LocalQueue{ + queue: make(chan *domain.QueueMessage, config.BufferSize), + messages: make(map[string]*domain.QueueMessage), + config: config, + persistToDisk: config.PersistToDisk, + persistPath: config.PersistPath, + closeChan: make(chan struct{}), + } + + // Load persisted messages if enabled + if lq.persistToDisk && lq.persistPath != "" { + if err := lq.loadFromDisk(); err != nil { + return nil, fmt.Errorf("failed to load persisted queue: %w", err) + } + } + + return lq, nil +} + +// Enqueue adds a notification to the queue +func (lq *LocalQueue) Enqueue(ctx context.Context, notification *domain.Notification) error { + lq.mu.Lock() + defer lq.mu.Unlock() + + if lq.closed { + return fmt.Errorf("queue is closed") + } + + msg := &domain.QueueMessage{ + ID: uuid.New().String(), + Notification: notification, + Attempt: 0, + EnqueuedAt: time.Now().Unix(), + } + + select { + case lq.queue <- msg: + lq.messages[msg.ID] = msg + notification.Status = domain.StatusQueued + + if lq.persistToDisk { + return lq.persistToDiskSync() + } + return nil + case <-ctx.Done(): + return ctx.Err() + case <-lq.closeChan: + return fmt.Errorf("queue is closed") + } +} + +// EnqueueBatch adds multiple notifications to the queue +func (lq *LocalQueue) EnqueueBatch(ctx context.Context, notifications []*domain.Notification) error { + lq.mu.Lock() + defer lq.mu.Unlock() + + if lq.closed { + return fmt.Errorf("queue is closed") + } + + for _, notification := range notifications { + msg := &domain.QueueMessage{ + ID: uuid.New().String(), + Notification: notification, + Attempt: 0, + EnqueuedAt: time.Now().Unix(), + } + + select { + case lq.queue <- msg: + lq.messages[msg.ID] = msg + notification.Status = domain.StatusQueued + case <-ctx.Done(): + return ctx.Err() + case <-lq.closeChan: + return fmt.Errorf("queue is closed") + } + } + + if lq.persistToDisk { + return lq.persistToDiskSync() + } + return nil +} + +// Dequeue retrieves the next notification from the queue +func (lq *LocalQueue) Dequeue(ctx context.Context) (*domain.QueueMessage, error) { + if lq.closed { + return nil, fmt.Errorf("queue is closed") + } + + select { + case msg := <-lq.queue: + lq.mu.Lock() + msg.Attempt++ + msg.Notification.Status = domain.StatusProcessing + lq.mu.Unlock() + return msg, nil + case <-ctx.Done(): + return nil, ctx.Err() + case <-lq.closeChan: + return nil, fmt.Errorf("queue is closed") + } +} + +// Ack acknowledges successful processing of a message +func (lq *LocalQueue) Ack(ctx context.Context, messageID string) error { + lq.mu.Lock() + defer lq.mu.Unlock() + + if msg, exists := lq.messages[messageID]; exists { + msg.Notification.Status = domain.StatusSent + delete(lq.messages, messageID) + + if lq.persistToDisk { + return lq.persistToDiskSync() + } + } + + return nil +} + +// Nack indicates processing failure and may requeue the message +func (lq *LocalQueue) Nack(ctx context.Context, messageID string, requeue bool) error { + lq.mu.Lock() + defer lq.mu.Unlock() + + msg, exists := lq.messages[messageID] + if !exists { + return fmt.Errorf("message not found: %s", messageID) + } + + if requeue { + msg.Notification.Status = domain.StatusRetrying + select { + case lq.queue <- msg: + if lq.persistToDisk { + return lq.persistToDiskSync() + } + return nil + case <-ctx.Done(): + return ctx.Err() + case <-lq.closeChan: + return fmt.Errorf("queue is closed") + } + } else { + msg.Notification.Status = domain.StatusFailed + delete(lq.messages, messageID) + + if lq.persistToDisk { + return lq.persistToDiskSync() + } + } + + return nil +} + +// Size returns the current number of messages in the queue +func (lq *LocalQueue) Size(ctx context.Context) (int64, error) { + lq.mu.RLock() + defer lq.mu.RUnlock() + return int64(len(lq.queue)), nil +} + +// Purge removes all messages from the queue +func (lq *LocalQueue) Purge(ctx context.Context) error { + lq.mu.Lock() + defer lq.mu.Unlock() + + // Drain the channel + for len(lq.queue) > 0 { + <-lq.queue + } + + lq.messages = make(map[string]*domain.QueueMessage) + + if lq.persistToDisk { + return lq.persistToDiskSync() + } + + return nil +} + +// Close cleanly shuts down the queue +func (lq *LocalQueue) Close() error { + lq.mu.Lock() + defer lq.mu.Unlock() + + if lq.closed { + return nil + } + + lq.closed = true + close(lq.closeChan) + + if lq.persistToDisk { + if err := lq.persistToDiskSync(); err != nil { + return err + } + } + + close(lq.queue) + return nil +} + +// HealthCheck verifies the queue is operational +func (lq *LocalQueue) HealthCheck(ctx context.Context) error { + lq.mu.RLock() + defer lq.mu.RUnlock() + + if lq.closed { + return fmt.Errorf("queue is closed") + } + + return nil +} + +// persistToDiskSync persists the queue state to disk (must be called with lock held) +func (lq *LocalQueue) persistToDiskSync() error { + if !lq.persistToDisk || lq.persistPath == "" { + return nil + } + + data, err := json.Marshal(lq.messages) + if err != nil { + return fmt.Errorf("failed to marshal queue state: %w", err) + } + + if err := os.WriteFile(lq.persistPath, data, 0644); err != nil { + return fmt.Errorf("failed to write queue state: %w", err) + } + + return nil +} + +// loadFromDisk loads the queue state from disk +func (lq *LocalQueue) loadFromDisk() error { + if lq.persistPath == "" { + return nil + } + + data, err := os.ReadFile(lq.persistPath) + if err != nil { + if os.IsNotExist(err) { + return nil // No persisted state yet + } + return fmt.Errorf("failed to read queue state: %w", err) + } + + var messages map[string]*domain.QueueMessage + if err := json.Unmarshal(data, &messages); err != nil { + return fmt.Errorf("failed to unmarshal queue state: %w", err) + } + + // Re-enqueue persisted messages + for _, msg := range messages { + lq.queue <- msg + lq.messages[msg.ID] = msg + } + + return nil +} diff --git a/internal/service/service.go b/internal/service/service.go new file mode 100644 index 0000000..c1fab8e --- /dev/null +++ b/internal/service/service.go @@ -0,0 +1,383 @@ +package service + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/igodwin/notifier/internal/domain" +) + +// NotificationService implements the domain.NotificationService interface +type NotificationService struct { + factory domain.NotifierFactory + queue domain.Queue + notifications map[string]*domain.Notification + mu sync.RWMutex + workerCount int + stopChan chan struct{} + wg sync.WaitGroup +} + +// NewNotificationService creates a new notification service +func NewNotificationService(factory domain.NotifierFactory, queue domain.Queue, workerCount int) *NotificationService { + if workerCount <= 0 { + workerCount = 10 + } + + return &NotificationService{ + factory: factory, + queue: queue, + notifications: make(map[string]*domain.Notification), + workerCount: workerCount, + stopChan: make(chan struct{}), + } +} + +// Start starts the worker pool +func (s *NotificationService) Start(ctx context.Context) error { + for i := 0; i < s.workerCount; i++ { + s.wg.Add(1) + go s.worker(ctx, i) + } + return nil +} + +// Stop stops the service gracefully +func (s *NotificationService) Stop() error { + close(s.stopChan) + s.wg.Wait() + return s.queue.Close() +} + +// worker processes notifications from the queue +func (s *NotificationService) worker(ctx context.Context, id int) { + defer s.wg.Done() + + for { + select { + case <-s.stopChan: + return + case <-ctx.Done(): + return + default: + // Try to dequeue with timeout + workerCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + msg, err := s.queue.Dequeue(workerCtx) + cancel() + + if err != nil { + if err == context.DeadlineExceeded { + continue + } + time.Sleep(100 * time.Millisecond) + continue + } + + if msg == nil { + time.Sleep(100 * time.Millisecond) + continue + } + + // Process the notification + s.processNotification(ctx, msg) + } + } +} + +// processNotification sends a notification and handles the result +func (s *NotificationService) processNotification(ctx context.Context, msg *domain.QueueMessage) { + notification := msg.Notification + + // Get the appropriate notifier + notifier, err := s.factory.Create(notification.Type) + if err != nil { + notification.Status = domain.StatusFailed + notification.LastError = fmt.Sprintf("failed to create notifier: %v", err) + s.queue.Nack(ctx, msg.ID, false) + s.updateNotification(notification) + return + } + + // Send the notification + result, err := notifier.Send(ctx, notification) + if err != nil || !result.Success { + notification.RetryCount++ + notification.LastError = result.Error + if err != nil { + notification.LastError = err.Error() + } + + // Check if we should retry + if notification.RetryCount < notification.MaxRetries { + notification.Status = domain.StatusRetrying + s.queue.Nack(ctx, msg.ID, true) // Requeue + } else { + notification.Status = domain.StatusFailed + s.queue.Nack(ctx, msg.ID, false) // Don't requeue + } + } else { + notification.Status = domain.StatusSent + now := time.Now() + notification.SentAt = &now + s.queue.Ack(ctx, msg.ID) + } + + s.updateNotification(notification) +} + +// Send queues a notification for delivery +func (s *NotificationService) Send(ctx context.Context, notification *domain.Notification) (*domain.NotificationResult, error) { + // Store the notification + s.storeNotification(notification) + + // Enqueue for processing + if err := s.queue.Enqueue(ctx, notification); err != nil { + return &domain.NotificationResult{ + NotificationID: notification.ID, + Success: false, + Error: fmt.Sprintf("failed to enqueue: %v", err), + SentAt: time.Now(), + }, err + } + + return &domain.NotificationResult{ + NotificationID: notification.ID, + Success: true, + Message: "notification queued successfully", + SentAt: time.Now(), + }, nil +} + +// SendBatch queues multiple notifications for delivery +func (s *NotificationService) SendBatch(ctx context.Context, notifications []*domain.Notification) ([]*domain.NotificationResult, error) { + results := make([]*domain.NotificationResult, 0, len(notifications)) + + // Store all notifications + for _, notification := range notifications { + s.storeNotification(notification) + } + + // Enqueue batch + if err := s.queue.EnqueueBatch(ctx, notifications); err != nil { + return nil, fmt.Errorf("failed to enqueue batch: %w", err) + } + + // Create results + for _, notification := range notifications { + results = append(results, &domain.NotificationResult{ + NotificationID: notification.ID, + Success: true, + Message: "notification queued successfully", + SentAt: time.Now(), + }) + } + + return results, nil +} + +// GetNotification retrieves a notification by ID +func (s *NotificationService) GetNotification(ctx context.Context, id string) (*domain.Notification, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + notification, exists := s.notifications[id] + if !exists { + return nil, fmt.Errorf("notification not found: %s", id) + } + + return notification, nil +} + +// ListNotifications retrieves notifications matching the filter +func (s *NotificationService) ListNotifications(ctx context.Context, filter *domain.NotificationFilter) ([]*domain.Notification, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + // Simple in-memory filtering + var results []*domain.Notification + + for _, notification := range s.notifications { + if s.matchesFilter(notification, filter) { + results = append(results, notification) + } + } + + // Apply limit and offset + if filter.Offset > 0 && filter.Offset < len(results) { + results = results[filter.Offset:] + } + + if filter.Limit > 0 && filter.Limit < len(results) { + results = results[:filter.Limit] + } + + return results, nil +} + +// CancelNotification cancels a pending notification +func (s *NotificationService) CancelNotification(ctx context.Context, id string) error { + s.mu.Lock() + defer s.mu.Unlock() + + notification, exists := s.notifications[id] + if !exists { + return fmt.Errorf("notification not found: %s", id) + } + + if notification.Status == domain.StatusSent { + return fmt.Errorf("notification already sent") + } + + notification.Status = domain.StatusFailed + notification.LastError = "cancelled by user" + + return nil +} + +// RetryNotification retries a failed notification +func (s *NotificationService) RetryNotification(ctx context.Context, id string) (*domain.NotificationResult, error) { + notification, err := s.GetNotification(ctx, id) + if err != nil { + return nil, err + } + + if notification.Status == domain.StatusSent { + return &domain.NotificationResult{ + NotificationID: id, + Success: false, + Error: "notification already sent", + SentAt: time.Now(), + }, fmt.Errorf("notification already sent") + } + + // Reset retry count and status + notification.RetryCount = 0 + notification.Status = domain.StatusPending + + // Re-enqueue + return s.Send(ctx, notification) +} + +// GetStats returns notification statistics +func (s *NotificationService) GetStats(ctx context.Context) (*domain.NotificationStats, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + stats := &domain.NotificationStats{ + ByType: make(map[string]int64), + ByStatus: make(map[string]int64), + } + + for _, notification := range s.notifications { + switch notification.Status { + case domain.StatusSent: + stats.TotalSent++ + case domain.StatusFailed: + stats.TotalFailed++ + case domain.StatusPending: + stats.TotalPending++ + case domain.StatusQueued: + stats.TotalQueued++ + } + + stats.ByType[string(notification.Type)]++ + stats.ByStatus[string(notification.Status)]++ + } + + return stats, nil +} + +// storeNotification stores a notification in memory +func (s *NotificationService) storeNotification(notification *domain.Notification) { + s.mu.Lock() + defer s.mu.Unlock() + s.notifications[notification.ID] = notification +} + +// updateNotification updates a notification in memory +func (s *NotificationService) updateNotification(notification *domain.Notification) { + s.mu.Lock() + defer s.mu.Unlock() + s.notifications[notification.ID] = notification +} + +// matchesFilter checks if a notification matches the filter +func (s *NotificationService) matchesFilter(notification *domain.Notification, filter *domain.NotificationFilter) bool { + if filter == nil { + return true + } + + // Check IDs + if len(filter.IDs) > 0 { + found := false + for _, id := range filter.IDs { + if notification.ID == id { + found = true + break + } + } + if !found { + return false + } + } + + // Check types + if len(filter.Types) > 0 { + found := false + for _, t := range filter.Types { + if notification.Type == t { + found = true + break + } + } + if !found { + return false + } + } + + // Check statuses + if len(filter.Statuses) > 0 { + found := false + for _, s := range filter.Statuses { + if notification.Status == s { + found = true + break + } + } + if !found { + return false + } + } + + // Check recipients + if len(filter.Recipients) > 0 { + found := false + for _, fr := range filter.Recipients { + for _, nr := range notification.Recipients { + if fr == nr { + found = true + break + } + } + if found { + break + } + } + if !found { + return false + } + } + + // Check time ranges + if filter.CreatedAfter != nil && notification.CreatedAt.Before(*filter.CreatedAfter) { + return false + } + + if filter.CreatedBefore != nil && notification.CreatedAt.After(*filter.CreatedBefore) { + return false + } + + return true +} diff --git a/k8s/configmap.yaml b/k8s/configmap.yaml new file mode 100644 index 0000000..8a837cf --- /dev/null +++ b/k8s/configmap.yaml @@ -0,0 +1,43 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: notifier-config + labels: + app: notifier +data: + config.yaml: | + server: + grpc_port: 50051 + rest_port: 8080 + host: "0.0.0.0" + mode: "both" + + queue: + type: "local" + max_size: 10000 + worker_count: 10 + retry_attempts: 3 + retry_backoff: "exponential" + local: + buffer_size: 1000 + persist_to_disk: false + + notifiers: + stdout: true + + logging: + level: "info" + format: "json" + output_path: "stdout" + + metrics: + enabled: true + port: 9090 + path: "/metrics" + prometheus_enabled: true + + health_check: + enabled: true + port: 8081 + path: "/health" + interval: 30 diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml new file mode 100644 index 0000000..3dc534e --- /dev/null +++ b/k8s/deployment.yaml @@ -0,0 +1,97 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: notifier + labels: + app: notifier + version: v1 +spec: + replicas: 3 + selector: + matchLabels: + app: notifier + template: + metadata: + labels: + app: notifier + version: v1 + spec: + serviceAccountName: notifier + containers: + - name: notifier + image: notifier:latest + imagePullPolicy: Always + ports: + - name: rest + containerPort: 8080 + protocol: TCP + - name: grpc + containerPort: 50051 + protocol: TCP + - name: metrics + containerPort: 9090 + protocol: TCP + - name: health + containerPort: 8081 + protocol: TCP + env: + - name: NOTIFIER_SERVER_MODE + value: "both" + - name: NOTIFIER_LOGGING_LEVEL + value: "info" + - name: NOTIFIER_LOGGING_FORMAT + value: "json" + - name: NOTIFIER_QUEUE_TYPE + value: "local" + volumeMounts: + - name: config + mountPath: /app/config.yaml + subPath: config.yaml + readOnly: true + - name: queue-storage + mountPath: /var/lib/notifier + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + livenessProbe: + httpGet: + path: /health + port: health + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: health + initialDelaySeconds: 10 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + securityContext: + runAsNonRoot: true + runAsUser: 1000 + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + capabilities: + drop: + - ALL + volumes: + - name: config + configMap: + name: notifier-config + - name: queue-storage + emptyDir: {} + restartPolicy: Always +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: notifier + labels: + app: notifier diff --git a/k8s/hpa.yaml b/k8s/hpa.yaml new file mode 100644 index 0000000..4ff488e --- /dev/null +++ b/k8s/hpa.yaml @@ -0,0 +1,43 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: notifier-hpa + labels: + app: notifier +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: notifier + minReplicas: 3 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 80 + behavior: + scaleDown: + stabilizationWindowSeconds: 300 + policies: + - type: Percent + value: 50 + periodSeconds: 60 + scaleUp: + stabilizationWindowSeconds: 60 + policies: + - type: Percent + value: 100 + periodSeconds: 60 + - type: Pods + value: 2 + periodSeconds: 60 + selectPolicy: Max diff --git a/k8s/ingress.yaml b/k8s/ingress.yaml new file mode 100644 index 0000000..078f5c4 --- /dev/null +++ b/k8s/ingress.yaml @@ -0,0 +1,26 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: notifier-ingress + labels: + app: notifier + annotations: + kubernetes.io/ingress.class: nginx + cert-manager.io/cluster-issuer: letsencrypt-prod + nginx.ingress.kubernetes.io/ssl-redirect: "true" +spec: + tls: + - hosts: + - notifier.example.com + secretName: notifier-tls + rules: + - host: notifier.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: notifier-rest + port: + number: 8080 diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml new file mode 100644 index 0000000..476f0d5 --- /dev/null +++ b/k8s/kustomization.yaml @@ -0,0 +1,20 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: notifier + +resources: + - deployment.yaml + - service.yaml + - configmap.yaml + - ingress.yaml + - hpa.yaml + +commonLabels: + app: notifier + managed-by: kustomize + +images: + - name: notifier + newName: your-registry/notifier + newTag: latest diff --git a/k8s/secret-example.yaml b/k8s/secret-example.yaml new file mode 100644 index 0000000..93b0114 --- /dev/null +++ b/k8s/secret-example.yaml @@ -0,0 +1,45 @@ +# Example secret for notifier credentials +# DO NOT commit actual secrets to version control +# Use sealed-secrets, external-secrets, or vault in production + +apiVersion: v1 +kind: Secret +metadata: + name: notifier-secrets + labels: + app: notifier +type: Opaque +stringData: + # SMTP credentials + smtp-username: "your-email@gmail.com" + smtp-password: "your-app-password" + + # Slack webhook URL + slack-webhook-url: "https://hooks.slack.com/services/YOUR/WEBHOOK/URL" + + # Ntfy token + ntfy-token: "tk_your_token" + + # Kafka credentials (if using Kafka) + kafka-username: "kafka-user" + kafka-password: "kafka-password" + +--- +# Example of using secrets in deployment +# Add this to deployment.yaml under spec.template.spec.containers[0].env + +# - name: NOTIFIER_NOTIFIERS_SMTP_USERNAME +# valueFrom: +# secretKeyRef: +# name: notifier-secrets +# key: smtp-username +# - name: NOTIFIER_NOTIFIERS_SMTP_PASSWORD +# valueFrom: +# secretKeyRef: +# name: notifier-secrets +# key: smtp-password +# - name: NOTIFIER_NOTIFIERS_SLACK_WEBHOOK_URL +# valueFrom: +# secretKeyRef: +# name: notifier-secrets +# key: slack-webhook-url diff --git a/k8s/service.yaml b/k8s/service.yaml new file mode 100644 index 0000000..d9f584a --- /dev/null +++ b/k8s/service.yaml @@ -0,0 +1,50 @@ +apiVersion: v1 +kind: Service +metadata: + name: notifier-rest + labels: + app: notifier + service: rest +spec: + type: ClusterIP + ports: + - port: 8080 + targetPort: rest + protocol: TCP + name: http + selector: + app: notifier +--- +apiVersion: v1 +kind: Service +metadata: + name: notifier-grpc + labels: + app: notifier + service: grpc +spec: + type: ClusterIP + ports: + - port: 50051 + targetPort: grpc + protocol: TCP + name: grpc + selector: + app: notifier +--- +apiVersion: v1 +kind: Service +metadata: + name: notifier-metrics + labels: + app: notifier + service: metrics +spec: + type: ClusterIP + ports: + - port: 9090 + targetPort: metrics + protocol: TCP + name: metrics + selector: + app: notifier