├── .github └── workflows │ └── ci-cd.yaml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── api └── proto │ └── notify.proto ├── cmd ├── server │ └── main.go └── worker │ └── main.go ├── config ├── config.example.yaml └── config.go ├── db └── migrations │ └── 20250207175052_init_schema.sql ├── deployments ├── docker-compose.yaml ├── docker │ ├── Dockerfile.grpc │ └── Dockerfile.worker ├── loki │ └── local-config.yaml └── prometheus │ └── prometheus.yaml ├── docs ├── Architecture Diagram.excalidraw └── Architecture Diagram.png ├── go.mod ├── go.sum └── internal ├── consumer └── consumer.go ├── grpc └── server.go ├── producer └── producer.go ├── ratelimiter └── ratelimiter.go ├── repository └── db.go └── service └── ntfy.go /.github/workflows/ci-cd.yaml: -------------------------------------------------------------------------------- 1 | name: CI/CD Pipeline 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | concurrency: 10 | group: ci-cd-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | build-test: 15 | name: Build, Test & Lint 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v4 21 | 22 | - name: Set up Go 23 | uses: actions/setup-go@v5 24 | with: 25 | go-version: 1.23 26 | 27 | - name: Cache Go modules 28 | uses: actions/cache@v4 29 | with: 30 | path: | 31 | ~/.cache/go-build 32 | ~/.local/share/go/pkg/mod 33 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 34 | 35 | - name: Install Protobuf Compiler 36 | run: | 37 | sudo apt-get update 38 | sudo apt-get install -y protobuf-compiler 39 | 40 | - name: Install Go protobuf plugins 41 | run: | 42 | go install google.golang.org/protobuf/cmd/protoc-gen-go@latest 43 | go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest 44 | echo "$(go env GOPATH)/bin" >> $GITHUB_PATH 45 | 46 | - name: Generate Protobuf files 47 | run: | 48 | mkdir -p api/generated 49 | make proto 50 | 51 | - name: Upload generated proto files artifact 52 | uses: actions/upload-artifact@v4 53 | with: 54 | name: generated-files 55 | path: api/generated 56 | 57 | - name: Ensure dependencies are up-to-date 58 | run: go mod tidy 59 | 60 | - name: Run tests 61 | run: make test 62 | 63 | - name: Lint code with golangci-lint 64 | run: | 65 | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh \ 66 | | sh -s -- -b $(go env GOPATH)/bin v1.64.7 67 | golangci-lint run 68 | 69 | - name: Build the binary 70 | run: make build 71 | 72 | docker-build: 73 | name: Docker Build & Push 74 | runs-on: ubuntu-latest 75 | needs: build-test 76 | 77 | steps: 78 | - name: Checkout repository 79 | uses: actions/checkout@v4 80 | 81 | - name: Download generated proto files artifact 82 | uses: actions/download-artifact@v4 83 | with: 84 | name: generated-files 85 | path: api/generated 86 | 87 | - name: Install docker-compose 88 | run: | 89 | sudo apt-get update 90 | sudo apt-get install -y docker-compose 91 | 92 | - name: Login to DockerHub 93 | uses: docker/login-action@v3 94 | with: 95 | username: ${{ secrets.DOCKER_USERNAME }} 96 | password: ${{ secrets.DOCKER_PASSWORD }} 97 | 98 | - name: Build Docker images using docker-compose 99 | run: docker-compose -f deployments/docker-compose.yaml build 100 | 101 | - name: Push Docker images using docker-compose 102 | run: docker-compose -f deployments/docker-compose.yaml push 103 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/.DS_Store 2 | config/config.yaml 3 | api/generated/* 4 | 5 | # If you prefer the allow list template instead of the deny list, see community template: 6 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 7 | # 8 | # Binaries for programs and plugins 9 | *.exe 10 | *.exe~ 11 | *.dll 12 | *.so 13 | *.dylib 14 | 15 | # Test binary, built with `go test -c` 16 | *.test 17 | 18 | # Output of the go coverage tool, specifically when used with LiteIDE 19 | *.out 20 | 21 | # Dependency directories (remove the comment below to include it) 22 | # vendor/ 23 | 24 | # Go workspace file 25 | go.work 26 | go.work.sum 27 | 28 | # env file 29 | .env 30 | 31 | # binary files 32 | bin/ 33 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official email address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | **siddharthsingh2014@gmail.com**. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for your interest in contributing to **go-notify**! We welcome contributions from the community. 4 | Please review the following guidelines to help you get started. 5 | 6 | ## **Table of Contents** 7 | 8 | - [Getting Started](#getting-started) 9 | - [Local Development Setup](#local-development-setup) 10 | - [Docker Setup](#docker-setup) 11 | - [Running Tests](#running-tests) 12 | - [Code Style](#code-style) 13 | - [Pull Request Process](#pull-request-process) 14 | - [Reporting Issues](#reporting-issues) 15 | - [License](#license) 16 | 17 | ## **Getting Started** 18 | 19 | Before you begin, please ensure that you have read our [Code of Conduct](CODE_OF_CONDUCT.md) and that you understand our contribution workflow. 20 | To start: 21 | 22 | 1. **Fork** the repository. 23 | 24 | 2. **Clone** your fork locally: 25 | 26 | ```bash 27 | git clone https://github.com/yourusername/go-notify.git 28 | cd go-notify 29 | ``` 30 | 31 | ## **Local Development Setup** 32 | 33 | ### **Prerequisites** 34 | 35 | - [Go](https://golang.org/dl/) (version 1.22 or later is recommended) 36 | - Git 37 | 38 | 1. **Install Dependencies** 39 | 40 | This project leverages Go modules. Download all required dependencies with: 41 | 42 | ```bash 43 | go mod download 44 | ``` 45 | 46 | 2. **Build the Application** 47 | 48 | Build the executable locally: 49 | 50 | ```bash 51 | go build -o bin/go-notify . 52 | ``` 53 | 54 | 3. **Run the Application** 55 | 56 | Execute the binary: 57 | 58 | ```bash 59 | ./bin/go-notify 60 | ``` 61 | 62 | 4. **Development Workflow** 63 | 64 | - Create a new branch for your feature or bug fix: 65 | 66 | ```bash 67 | git checkout -b feature/your-feature-name 68 | ``` 69 | 70 | - Make your changes and test locally. 71 | 72 | - Run linters to ensure code quality. 73 | 74 | ```bash 75 | make lint 76 | ``` 77 | 78 | ## **Docker Setup** 79 | 80 | This project provides a Docker configuration to simplify the setup and testing process. 81 | 82 | ### **Prerequisites** 83 | 84 | - [Docker](https://docs.docker.com/get-docker/) 85 | 86 | 1. **Building the Docker Image** 87 | 88 | Create the Docker image by running: 89 | 90 | ```bash 91 | make docker-build 92 | ``` 93 | 94 | 2. **Running the Docker Container** 95 | 96 | After building the image, start a container and map the necessary ports: 97 | 98 | ```bash 99 | make docker-up 100 | ``` 101 | 102 | ## **Running Tests** 103 | 104 | Before opening a pull request, please ensure that all tests pass: 105 | 106 | ```bash 107 | make test 108 | ``` 109 | 110 | If any tests fail, address the issues before submitting your contribution. 111 | 112 | ## **Code Style** 113 | 114 | - Follow Go’s official Effective Go guidelines. 115 | 116 | - Write clear and descriptive commit messages. 117 | 118 | - Maintain consistency in code formatting. 119 | 120 | - Use linters to identify potential issues: 121 | ```bash 122 | make lint 123 | ``` 124 | 125 | ## **Pull Request Process** 126 | 127 | 1. **Fork the repository and create your feature branch:** 128 | 129 | ```bash 130 | git checkout -b feature/your-feature-name 131 | ``` 132 | 133 | 2. **Commit your changes with a descriptive message.** 134 | 135 | 3. **Push your branch to your fork:** 136 | 137 | ```bash 138 | git push origin feature/your-feature-name 139 | ``` 140 | 141 | 4. **Open a pull request against the main repository with a detailed description of your changes.** 142 | 143 | 5. **Participate in code review and make any necessary adjustments.** 144 | 145 | ## **Reporting Issues** 146 | 147 | If you encounter a bug or have a suggestion for an improvement, please help us improve go-notify by opening an issue on our [GitHub Issues](https://github.com/officiallysidsingh/go-notify/issues) page. When creating an issue, try to include: 148 | 149 | - A clear description of the problem or enhancement 150 | - Steps to reproduce the issue (if applicable) 151 | - Any relevant error messages or logs 152 | - Information about your environment (OS, Go version, etc.) 153 | 154 | This information helps us diagnose and address problems more efficiently. 155 | 156 | ## **License** 157 | 158 | All contributions to **go-notify** are made available under the terms of the project's [LICENSE](LICENSE). By contributing, you agree that your contributions will be distributed under this license. Please review the license file for more details. 159 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Siddharth Singh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build run worker proto migrate-up migrate-down docker-up docker-down clean restart-rabbitmq test docker-build 2 | 3 | # Variables 4 | DB_URL := "postgres://notify:notify_pass@localhost:5432/go_notify?sslmode=disable" 5 | MIGRATION_DIR := db/migrations 6 | PROTO_PATH := api/proto 7 | GENERATED_DIR := api/generated 8 | 9 | # Directories 10 | BIN_DIR := bin 11 | CMD_DIR := ./cmd 12 | SERVER_CMD := $(CMD_DIR)/server 13 | WORKER_CMD := $(CMD_DIR)/worker 14 | 15 | # Build both server and worker binaries 16 | build: 17 | mkdir -p $(BIN_DIR) 18 | go build -o $(BIN_DIR)/server $(SERVER_CMD)/main.go 19 | go build -o $(BIN_DIR)/worker $(WORKER_CMD)/main.go 20 | 21 | # Run gRPC server (using built binary) 22 | run: build 23 | $(BIN_DIR)/server 24 | 25 | # Run worker service (using built binary) 26 | worker: build 27 | $(BIN_DIR)/worker 28 | 29 | # Generate protobuf code 30 | proto: 31 | protoc --go_out=$(GENERATED_DIR) --go-grpc_out=$(GENERATED_DIR) --go_opt=paths=source_relative --go-grpc_opt=paths=source_relative --proto_path=$(PROTO_PATH) $(PROTO_PATH)/*.proto 32 | 33 | # Build Docker images 34 | docker-build: 35 | docker-compose -f ./deployments/docker-compose.yaml build 36 | 37 | # Start Docker containers 38 | docker-up: 39 | docker-compose -f ./deployments/docker-compose.yaml up --build -d && make migrate-up 40 | 41 | # Stop Docker containers 42 | docker-down: 43 | docker-compose -f ./deployments/docker-compose.yaml down -v 44 | 45 | # Restart RabbitMQ 46 | restart-rabbitmq: 47 | docker restart rabbitmq 48 | 49 | # Run tests 50 | test: 51 | go test ./tests -v 52 | 53 | # Database migrations 54 | migrate-up: 55 | goose -dir $(MIGRATION_DIR) postgres $(DB_URL) up 56 | 57 | migrate-down: 58 | goose -dir $(MIGRATION_DIR) postgres $(DB_URL) down 59 | 60 | # Clean build artifacts 61 | clean: 62 | rm -rf $(BIN_DIR) 63 | 64 | # Lint 65 | lint: 66 | golangci-lint run 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # **GoNotify – Event-Driven Notification System** 2 | 3 | 🚀 **GoNotify** is a lightweight, event-driven notification system built with **Golang** and **RabbitMQ**, designed to seamlessly integrate with any backend server. It enables reliable, asynchronous notification delivery across multiple channels, ensuring scalability and high performance. 4 | 5 | ## **Key Features** 6 | 7 | ✅ **Seamless Integration** – Works with any backend (REST, GraphQL, gRPC).\ 8 | ✅ **Event-Driven Architecture** – Decoupled and scalable notification handling.\ 9 | ✅ **RabbitMQ-Based Queuing** – Ensures reliable and asynchronous processing.\ 10 | ✅ **Multi-Channel Support** – Easily extendable to Email, SMS, WebSockets, Push.\ 11 | ✅ **Observability** – Built-in logging, metrics, and monitoring with Prometheus & Grafana.\ 12 | ✅ **High Performance & Scalability** – Optimized for real-time event handling. 13 | 14 | ## **Tech Stack** 15 | 16 | 🔹 **Golang** – High-performance backend development\ 17 | 🔹 **RabbitMQ** – Message broker for async event processing\ 18 | 🔹 **Prometheus & Grafana** – Monitoring, logging, and observability\ 19 | 🔹 **PostgreSQL** – Notification status storage\ 20 | 🔹 **Redis** – Rate limiting notifications 21 | 22 | ## **Use Case** 23 | 24 | Ideal for **e-commerce, SaaS, fintech, and microservices**, GoNotify enables real-time notifications for order updates, system alerts, and user engagement, ensuring a responsive and scalable event-driven architecture. 25 | 26 | ## **Design Decisions for GoNotify** 27 | 28 | ### Event-Driven Architecture 29 | 30 | - **RabbitMQ** is chosen over **Kafka** for the following reasons: 31 | - **Message Acknowledgment & Delivery Guarantees**: RabbitMQ’s robust message acknowledgment mechanism ensures reliable message delivery. 32 | - **Routing & Fan-out Patterns**: RabbitMQ supports Direct Exchange, useful for routing notifications (email, SMS, push). 33 | - **Lower Throughput Requirement**: RabbitMQ is ideal for scenarios where the focus is on reliability over massive throughput. 34 | 35 | ### Tech Stack Decisions 36 | 37 | #### gRPC vs REST 38 | 39 | - **gRPC**: Chosen for internal communication between microservices, offering better performance and bi-directional streaming. 40 | - **REST**: Used for communication with third-party services like **Twilio** and **SendGrid** for external notifications. 41 | - **Decision**: Hybrid Approach — **gRPC** for internal calls, **REST** for external third-party integrations. 42 | 43 | #### Message Broker: RabbitMQ 44 | 45 | - **Queues**: Separate queues per notification type (email, SMS, push). 46 | - **Exchanges**: Direct, Topic, and Fan-out exchanges are configured for routing notifications to appropriate channels. 47 | - **Dead Letter Queue (DLQ)**: Implemented for retrying failed notifications. 48 | 49 | #### Database: PostgreSQL + Redis 50 | 51 | - **PostgreSQL**: Used for storing **notification logs**, offering ACID properties and relational capabilities. 52 | - **Redis**: Utilized for **rate limiting**, ensuring notifications are not sent too frequently. 53 | 54 | ### Observability 55 | 56 | - **Prometheus** and **Grafana** are used for **metrics and monitoring**, providing insights into system performance. 57 | - **Loki** is used for **logging**, enabling efficient storage and querying of logs. 58 | 59 | ### Deployment 60 | 61 | - **Docker** containers for consistent environments across development and production. 62 | 63 | ## Architecture Diagram 64 | 65 | ![Architecture Diagram](https://github.com/user-attachments/assets/8858dd74-74e3-4189-a366-23c6924026cf) 66 | 67 | ## **Prerequisites** 68 | 69 | - [Docker](https://www.docker.com/get-started) 70 | - [Docker Compose](https://docs.docker.com/compose/) 71 | 72 | ## **Installation** 73 | 74 | 1. **Clone this repository:** 75 | 76 | ```bash 77 | git clone https://github.com/your-username/go-notify.git 78 | cd go-notify 79 | ``` 80 | 81 | 2. **Configure the environment:** 82 | Update the configuration in config/config.yaml as needed. 83 | You can also refer to config/config.example.yaml for environment variable settings. 84 | 85 | 3. **Run the services using Docker Compose:** 86 | 87 | ```bash 88 | make docker-up 89 | ``` 90 | 91 | This command will start RabbitMQ, PostgreSQL, gRPC server, worker, Prometheus, Grafana, and Loki. 92 | 93 | 4. **Access the Services:** 94 | 95 | - gRPC Server: localhost:50051 96 | - Prometheus Metrics: localhost:9090/metrics 97 | - Grafana Dashboard: localhost:3000 (default login: admin/admin) 98 | - RabbitMQ UI: localhost:15672 99 | 100 | ## **Testing** 101 | 102 | - **Run tests locally:** 103 | 104 | ```bash 105 | make test 106 | ``` 107 | 108 | - **Generate Protobuf files:** 109 | 110 | ```bash 111 | make proto 112 | ``` 113 | 114 | ## **CI/CD** 115 | 116 | This repository uses GitHub Actions for continuous integration and delivery. The workflow is defined in `/.github/workflows/ci-cd.yml` and covers: 117 | 118 | - **Linting** 119 | - **Testing** 120 | - **Building** 121 | - **Docker image creation** 122 | 123 | ## **License** 124 | 125 | Distributed under the MIT License. See `LICENSE` for more information. 126 | 127 | ## **Contributing** 128 | 129 | Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. 130 | -------------------------------------------------------------------------------- /api/proto/notify.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package notify; 4 | 5 | option go_package = "github.com/officiallysidsingh/go-notify/api/generated"; 6 | 7 | service NotificationService { 8 | rpc SendNotification (NotificationRequest) returns (NotificationResponse); 9 | rpc GetNotificationStatus (StatusRequest) returns (StatusResponse); 10 | } 11 | 12 | message NotificationRequest { 13 | string user_id = 1; 14 | string title = 2; 15 | string priority = 3; 16 | string message = 4; 17 | string type = 5; 18 | } 19 | 20 | message NotificationResponse { 21 | bool success = 1; 22 | string error = 2; 23 | } 24 | 25 | message StatusRequest { 26 | int32 notification_id = 1; 27 | } 28 | 29 | message StatusResponse { 30 | string status = 1; 31 | string error = 2; 32 | } 33 | -------------------------------------------------------------------------------- /cmd/server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/officiallysidsingh/go-notify/config" 10 | "github.com/prometheus/client_golang/prometheus/promhttp" 11 | "go.uber.org/zap" 12 | 13 | pb "github.com/officiallysidsingh/go-notify/api/generated" 14 | grpcserver "github.com/officiallysidsingh/go-notify/internal/grpc" 15 | "github.com/officiallysidsingh/go-notify/internal/producer" 16 | "github.com/officiallysidsingh/go-notify/internal/ratelimiter" 17 | "github.com/officiallysidsingh/go-notify/internal/repository" 18 | 19 | "google.golang.org/grpc" 20 | "google.golang.org/grpc/health" 21 | "google.golang.org/grpc/health/grpc_health_v1" 22 | "google.golang.org/grpc/reflection" 23 | ) 24 | 25 | func main() { 26 | // Load configuration from the config folder. 27 | config.LoadConfig("./config") 28 | 29 | // Structured logging 30 | logger, err := zap.NewProduction() 31 | if err != nil { 32 | panic(err) 33 | } 34 | sugar := logger.Sugar() 35 | defer func() { 36 | if err := logger.Sync(); err != nil { 37 | sugar.Errorw("failed to sync logger", "error", err) 38 | } 39 | }() 40 | 41 | // Start Prometheus metrics HTTP server in separate goroutine 42 | go func() { 43 | sugar.Infof("Starting metrics server on %s", config.AppConfig.Metrics.Port) 44 | http.Handle("/metrics", promhttp.Handler()) 45 | if err := http.ListenAndServe(config.AppConfig.Metrics.Port, nil); err != nil { 46 | sugar.Fatalf("Metrics HTTP server failed: %v", err) 47 | } 48 | }() 49 | 50 | // Init RabbitMQ Producer 51 | producer, err := producer.NewProducer( 52 | config.AppConfig.RabbitMQ.URL, 53 | ) 54 | if err != nil { 55 | sugar.Fatalf("Failed to initialize RabbitMQ: %v", err) 56 | } 57 | defer producer.Close() 58 | 59 | // Connect to Postgres DB 60 | database, err := repository.NewDB(config.AppConfig.Postgres) 61 | if err != nil { 62 | sugar.Fatalf("Failed to initialize PostgresDB: %v", err) 63 | } 64 | 65 | defer func() { 66 | if err := database.Close(); err != nil { 67 | log.Printf("error closing database: %v", err) 68 | } 69 | }() 70 | 71 | // Convert the Redis window from string to time.Duration 72 | redisWindowDuration, err := time.ParseDuration(config.AppConfig.Redis.Window) 73 | if err != nil { 74 | log.Fatalf("Invalid Redis window duration: %v", err) 75 | } 76 | 77 | // Connect to Rate Limiter 78 | limiter := ratelimiter.NewRateLimiter( 79 | config.AppConfig.Redis.Addr, 80 | config.AppConfig.Redis.Limit, 81 | redisWindowDuration, 82 | ) 83 | 84 | // Start gRPC Server 85 | listener, err := net.Listen("tcp", config.AppConfig.GRPC.Port) 86 | if err != nil { 87 | sugar.Fatalf("Failed to listen on port %s: %v", config.AppConfig.GRPC.Port, err) 88 | } 89 | 90 | // Create gRPC server with integrated notification service 91 | server := grpcserver.NewNotificationServer(producer, database, limiter) 92 | grpcServer := grpc.NewServer() 93 | pb.RegisterNotificationServiceServer(grpcServer, server) 94 | 95 | // Register gRPC health service 96 | healthServer := health.NewServer() 97 | grpc_health_v1.RegisterHealthServer(grpcServer, healthServer) 98 | 99 | // Mark the server as serving 100 | healthServer.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING) 101 | 102 | // Register reflection service for debugging 103 | reflection.Register(grpcServer) 104 | 105 | sugar.Infof("gRPC server running on %s", config.AppConfig.GRPC.Port) 106 | if err := grpcServer.Serve(listener); err != nil { 107 | sugar.Fatalf("Failed to serve gRPC server: %v", err) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /cmd/worker/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/officiallysidsingh/go-notify/config" 7 | "github.com/officiallysidsingh/go-notify/internal/consumer" 8 | "github.com/officiallysidsingh/go-notify/internal/repository" 9 | ) 10 | 11 | func main() { 12 | // Load configuration from the config folder 13 | config.LoadConfig("./config") 14 | 15 | // Connect to PostgreSQL 16 | dbConn, err := repository.NewDB(config.AppConfig.Postgres) 17 | if err != nil { 18 | log.Fatalf("Failed to initialize PostgresDB: %v", err) 19 | } 20 | 21 | defer func() { 22 | if err := dbConn.Close(); err != nil { 23 | log.Printf("error closing dbConn: %v", err) 24 | } 25 | }() 26 | 27 | // Create a new consumer with a global worker pool 28 | consumer, err := consumer.NewConsumer(config.AppConfig.RabbitMQ.URL, 10, dbConn) 29 | if err != nil { 30 | log.Fatalf("Failed to initialize consumer: %v", err) 31 | } 32 | 33 | // Define queues 34 | queues := []string{ 35 | "dead_letter_queue", 36 | "queue_email", 37 | "queue_sms", 38 | "queue_push", 39 | } 40 | 41 | // Start the consumer 42 | if err := consumer.Start(queues); err != nil { 43 | log.Fatalf("Failed to start consumer: %v", err) 44 | } 45 | 46 | log.Println("Consumer is up and running, waiting for messages...") 47 | 48 | // Block forever 49 | select {} 50 | } 51 | -------------------------------------------------------------------------------- /config/config.example.yaml: -------------------------------------------------------------------------------- 1 | grpc: 2 | port: ":50051" # gRPC server port 3 | 4 | rabbitmq: 5 | url: "amqp://user:password@rabbitmq_host:5672/" # RabbitMQ connection URL 6 | 7 | postgres: 8 | DataSourceName: "postgres://notify:notify_pass@localhost:5432/go_notify?sslmode=disable" # PostgreSQL DataSourceName 9 | MaxOpenConns: 100 # PostgreSQL MaxOpenConns 10 | MaxIdleConns: 25 # PostgreSQL MaxIdleConns 11 | ConnMaxLifetime: 20 * time.Minute # PostgreSQL ConnMaxLifetime 12 | ConnMaxIdleTime: 5 * time.Minute # PostgreSQL ConnMaxIdleTime 13 | ConnTimeout: 3 * time.Second # PostgreSQL ConnTimeout 14 | 15 | redis: 16 | addr: "redis_host:6379" # Redis address 17 | limit: 5 # Rate limiting: max requests 18 | window: "1m" # Rate limiting window duration 19 | 20 | metrics: 21 | port: ":9091" # Metrics server port 22 | 23 | logging: 24 | level: "info" # Logging level (debug, info, warn, error) 25 | 26 | ntfy: 27 | topic: "notification-topic" # Topic for push notifications 28 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "log" 5 | "strings" 6 | "time" 7 | 8 | "github.com/spf13/viper" 9 | ) 10 | 11 | type GRPCConfig struct { 12 | Port string 13 | } 14 | 15 | type RabbitMQConfig struct { 16 | URL string 17 | } 18 | 19 | type PostgresConfig struct { 20 | DataSourceName string 21 | MaxOpenConns int 22 | MaxIdleConns int 23 | ConnMaxLifetime time.Duration 24 | ConnMaxIdleTime time.Duration 25 | ConnTimeout time.Duration 26 | } 27 | 28 | type RedisConfig struct { 29 | Addr string 30 | Limit int 31 | Window string 32 | } 33 | 34 | type MetricsConfig struct { 35 | Port string 36 | } 37 | 38 | type LoggingConfig struct { 39 | Level string 40 | } 41 | 42 | type NtfyConfig struct { 43 | Topic string 44 | } 45 | 46 | // Holds all configuration values. 47 | type Config struct { 48 | GRPC GRPCConfig 49 | RabbitMQ RabbitMQConfig 50 | Postgres PostgresConfig 51 | Redis RedisConfig 52 | Metrics MetricsConfig 53 | Logging LoggingConfig 54 | Ntfy NtfyConfig 55 | } 56 | 57 | // Global config instance 58 | var AppConfig *Config 59 | 60 | // Load config 61 | func LoadConfig(path string) { 62 | viper.SetConfigName("config") 63 | viper.SetConfigType("yaml") 64 | viper.AddConfigPath(path) 65 | 66 | // Allow overriding with env variables 67 | viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) 68 | viper.AutomaticEnv() 69 | 70 | if err := viper.ReadInConfig(); err != nil { 71 | log.Printf("No config file found: %v", err) 72 | } 73 | 74 | AppConfig = &Config{ 75 | GRPC: GRPCConfig{ 76 | Port: viper.GetString("grpc.port"), 77 | }, 78 | RabbitMQ: RabbitMQConfig{ 79 | URL: viper.GetString("rabbitmq.url"), 80 | }, 81 | Postgres: PostgresConfig{ 82 | DataSourceName: viper.GetString("postgres.DataSourceName"), 83 | MaxOpenConns: viper.GetInt("postgres.MaxOpenConns"), 84 | MaxIdleConns: viper.GetInt("postgres.MaxIdleConns"), 85 | ConnMaxLifetime: viper.GetDuration("postgres.ConnMaxLifetime"), 86 | ConnMaxIdleTime: viper.GetDuration("postgres.ConnMaxIdleTime"), 87 | ConnTimeout: viper.GetDuration("postgres.ConnTimeout"), 88 | }, 89 | Redis: RedisConfig{ 90 | Addr: viper.GetString("redis.addr"), 91 | Limit: viper.GetInt("redis.limit"), 92 | Window: viper.GetString("redis.window"), 93 | }, 94 | Metrics: MetricsConfig{ 95 | Port: viper.GetString("metrics.port"), 96 | }, 97 | Logging: LoggingConfig{ 98 | Level: viper.GetString("logging.level"), 99 | }, 100 | Ntfy: NtfyConfig{ 101 | Topic: viper.GetString("ntfy.topic"), 102 | }, 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /db/migrations/20250207175052_init_schema.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | CREATE TABLE notifications ( 3 | id SERIAL PRIMARY KEY, 4 | user_id TEXT NOT NULL, 5 | message TEXT NOT NULL, 6 | status TEXT DEFAULT 'pending', 7 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 8 | ); 9 | 10 | -- +goose Down 11 | DROP TABLE notifications; 12 | -------------------------------------------------------------------------------- /deployments/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | rabbitmq: 3 | image: "rabbitmq:3-management" 4 | container_name: rabbitmq 5 | ports: 6 | - "5672:5672" 7 | - "15672:15672" 8 | environment: 9 | RABBITMQ_DEFAULT_USER: guest 10 | RABBITMQ_DEFAULT_PASS: guest 11 | healthcheck: 12 | test: ["CMD", "rabbitmq-diagnostics", "status"] 13 | interval: 10s 14 | timeout: 10s 15 | retries: 5 16 | 17 | postgres: 18 | image: postgres:15 19 | container_name: postgres 20 | restart: always 21 | ports: 22 | - "5432:5432" 23 | environment: 24 | POSTGRES_USER: notify 25 | POSTGRES_PASSWORD: notify_pass 26 | POSTGRES_DB: go_notify 27 | volumes: 28 | - postgres_data:/var/lib/postgresql/data 29 | 30 | grpc-server: 31 | build: 32 | context: ../ 33 | dockerfile: deployments/docker/Dockerfile.grpc 34 | container_name: go-notify-grpc 35 | environment: 36 | - GRPC_PORT=:50051 37 | - METRICS_PORT=:9091 38 | - RABBITMQ_QUEUE=notifications 39 | - RABBITMQ_URL=amqp://guest:guest@rabbitmq:5672/ 40 | - POSTGRES_DATASOURCENAME=postgres://notify:notify_pass@postgres:5432/go_notify?sslmode=disable 41 | - POSTGRES_MAXOPENCONNS=100 42 | - POSTGRES_MAXIDLECONNS=25 43 | - POSTGRES_CONNMAXLIFETIME=20m 44 | - POSTGRES_CONNMAXIDLETIME=5m 45 | - POSTGRES_CONNTIMEOUT=3s 46 | - REDIS_ADDR=redis:6379 47 | - REDIS_LIMIT=5 48 | - REDIS_WINDOW=1m 49 | ports: 50 | - "50051:50051" 51 | - "9091:9090" 52 | depends_on: 53 | rabbitmq: 54 | condition: service_healthy 55 | postgres: 56 | condition: service_started 57 | healthcheck: 58 | test: ["CMD", "grpc-health-probe", "-addr=:50051"] 59 | interval: 10s 60 | timeout: 5s 61 | retries: 3 62 | 63 | worker: 64 | build: 65 | context: ../ 66 | dockerfile: deployments/docker/Dockerfile.worker 67 | container_name: go-notify-worker 68 | environment: 69 | - NTFY_TOPIC=go-notify-sid 70 | - RABBITMQ_QUEUE=notifications 71 | - RABBITMQ_URL=amqp://guest:guest@rabbitmq:5672/ 72 | - POSTGRES_DATASOURCENAME=postgres://notify:notify_pass@postgres:5432/go_notify?sslmode=disable 73 | - POSTGRES_MAXOPENCONNS=100 74 | - POSTGRES_MAXIDLECONNS=25 75 | - POSTGRES_CONNMAXLIFETIME=20m 76 | - POSTGRES_CONNMAXIDLETIME=5m 77 | - POSTGRES_CONNTIMEOUT=3s 78 | depends_on: 79 | rabbitmq: 80 | condition: service_healthy 81 | postgres: 82 | condition: service_started 83 | grpc-server: 84 | condition: service_healthy 85 | 86 | redis: 87 | image: redis:7-alpine 88 | container_name: redis 89 | ports: 90 | - "6379:6379" 91 | 92 | prometheus: 93 | image: prom/prometheus:latest 94 | container_name: prometheus 95 | volumes: 96 | - ./prometheus/prometheus.yaml:/etc/prometheus/prometheus.yaml 97 | ports: 98 | - "9090:9090" 99 | 100 | grafana: 101 | image: grafana/grafana:latest 102 | container_name: grafana 103 | ports: 104 | - "3000:3000" 105 | environment: 106 | - GF_SECURITY_ADMIN_PASSWORD=admin 107 | depends_on: 108 | - prometheus 109 | 110 | loki: 111 | image: grafana/loki:latest 112 | container_name: loki 113 | ports: 114 | - "3100:3100" 115 | command: -config.file=/etc/loki/local-config.yaml 116 | user: "root" 117 | volumes: 118 | - ./loki/local-config.yaml:/etc/loki/local-config.yaml 119 | - loki-data:/loki-data 120 | 121 | volumes: 122 | postgres_data: 123 | loki-data: 124 | -------------------------------------------------------------------------------- /deployments/docker/Dockerfile.grpc: -------------------------------------------------------------------------------- 1 | # Use the official Golang image as the base image 2 | FROM golang:1.23 AS builder 3 | WORKDIR /app 4 | 5 | # Copy go.mod and go.sum files to the working directory 6 | COPY go.mod go.sum ./ 7 | RUN go mod download 8 | 9 | COPY . . 10 | 11 | # Download grpc-health-probe. 12 | RUN curl -L https://github.com/grpc-ecosystem/grpc-health-probe/releases/download/v0.4.37/grpc_health_probe-linux-amd64 \ 13 | -o /usr/local/bin/grpc-health-probe && \ 14 | chmod +x /usr/local/bin/grpc-health-probe 15 | 16 | # Disable CGO to build a fully static binary 17 | ENV CGO_ENABLED=0 18 | 19 | # Build the gRPC server binary. 20 | RUN go build -o grpc_service ./cmd/server/main.go 21 | 22 | FROM debian:bullseye-slim 23 | 24 | RUN apt-get update && \ 25 | apt-get install -y bash ca-certificates && \ 26 | rm -rf /var/lib/apt/lists/* 27 | 28 | WORKDIR /app 29 | 30 | COPY --from=builder /app/grpc_service /app/grpc_service 31 | 32 | # Copy the grpc-health-probe binary from the builder stage. 33 | COPY --from=builder /usr/local/bin/grpc-health-probe /usr/local/bin/grpc-health-probe 34 | 35 | EXPOSE 50051 36 | 37 | # Command to run the gRPC service 38 | CMD ["/app/grpc_service"] 39 | -------------------------------------------------------------------------------- /deployments/docker/Dockerfile.worker: -------------------------------------------------------------------------------- 1 | # Use the official Golang image as the base image for building 2 | FROM golang:1.23 AS builder 3 | WORKDIR /app 4 | 5 | # Copy go.mod and go.sum files to the working directory 6 | COPY go.mod go.sum ./ 7 | RUN go mod download 8 | 9 | COPY . . 10 | 11 | # Disable CGO to build a fully static binary 12 | ENV CGO_ENABLED=0 13 | 14 | # Build the gRPC server binary 15 | RUN go build -o worker_service ./cmd/worker/main.go 16 | 17 | FROM debian:bullseye-slim 18 | 19 | RUN apt-get update && \ 20 | apt-get install -y bash ca-certificates && \ 21 | rm -rf /var/lib/apt/lists/* 22 | 23 | WORKDIR /app 24 | 25 | COPY --from=builder /app/worker_service /app/worker_service 26 | 27 | # Command to run the worker service 28 | CMD ["/app/worker_service"] 29 | -------------------------------------------------------------------------------- /deployments/loki/local-config.yaml: -------------------------------------------------------------------------------- 1 | auth_enabled: false 2 | 3 | server: 4 | http_listen_port: 3100 5 | 6 | ingester: 7 | lifecycler: 8 | address: 127.0.0.1 9 | ring: 10 | kvstore: 11 | store: inmemory 12 | replication_factor: 1 13 | wal: 14 | dir: /loki-data/wal 15 | 16 | schema_config: 17 | configs: 18 | - from: 2020-10-24 19 | store: tsdb 20 | object_store: filesystem 21 | schema: v13 22 | index: 23 | prefix: index_ 24 | period: 24h 25 | 26 | storage_config: 27 | tsdb_shipper: 28 | active_index_directory: /loki-data/index 29 | cache_location: /loki-data/tsdb-cache 30 | boltdb_shipper: 31 | active_index_directory: /loki-data/index 32 | cache_location: /loki-data/boltdb-cache 33 | filesystem: 34 | directory: /loki-data/chunks 35 | 36 | compactor: 37 | working_directory: /loki-data/compactor 38 | 39 | table_manager: 40 | retention_deletes_enabled: false 41 | retention_period: 0s 42 | -------------------------------------------------------------------------------- /deployments/prometheus/prometheus.yaml: -------------------------------------------------------------------------------- 1 | global: 2 | scrape_interval: 15s 3 | 4 | scrape_configs: 5 | - job_name: "go-notify" 6 | static_configs: 7 | - targets: ["grpc-server:9090"] 8 | -------------------------------------------------------------------------------- /docs/Architecture Diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/officiallysidsingh/go-notify/93601f49a5809cf249d96b371a2a6c8c667c2966/docs/Architecture Diagram.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/officiallysidsingh/go-notify 2 | 3 | go 1.23 4 | 5 | require ( 6 | github.com/DATA-DOG/go-sqlmock v1.5.2 7 | github.com/go-redis/redis/v8 v8.11.5 8 | github.com/jmoiron/sqlx v1.4.0 9 | github.com/lib/pq v1.10.9 10 | github.com/prometheus/client_golang v1.20.5 11 | github.com/spf13/viper v1.19.0 12 | github.com/streadway/amqp v1.1.0 13 | github.com/stretchr/testify v1.9.0 14 | go.uber.org/zap v1.27.0 15 | google.golang.org/grpc v1.70.0 16 | google.golang.org/protobuf v1.36.1 17 | ) 18 | 19 | require ( 20 | github.com/beorn7/perks v1.0.1 // indirect 21 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 22 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 23 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 24 | github.com/fsnotify/fsnotify v1.7.0 // indirect 25 | github.com/hashicorp/hcl v1.0.0 // indirect 26 | github.com/klauspost/compress v1.17.9 // indirect 27 | github.com/magiconair/properties v1.8.7 // indirect 28 | github.com/mitchellh/mapstructure v1.5.0 // indirect 29 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 30 | github.com/onsi/gomega v1.36.2 // indirect 31 | github.com/pelletier/go-toml/v2 v2.2.2 // indirect 32 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 33 | github.com/prometheus/client_model v0.6.1 // indirect 34 | github.com/prometheus/common v0.55.0 // indirect 35 | github.com/prometheus/procfs v0.15.1 // indirect 36 | github.com/sagikazarmark/locafero v0.4.0 // indirect 37 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 38 | github.com/sourcegraph/conc v0.3.0 // indirect 39 | github.com/spf13/afero v1.11.0 // indirect 40 | github.com/spf13/cast v1.6.0 // indirect 41 | github.com/spf13/pflag v1.0.5 // indirect 42 | github.com/subosito/gotenv v1.6.0 // indirect 43 | go.uber.org/multierr v1.11.0 // indirect 44 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect 45 | golang.org/x/net v0.33.0 // indirect 46 | golang.org/x/sys v0.28.0 // indirect 47 | golang.org/x/text v0.21.0 // indirect 48 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a // indirect 49 | gopkg.in/ini.v1 v1.67.0 // indirect 50 | gopkg.in/yaml.v3 v3.0.1 // indirect 51 | ) 52 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= 4 | github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= 5 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 6 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 7 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 8 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 9 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 12 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 14 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 15 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 16 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 17 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= 18 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 19 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 20 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 21 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 22 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 23 | github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= 24 | github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= 25 | github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= 26 | github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= 27 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 28 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 29 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 30 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 31 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 32 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 33 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 34 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 35 | github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= 36 | github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= 37 | github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= 38 | github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= 39 | github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= 40 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 41 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 42 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 43 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 44 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 45 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 46 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 47 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 48 | github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= 49 | github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 50 | github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 51 | github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 52 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 53 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 54 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 55 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 56 | github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= 57 | github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= 58 | github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= 59 | github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= 60 | github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= 61 | github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= 62 | github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= 63 | github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= 64 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 65 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 66 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 67 | github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= 68 | github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= 69 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 70 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 71 | github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= 72 | github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= 73 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 74 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 75 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 76 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 77 | github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= 78 | github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= 79 | github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= 80 | github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= 81 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 82 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 83 | github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= 84 | github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= 85 | github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= 86 | github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 87 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 88 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 89 | github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= 90 | github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= 91 | github.com/streadway/amqp v1.1.0 h1:py12iX8XSyI7aN/3dUT8DFIDJazNJsVJdxNVEpnQTZM= 92 | github.com/streadway/amqp v1.1.0/go.mod h1:WYSrTEYHOXHd0nwFeUXAe2G2hRnQT+deZJJf88uS9Bg= 93 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 94 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 95 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 96 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 97 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 98 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 99 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 100 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 101 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 102 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 103 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 104 | go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U= 105 | go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg= 106 | go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M= 107 | go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8= 108 | go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4= 109 | go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU= 110 | go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU= 111 | go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ= 112 | go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM= 113 | go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8= 114 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 115 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 116 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 117 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 118 | go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= 119 | go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 120 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= 121 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= 122 | golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= 123 | golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 124 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 125 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 126 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 127 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 128 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a h1:hgh8P4EuoxpsuKMXX/To36nOFD7vixReXgn8lPGnt+o= 129 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU= 130 | google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ= 131 | google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw= 132 | google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= 133 | google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 134 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 135 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 136 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 137 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 138 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 139 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 140 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 141 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 142 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 143 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 144 | -------------------------------------------------------------------------------- /internal/consumer/consumer.go: -------------------------------------------------------------------------------- 1 | package consumer 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "log" 7 | "sync" 8 | "time" 9 | 10 | "github.com/officiallysidsingh/go-notify/config" 11 | "github.com/officiallysidsingh/go-notify/internal/repository" 12 | "github.com/officiallysidsingh/go-notify/internal/service" 13 | "github.com/streadway/amqp" 14 | ) 15 | 16 | // Payload from RabbitMQ 17 | type NotificationMessage struct { 18 | NotificationID int64 `json:"notification_id"` 19 | UserID string `json:"user_id"` 20 | Title string `json:"title"` 21 | Priority string `json:"priority"` 22 | Message string `json:"message"` 23 | Type string `json:"type"` 24 | } 25 | 26 | // Message wraps a RabbitMQ delivery with its queue name 27 | type Message struct { 28 | QueueName string 29 | Delivery amqp.Delivery 30 | } 31 | 32 | // Consumer encapsulates the logic for consuming messages 33 | type Consumer struct { 34 | conn *amqp.Connection 35 | ch *amqp.Channel 36 | dbConn *repository.DB 37 | msgChannel chan Message 38 | workers int 39 | wg sync.WaitGroup 40 | } 41 | 42 | // Create a new Consumer instance 43 | func NewConsumer(amqpURL string, workers int, db *repository.DB) (*Consumer, error) { 44 | // Connect to RabbitMQ 45 | conn, err := amqp.Dial(amqpURL) 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | ch, err := conn.Channel() 51 | if err != nil { 52 | if err := conn.Close(); err != nil { 53 | log.Printf("error closing connection: %v", err) 54 | } 55 | return nil, err 56 | } 57 | 58 | return &Consumer{ 59 | conn: conn, 60 | ch: ch, 61 | dbConn: db, 62 | workers: workers, 63 | msgChannel: make(chan Message, 100), 64 | }, nil 65 | } 66 | 67 | // Consume messages from multiple queues 68 | func (c *Consumer) Start(queues []string) error { 69 | // Enable Dead Lettering 70 | queueArgs := amqp.Table{ 71 | "x-dead-letter-exchange": "dead_letter_exchange", 72 | "x-dead-letter-routing-key": "dead_letter", 73 | } 74 | 75 | // Declare and start a consumer for each queue 76 | for _, queueName := range queues { 77 | var args amqp.Table 78 | 79 | // No args in dead_letter_queue 80 | if queueName == "dead_letter_queue" { 81 | args = nil 82 | } else { 83 | args = queueArgs 84 | } 85 | 86 | // Declare the queue to ensure it exists 87 | _, err := c.ch.QueueDeclare( 88 | queueName, 89 | true, 90 | false, 91 | false, 92 | false, 93 | args, 94 | ) 95 | if err != nil { 96 | return err 97 | } 98 | 99 | // Consume messages 100 | msgs, err := c.ch.Consume( 101 | queueName, 102 | "", 103 | false, 104 | false, 105 | false, 106 | false, 107 | nil, 108 | ) 109 | if err != nil { 110 | return err 111 | } 112 | 113 | // Push messages from each queue into the global msgChannel 114 | go func(q string, deliveries <-chan amqp.Delivery) { 115 | for d := range deliveries { 116 | c.msgChannel <- Message{QueueName: q, Delivery: d} 117 | } 118 | }(queueName, msgs) 119 | } 120 | 121 | // Start worker goroutines. 122 | for i := 0; i < c.workers; i++ { 123 | c.wg.Add(1) 124 | go c.worker() 125 | } 126 | 127 | return nil 128 | } 129 | 130 | // Process messages from the global msgChannel 131 | func (c *Consumer) worker() { 132 | defer c.wg.Done() 133 | for msg := range c.msgChannel { 134 | c.processMessage(msg) 135 | } 136 | } 137 | 138 | // Handle single message with its own context 139 | func (c *Consumer) processMessage(msg Message) { 140 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 141 | defer cancel() 142 | 143 | // In DLQ, simply log the message for manual intervention 144 | if msg.QueueName == "dead_letter_queue" { 145 | log.Printf("Received DLQ message: %s", string(msg.Delivery.Body)) 146 | // Acknowledge the message to remove it from the DLQ 147 | if err := msg.Delivery.Ack(false); err != nil { 148 | log.Printf("Error acknowledging DLQ message: %v", err) 149 | } 150 | return 151 | } 152 | 153 | // For main queues, unmarshal the message 154 | var notifMsg NotificationMessage 155 | err := json.Unmarshal(msg.Delivery.Body, ¬ifMsg) 156 | if err != nil { 157 | log.Printf("Error unmarshaling message from queue %s: %v", msg.QueueName, err) 158 | // Reject the message without requeueing 159 | if nackErr := msg.Delivery.Nack(false, false); nackErr != nil { 160 | log.Printf("Error sending Nack for queue %s: %v", msg.QueueName, nackErr) 161 | } 162 | return 163 | } 164 | 165 | log.Printf("Processing notification %d from %s", notifMsg.NotificationID, msg.QueueName) 166 | 167 | // Process the message based on which queue it came from 168 | switch msg.QueueName { 169 | case "queue_email": 170 | // TODO: Implement email notification 171 | println("Make SendEmailNotification Service") 172 | case "queue_sms": 173 | // TODO: Implement SMS notification 174 | println("Make SendSMSNotification Service") 175 | case "queue_push": 176 | err = service.SendPushNotification( 177 | config.AppConfig.Ntfy.Topic, 178 | notifMsg.Title, 179 | notifMsg.Priority, 180 | notifMsg.Message, 181 | ) 182 | default: 183 | log.Printf("Unknown queue: %s", msg.QueueName) 184 | } 185 | 186 | if err != nil { 187 | log.Printf( 188 | "Failed to process notification %d from queue %s: %v", 189 | notifMsg.NotificationID, 190 | msg.QueueName, 191 | err, 192 | ) 193 | 194 | // For updating the status to "failed" 195 | err = c.dbConn.UpdateNotificationStatus(ctx, notifMsg.NotificationID, "failed") 196 | if err != nil { 197 | log.Printf("Failed updating status: %v", err) 198 | } 199 | 200 | // Requeue the message after a short delay 201 | time.Sleep(2 * time.Second) 202 | err = msg.Delivery.Nack(false, true) 203 | if err != nil { 204 | log.Printf("Error sending Nack for queue %s: %v", msg.QueueName, err) 205 | } 206 | return 207 | } 208 | 209 | // Update DB status to "sent" on successful processing 210 | if err := c.dbConn.UpdateNotificationStatus(ctx, notifMsg.NotificationID, "sent"); err != nil { 211 | log.Printf( 212 | "Failed to update notification status for notification %d: %v", 213 | notifMsg.NotificationID, 214 | err, 215 | ) 216 | if err := msg.Delivery.Nack(false, true); err != nil { 217 | log.Printf("Error sending Nack for queue %s: %v", msg.QueueName, err) 218 | } 219 | return 220 | } 221 | 222 | // Acknowledge successful processing 223 | if err := msg.Delivery.Ack(false); err != nil { 224 | log.Printf("Error sending Ack for queue %s: %v", msg.QueueName, err) 225 | } else { 226 | log.Printf("Notification %d processed and sent from queue %s.", notifMsg.NotificationID, msg.QueueName) 227 | } 228 | } 229 | 230 | // Stop gracefully shuts down the consumer. 231 | func (c *Consumer) Stop() { 232 | close(c.msgChannel) 233 | c.wg.Wait() 234 | 235 | if err := c.ch.Close(); err != nil { 236 | log.Printf("error closing channel: %v", err) 237 | } 238 | 239 | if err := c.conn.Close(); err != nil { 240 | log.Printf("error closing consumer connection: %v", err) 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /internal/grpc/server.go: -------------------------------------------------------------------------------- 1 | package grpc 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "log" 9 | 10 | "github.com/prometheus/client_golang/prometheus" 11 | 12 | pb "github.com/officiallysidsingh/go-notify/api/generated" 13 | "github.com/officiallysidsingh/go-notify/internal/producer" 14 | "github.com/officiallysidsingh/go-notify/internal/ratelimiter" 15 | "github.com/officiallysidsingh/go-notify/internal/repository" 16 | ) 17 | 18 | // NotificationMessage defines the payload published to RabbitMQ. 19 | type NotificationMessage struct { 20 | NotificationID int64 `json:"notification_id"` 21 | UserID string `json:"user_id"` 22 | Title string `json:"title"` 23 | Priority string `json:"priority"` 24 | Message string `json:"message"` 25 | Type string `json:"type"` 26 | } 27 | 28 | // Prometheus total notification counter 29 | var notificationsReceived = prometheus.NewCounter( 30 | prometheus.CounterOpts{ 31 | Name: "notifications_received_total", 32 | Help: "Total number of notifications received via gRPC", 33 | }, 34 | ) 35 | 36 | type NotificationServer struct { 37 | pb.UnimplementedNotificationServiceServer 38 | producer *producer.RabbitMQProducer 39 | db *repository.DB 40 | rateLimiter *ratelimiter.RateLimiter 41 | } 42 | 43 | // Init prometheus counter 44 | func init() { 45 | prometheus.MustRegister(notificationsReceived) 46 | } 47 | 48 | // Init gRPC server 49 | func NewNotificationServer( 50 | producer *producer.RabbitMQProducer, 51 | db *repository.DB, 52 | limiter *ratelimiter.RateLimiter, 53 | ) *NotificationServer { 54 | return &NotificationServer{ 55 | producer: producer, 56 | db: db, 57 | rateLimiter: limiter, 58 | } 59 | } 60 | 61 | // To handle notification requests 62 | func (s *NotificationServer) SendNotification( 63 | ctx context.Context, 64 | req *pb.NotificationRequest, 65 | ) ( 66 | *pb.NotificationResponse, 67 | error, 68 | ) { 69 | // Rate limiting 70 | allowed, err := s.rateLimiter.Allow(ctx, req.UserId) 71 | if err != nil { 72 | log.Printf("Rate limiter error: %v", err) 73 | return &pb.NotificationResponse{ 74 | Success: false, 75 | Error: "Rate limiter error", 76 | }, errors.New( 77 | "rate limiter error", 78 | ) 79 | } 80 | if !allowed { 81 | return &pb.NotificationResponse{ 82 | Success: false, 83 | Error: "Rate limit exceeded", 84 | }, errors.New( 85 | "rate limit exceeded", 86 | ) 87 | } 88 | 89 | notificationsReceived.Inc() 90 | log.Printf("Received notification request for user: %s", req.UserId) 91 | 92 | // Insert notification into db 93 | notificationID, err := s.db.InsertNotification(ctx, req.UserId, req.Message, "pending") 94 | if err != nil { 95 | return &pb.NotificationResponse{Success: false, Error: err.Error()}, err 96 | } 97 | 98 | // Prepare payload 99 | payload := NotificationMessage{ 100 | NotificationID: notificationID, 101 | UserID: req.UserId, 102 | Title: req.Title, 103 | Priority: req.Priority, 104 | Message: req.Message, 105 | Type: req.Type, 106 | } 107 | data, err := json.Marshal(payload) 108 | if err != nil { 109 | return &pb.NotificationResponse{Success: false, Error: err.Error()}, err 110 | } 111 | 112 | // Publish the payload to RabbitMQ 113 | err = s.producer.Publish("notification_exchange_topic", req.Type, string(data)) 114 | if err != nil { 115 | return &pb.NotificationResponse{Success: false, Error: err.Error()}, err 116 | } 117 | 118 | return &pb.NotificationResponse{Success: true}, nil 119 | } 120 | 121 | func (s *NotificationServer) GetNotificationStatus( 122 | ctx context.Context, 123 | req *pb.StatusRequest, 124 | ) ( 125 | *pb.StatusResponse, 126 | error, 127 | ) { 128 | var status string 129 | query := "SELECT status FROM notifications WHERE id=$1" 130 | 131 | err := s.db.Conn.Get(&status, query, req.NotificationId) 132 | if err != nil { 133 | return &pb.StatusResponse{ 134 | Status: "", 135 | Error: fmt.Sprintf("Notification not found: %v", err.Error()), 136 | }, err 137 | } 138 | 139 | return &pb.StatusResponse{ 140 | Status: status, 141 | }, nil 142 | } 143 | -------------------------------------------------------------------------------- /internal/producer/producer.go: -------------------------------------------------------------------------------- 1 | package producer 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "time" 7 | 8 | "github.com/streadway/amqp" 9 | ) 10 | 11 | type RabbitMQProducer struct { 12 | conn *amqp.Connection 13 | channel *amqp.Channel 14 | } 15 | 16 | // Init RabbitMQ Producer 17 | func NewProducer(url string) (*RabbitMQProducer, error) { 18 | var conn *amqp.Connection 19 | var err error 20 | 21 | // Retry connection up to 5 times 22 | for i := range [5]int{} { 23 | conn, err = amqp.Dial(url) 24 | if err == nil { 25 | break 26 | } 27 | log.Printf("RabbitMQ connection failed. Retrying... (%d/5)", i+1) 28 | time.Sleep(2 * time.Second) 29 | } 30 | if err != nil { 31 | return nil, fmt.Errorf("failed to connect to RabbitMQ: %w", err) 32 | } 33 | 34 | ch, err := conn.Channel() 35 | if err != nil { 36 | return nil, fmt.Errorf("failed to open a channel: %w", err) 37 | } 38 | 39 | producer := &RabbitMQProducer{ 40 | conn: conn, 41 | channel: ch, 42 | } 43 | 44 | // Ensure exchanges and queues are created 45 | if err := producer.SetupExchangesAndQueues(); err != nil { 46 | producer.Close() // Close connection on failure 47 | return nil, fmt.Errorf("failed to setup exchanges and queues: %w", err) 48 | } 49 | 50 | return producer, nil 51 | } 52 | 53 | // Declare necessary exchanges and queues 54 | func (p *RabbitMQProducer) SetupExchangesAndQueues() error { 55 | // Declaring Dead Letter Exchange (DLX) 56 | if err := p.channel.ExchangeDeclare( 57 | "dead_letter_exchange", 58 | "direct", 59 | true, 60 | false, 61 | false, 62 | false, 63 | nil, 64 | ); err != nil { 65 | return err 66 | } 67 | 68 | // Declaring Dead Letter Queue (DLQ) 69 | _, err := p.channel.QueueDeclare( 70 | "dead_letter_queue", 71 | true, 72 | false, 73 | false, 74 | false, 75 | nil, 76 | ) 77 | if err != nil { 78 | return err 79 | } 80 | 81 | // Bind DLQ to DLX with fixed routing key 82 | if err := p.channel.QueueBind( 83 | "dead_letter_queue", 84 | "dead_letter", 85 | "dead_letter_exchange", 86 | false, 87 | nil, 88 | ); err != nil { 89 | return err 90 | } 91 | 92 | // Declaring Topic Exchange for Notification Type based routing 93 | if err := p.channel.ExchangeDeclare( 94 | "notification_exchange_topic", 95 | "topic", 96 | true, 97 | false, 98 | false, 99 | false, 100 | nil, 101 | ); err != nil { 102 | return err 103 | } 104 | 105 | // Declaring Fanout Exchange for broadcasting to all channels 106 | if err := p.channel.ExchangeDeclare( 107 | "notification_exchange_fanout", 108 | "fanout", 109 | true, 110 | false, 111 | false, 112 | false, 113 | nil, 114 | ); err != nil { 115 | return err 116 | } 117 | 118 | // Enable Dead Lettering 119 | queueArgs := amqp.Table{ 120 | "x-dead-letter-exchange": "dead_letter_exchange", 121 | "x-dead-letter-routing-key": "dead_letter", 122 | } 123 | 124 | // Queues for each notification type 125 | queues := []struct { 126 | Name string 127 | BindingKey string 128 | }{ 129 | {"queue_email", "email"}, 130 | {"queue_sms", "sms"}, 131 | {"queue_push", "push"}, 132 | } 133 | 134 | for _, q := range queues { 135 | // Declare each notification type queue 136 | _, err := p.channel.QueueDeclare( 137 | q.Name, 138 | true, 139 | false, 140 | false, 141 | false, 142 | queueArgs, 143 | ) 144 | if err != nil { 145 | return err 146 | } 147 | 148 | // Bind queue to topic exchange 149 | if err := p.channel.QueueBind( 150 | q.Name, 151 | q.BindingKey, 152 | "notification_exchange_topic", 153 | false, 154 | nil, 155 | ); err != nil { 156 | return err 157 | } 158 | 159 | // Bind queue to fanout exchange 160 | if err := p.channel.QueueBind( 161 | q.Name, 162 | "", 163 | "notification_exchange_fanout", 164 | false, 165 | nil, 166 | ); err != nil { 167 | return err 168 | } 169 | } 170 | 171 | return nil 172 | } 173 | 174 | // To send a message to the queue 175 | func (p *RabbitMQProducer) Publish(exchange, routingKey, message string) error { 176 | var err error 177 | 178 | // Retry upto 3 times 179 | for i := range [3]int{} { 180 | err = p.channel.Publish( 181 | exchange, 182 | routingKey, 183 | false, 184 | false, 185 | amqp.Publishing{ 186 | ContentType: "application/json", 187 | Body: []byte(message), 188 | DeliveryMode: amqp.Persistent, 189 | }, 190 | ) 191 | if err == nil { 192 | return nil 193 | } 194 | 195 | log.Printf("Failed to publish message. Retrying... (%d/3)", i+1) 196 | 197 | // Wait before retrying 198 | time.Sleep(1 * time.Second) 199 | } 200 | 201 | return fmt.Errorf("failed to publish message after retries: %w", err) 202 | } 203 | 204 | // To shut down connection 205 | func (p *RabbitMQProducer) Close() { 206 | if p.channel != nil { 207 | if err := p.channel.Close(); err != nil { 208 | log.Printf("error closing producer channel: %v", err) 209 | } 210 | } 211 | if p.conn != nil { 212 | if err := p.conn.Close(); err != nil { 213 | log.Printf("error closing producer connection: %v", err) 214 | } 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /internal/ratelimiter/ratelimiter.go: -------------------------------------------------------------------------------- 1 | package ratelimiter 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/go-redis/redis/v8" 9 | ) 10 | 11 | // Implements a simple counter-based rate limiter 12 | type RateLimiter struct { 13 | client *redis.Client 14 | limit int 15 | window time.Duration 16 | } 17 | 18 | // Creates a new RateLimiter 19 | func NewRateLimiter(addr string, limit int, window time.Duration) *RateLimiter { 20 | client := redis.NewClient(&redis.Options{ 21 | Addr: addr, 22 | }) 23 | 24 | return &RateLimiter{ 25 | client: client, 26 | limit: limit, 27 | window: window, 28 | } 29 | } 30 | 31 | // Returns true if key (userID) is within rate limits 32 | func (rl *RateLimiter) Allow(ctx context.Context, key string) (bool, error) { 33 | fullKey := fmt.Sprintf("rate:%s", key) 34 | count, err := rl.client.Incr(ctx, fullKey).Result() 35 | if err != nil { 36 | return false, err 37 | } 38 | if count == 1 { 39 | rl.client.Expire(ctx, fullKey, rl.window) 40 | } 41 | if count > int64(rl.limit) { 42 | return false, nil 43 | } 44 | return true, nil 45 | } 46 | -------------------------------------------------------------------------------- /internal/repository/db.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | 8 | "github.com/jmoiron/sqlx" 9 | _ "github.com/lib/pq" 10 | "github.com/officiallysidsingh/go-notify/config" 11 | ) 12 | 13 | type DB struct { 14 | Conn *sqlx.DB 15 | } 16 | 17 | // Creates a new database connection 18 | func NewDB(cfg config.PostgresConfig) (*DB, error) { 19 | // Create a context with timeout for establishing the connection. 20 | ctx, cancel := context.WithTimeout(context.Background(), cfg.ConnTimeout) 21 | defer cancel() 22 | 23 | // Open a database connection using sqlx. 24 | db, err := sqlx.Open("postgres", cfg.DataSourceName) 25 | if err != nil { 26 | return nil, fmt.Errorf("failed to open DB: %w", err) 27 | } 28 | 29 | // Ping the database to ensure a successful connection. 30 | if err := db.PingContext(ctx); err != nil { 31 | return nil, fmt.Errorf("failed to ping DB: %w", err) 32 | } 33 | 34 | // Configure the connection pool. 35 | db.SetMaxOpenConns(cfg.MaxOpenConns) 36 | db.SetMaxIdleConns(cfg.MaxIdleConns) 37 | db.SetConnMaxLifetime(cfg.ConnMaxLifetime) 38 | db.SetConnMaxIdleTime(cfg.ConnMaxIdleTime) 39 | 40 | return &DB{Conn: db}, nil 41 | } 42 | 43 | // Close gracefully closes the database connection. 44 | func (d *DB) Close() error { 45 | return d.Conn.Close() 46 | } 47 | 48 | // Inserts a new notification into the database and returns its generated ID 49 | func (d *DB) InsertNotification( 50 | ctx context.Context, 51 | userID, message, status string, 52 | ) (int64, error) { 53 | var id int64 54 | 55 | // Begin a transaction 56 | tx, err := d.Conn.BeginTxx(ctx, nil) 57 | if err != nil { 58 | return 0, fmt.Errorf("failed to begin transaction: %w", err) 59 | } 60 | 61 | // Ensure rollback if something goes wrong 62 | defer func() { 63 | if err != nil { 64 | if rbErr := tx.Rollback(); rbErr != nil { 65 | err = fmt.Errorf("rollback error: %v (original error: %w)", rbErr, err) 66 | } 67 | } 68 | }() 69 | 70 | query := ` 71 | INSERT INTO notifications (user_id, message, status) 72 | VALUES ($1, $2, $3) 73 | RETURNING id` 74 | 75 | err = tx.QueryRowContext(ctx, query, userID, message, status).Scan(&id) 76 | if err != nil { 77 | return 0, fmt.Errorf("failed to insert notification: %w", err) 78 | } 79 | 80 | // Commit transaction 81 | if err = tx.Commit(); err != nil { 82 | return 0, fmt.Errorf("failed to commit transaction: %w", err) 83 | } 84 | 85 | return id, nil 86 | } 87 | 88 | // updates the status of a notification 89 | func (d *DB) UpdateNotificationStatus(ctx context.Context, id int64, status string) error { 90 | query := `UPDATE notifications SET status = $1 WHERE id = $2` 91 | 92 | // ExecContext is used for executing queries with a context. 93 | result, err := d.Conn.ExecContext(ctx, query, status, id) 94 | if err != nil { 95 | return fmt.Errorf("failed to update notification status: %w", err) 96 | } 97 | 98 | // Check that exactly one row was affected. 99 | rowsAffected, err := result.RowsAffected() 100 | if err != nil { 101 | return fmt.Errorf("failed to get rows affected: %w", err) 102 | } 103 | if rowsAffected == 0 { 104 | return sql.ErrNoRows 105 | } 106 | 107 | return nil 108 | } 109 | -------------------------------------------------------------------------------- /internal/service/ntfy.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | ) 9 | 10 | func SendPushNotification(topic, title, priority, message string) error { 11 | // - topic: ntfy topic 12 | // - title: title for the notification 13 | // - priority: priority of notification(1 - 5) 14 | // - message: the notification body 15 | 16 | url := fmt.Sprintf("https://ntfy.sh/%s", topic) 17 | 18 | req, err := http.NewRequest("POST", url, bytes.NewBuffer([]byte(message))) 19 | if err != nil { 20 | return err 21 | } 22 | 23 | req.Header.Set("Title", title) 24 | req.Header.Set("X-Priority", priority) 25 | req.Header.Set("Content-Type", "text/plain") 26 | 27 | client := &http.Client{} 28 | 29 | res, err := client.Do(req) 30 | if err != nil { 31 | return err 32 | } 33 | 34 | defer func() { 35 | if err := res.Body.Close(); err != nil { 36 | log.Printf("error closing response body: %v", err) 37 | } 38 | }() 39 | 40 | if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusAccepted { 41 | return fmt.Errorf("ailed to send push notification, status code: %d", res.StatusCode) 42 | } 43 | 44 | return nil 45 | } 46 | --------------------------------------------------------------------------------