Compare commits
98 Commits
more-messa
...
implement-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d7bc65995 | ||
|
|
05453895b9 | ||
|
|
14ed0a23a6 | ||
|
|
40ed4f6afd | ||
|
|
26324150d2 | ||
|
|
b0572451d3 | ||
|
|
d9c3bd1db5 | ||
|
|
615dd1dddc | ||
|
|
288092e4b4 | ||
|
|
69a2112df9 | ||
|
|
4ba91c7d9b | ||
|
|
f9fa7def2b | ||
|
|
7b45c5b2ab | ||
|
|
0fb4dcd7d1 | ||
|
|
d3530deb46 | ||
|
|
aadcdc628e | ||
|
|
2d4fcefdcf | ||
|
|
81303573fd | ||
|
|
76fadf05d9 | ||
|
|
b9e3d8ae82 | ||
|
|
6c5ffc2340 | ||
|
|
e32aa4b6aa | ||
|
|
682c168250 | ||
|
|
e5c8f91e3f | ||
|
|
f6255ec1f1 | ||
|
|
4b77e7a4d4 | ||
|
|
8f1212813f | ||
|
|
95ec1a520a | ||
|
|
4b228d9b57 | ||
|
|
08bc3e1ea2 | ||
|
|
a091d39c08 | ||
|
|
35699a41bd | ||
|
|
2e96982d3a | ||
|
|
e48de2d6b6 | ||
|
|
9414af8108 | ||
|
|
068ca2f834 | ||
|
|
7f10bcb7de | ||
|
|
f91ca33752 | ||
|
|
2c86bba235 | ||
|
|
44dad43f1d | ||
|
|
2fe0e5b8e9 | ||
|
|
8f08105246 | ||
|
|
2bc06669e9 | ||
|
|
a2d1ff9d76 | ||
|
|
dcbf0756d2 | ||
|
|
239ea7db85 | ||
|
|
9ad414313b | ||
|
|
3ee1598478 | ||
|
|
91336130b9 | ||
|
|
e4e859d051 | ||
|
|
6f6a2c6d18 | ||
|
|
63eecaaf72 | ||
|
|
8a5f5db903 | ||
|
|
c2515472b2 | ||
|
|
5ad51271af | ||
|
|
b3a08e8f25 | ||
|
|
8716be0201 | ||
|
|
647be874e5 | ||
|
|
c12c4fe742 | ||
|
|
5f5253d495 | ||
|
|
bbe2374703 | ||
|
|
205317c920 | ||
|
|
25850825b4 | ||
|
|
f01115de86 | ||
|
|
6735000050 | ||
|
|
42a64fc043 | ||
|
|
9935817fa8 | ||
|
|
8d5716bdb7 | ||
|
|
aafe96d9a1 | ||
|
|
93168209b5 | ||
|
|
6840af1a47 | ||
|
|
7c36f3c214 | ||
|
|
c707b82f36 | ||
|
|
37ff644aa7 | ||
|
|
c80ed89c0a | ||
|
|
6590cbf4be | ||
|
|
61a8c5fe8b | ||
|
|
c79136a06e | ||
|
|
a5590a438f | ||
|
|
1c196cae9f | ||
|
|
c494bf5f53 | ||
|
|
d07dc971fe | ||
|
|
112c9d0627 | ||
|
|
b1e63a24c7 | ||
|
|
7357315810 | ||
|
|
90a338d46c | ||
|
|
230dc99dba | ||
|
|
62e6c64ddb | ||
|
|
da8b45012a | ||
|
|
a5d1bc171c | ||
|
|
4f6ed5dd20 | ||
|
|
6901c759da | ||
|
|
b283d0f835 | ||
|
|
aac62bacf4 | ||
|
|
5c721ca271 | ||
|
|
2fcbcf5b54 | ||
|
|
21ae859d1c | ||
|
|
6648cd347d |
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
.git
|
||||
node_modules
|
||||
.DS_Store
|
||||
15
.editorconfig
Normal file
15
.editorconfig
Normal file
@@ -0,0 +1,15 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
|
||||
[*.go]
|
||||
indent_style = tab
|
||||
47
.gitignore
vendored
Normal file
47
.gitignore
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editors
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
*.bak
|
||||
.idea/
|
||||
.vscode/
|
||||
*.sublime-*
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# Environment / secrets
|
||||
.env
|
||||
.env.*
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# Go build artifacts
|
||||
backend/bin/
|
||||
backend/api
|
||||
backend/poller
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.test
|
||||
*.out
|
||||
vendor/
|
||||
tmp/
|
||||
|
||||
# Frontend build output
|
||||
frontend/dist/
|
||||
frontend/build/
|
||||
frontend/.vite/
|
||||
|
||||
# Prompts directory (local tooling, not part of the project)
|
||||
Prompts/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
.claude
|
||||
7
.prettierignore
Normal file
7
.prettierignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
frontend/build/
|
||||
backend/bin/
|
||||
*.lock
|
||||
Prompts/
|
||||
.claude/
|
||||
4
.prettierrc
Normal file
4
.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"proseWrap": "always"
|
||||
}
|
||||
22
Dockerfile
Normal file
22
Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# golang:1.24-alpine, 2025-02-28
|
||||
FROM golang:1.24-alpine@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191
|
||||
|
||||
# Install system dependencies: Node.js, npm, make, git
|
||||
RUN apk add --no-cache nodejs npm make git
|
||||
|
||||
# Install golangci-lint at a pinned commit hash
|
||||
# golangci-lint v2.10.1 (2025-02-17), commit 5d1e709b7be35cb2025444e19de266b056b7b7ee
|
||||
RUN GOBIN=/usr/local/bin go install \
|
||||
github.com/golangci/golangci-lint/cmd/golangci-lint@5d1e709b7be35cb2025444e19de266b056b7b7ee
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
# Install frontend dependencies
|
||||
RUN cd frontend && npm ci
|
||||
|
||||
# Run all checks — build fails if the branch is not green
|
||||
RUN make check
|
||||
20
LICENSE
Normal file
20
LICENSE
Normal file
@@ -0,0 +1,20 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Steven Jannette
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
90
Makefile
Normal file
90
Makefile
Normal file
@@ -0,0 +1,90 @@
|
||||
.PHONY: test lint fmt fmt-check check docker hooks build run dev clean \
|
||||
test-go test-js lint-go lint-js fmt-go fmt-js fmt-check-go fmt-check-js \
|
||||
build-go build-js
|
||||
|
||||
GODIR := backend
|
||||
JSDIR := frontend
|
||||
PRETTIER := $(JSDIR)/node_modules/.bin/prettier
|
||||
|
||||
# ── required targets ────────────────────────────────────────────
|
||||
|
||||
all: check
|
||||
|
||||
check: test lint fmt-check
|
||||
|
||||
test: test-go test-js
|
||||
|
||||
lint: lint-go lint-js
|
||||
|
||||
fmt: fmt-go fmt-js
|
||||
|
||||
fmt-check: fmt-check-go fmt-check-js
|
||||
|
||||
# ── go ──────────────────────────────────────────────────────────
|
||||
|
||||
test-go:
|
||||
cd $(GODIR) && go test -timeout 30s ./...
|
||||
|
||||
lint-go:
|
||||
cd $(GODIR) && golangci-lint run ./...
|
||||
|
||||
fmt-go:
|
||||
cd $(GODIR) && gofmt -w .
|
||||
|
||||
fmt-check-go:
|
||||
@diff=$$(cd $(GODIR) && gofmt -l .); \
|
||||
if [ -n "$$diff" ]; then \
|
||||
echo "Go files need formatting:"; \
|
||||
echo "$$diff"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
# ── js / frontend ───────────────────────────────────────────────
|
||||
|
||||
test-js:
|
||||
cd $(JSDIR) && npm test
|
||||
|
||||
lint-js:
|
||||
$(PRETTIER) --check '**/*.{js,jsx,ts,tsx,css,md,json}'
|
||||
|
||||
fmt-js:
|
||||
$(PRETTIER) --write '**/*.{js,jsx,ts,tsx,css,md,json}'
|
||||
|
||||
fmt-check-js:
|
||||
$(PRETTIER) --check '**/*.{js,jsx,ts,tsx,css,md,json}'
|
||||
|
||||
# ── build ───────────────────────────────────────────────────────
|
||||
|
||||
build: build-go build-js
|
||||
|
||||
build-go:
|
||||
cd $(GODIR) && go build -o bin/api ./cmd/api
|
||||
cd $(GODIR) && go build -o bin/poller ./cmd/poller
|
||||
|
||||
build-js:
|
||||
cd $(JSDIR) && npm run build
|
||||
|
||||
# ── dev / run ───────────────────────────────────────────────────
|
||||
|
||||
run:
|
||||
cd $(GODIR) && go run ./cmd/api
|
||||
|
||||
dev:
|
||||
cd $(GODIR) && go run ./cmd/api
|
||||
|
||||
# ── docker ──────────────────────────────────────────────────────
|
||||
|
||||
docker:
|
||||
docker build -t koin-ping .
|
||||
|
||||
# ── hooks ───────────────────────────────────────────────────────
|
||||
|
||||
hooks:
|
||||
printf '#!/bin/sh\nmake check\n' > .git/hooks/pre-commit
|
||||
chmod +x .git/hooks/pre-commit
|
||||
|
||||
# ── clean ───────────────────────────────────────────────────────
|
||||
|
||||
clean:
|
||||
rm -rf $(GODIR)/bin/
|
||||
rm -rf $(JSDIR)/dist/
|
||||
211
README.md
211
README.md
@@ -1,95 +1,156 @@
|
||||
# Koin Ping
|
||||
|
||||
A lightweight on-chain monitoring and alerting system designed to give users situational awareness over blockchain addresses they care about.
|
||||
|
||||
## Overview
|
||||
# Overview
|
||||
|
||||
Koin Ping is designed to reliably observe on-chain activity and notify users when predefined conditions are met. It does not execute transactions, manage wallets, or speculate on prices.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
koin_ping/
|
||||
├── backend/ # Node.js + Express + PostgreSQL backend
|
||||
│ ├── api/ # API endpoints and server configuration
|
||||
│ ├── poller/ # Blockchain polling logic
|
||||
│ ├── alerts/ # Alert evaluation and management
|
||||
│ ├── notifications/ # Notification delivery system
|
||||
│ ├── domain/ # Domain models and business logic
|
||||
│ ├── infra/ # Infrastructure (database, external services)
|
||||
│ └── shared/ # Shared utilities and helpers
|
||||
│
|
||||
└── frontend/ # React + Vite frontend
|
||||
├── public/ # Static assets
|
||||
└── src/
|
||||
├── api/ # Frontend API calls to backend
|
||||
├── components/ # Reusable UI components
|
||||
├── pages/ # Top-level pages (views)
|
||||
└── utils/ # Utility functions
|
||||
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Backend
|
||||
- **Runtime:** Node.js
|
||||
- **Framework:** Express
|
||||
- **Database:** PostgreSQL
|
||||
- **Key Dependencies:**
|
||||
- `pg` - PostgreSQL client
|
||||
- `dotenv` - Environment variable management
|
||||
- `cors` - CORS middleware
|
||||
- `nodemon` - Development auto-reload
|
||||
|
||||
### Frontend
|
||||
- **Framework:** React 19
|
||||
- **Build Tool:** Vite
|
||||
- **Language:** JavaScript/TypeScript (mixed)
|
||||
- **Type Checking:** TypeScript
|
||||
Koin Ping observes on-chain activity and notifies users when predefined conditions are met. It does not execute transactions, manage wallets, or speculate on prices.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
- Node.js (v18 or higher recommended)
|
||||
- PostgreSQL database
|
||||
- npm or yarn
|
||||
|
||||
### Backend Setup
|
||||
- Go 1.24+
|
||||
- Node.js 18+ and npm (or yarn — preferred per repo policy)
|
||||
- PostgreSQL 15+
|
||||
- `golangci-lint` v2 (for `make lint`)
|
||||
|
||||
1. Navigate to the backend directory:
|
||||
```bash
|
||||
cd backend
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Clone and enter the repo
|
||||
git clone <repo-url>
|
||||
cd koin_ping_0.2.0
|
||||
|
||||
# Install pre-commit hook
|
||||
make hooks
|
||||
|
||||
# Install frontend dependencies
|
||||
cd frontend && npm install && cd ..
|
||||
|
||||
# Copy and fill in environment variables
|
||||
cp backend/.env.example backend/.env
|
||||
# edit backend/.env with your DATABASE_URL, FIREBASE_PROJECT_ID, ETH_RPC_URL
|
||||
|
||||
# Run checks (requires golangci-lint)
|
||||
make check
|
||||
|
||||
# Start the API server
|
||||
make run
|
||||
|
||||
# Start the poller (separate terminal)
|
||||
cd backend && go run ./cmd/poller
|
||||
|
||||
# Start the frontend dev server (separate terminal)
|
||||
cd frontend && npm run dev
|
||||
```
|
||||
|
||||
The API listens on `http://localhost:3001` and the frontend on
|
||||
`http://localhost:3000` by default for development.
|
||||
|
||||
## Rationale
|
||||
|
||||
Crypto users may actively monitor addresses with webhooks integrating popular
|
||||
messaging platforms: Discord, Slack, Telegram.
|
||||
This lightweight, reliable framework makes instant awareness of on-chain
|
||||
activity trivial, without polling block explorers manually. Koin Ping watches
|
||||
addresses, evaluates configurable alert rules (incoming transactions, outgoing
|
||||
transactions, "large" transfers, balance thresholds), and sends notifications.
|
||||
|
||||
## Design
|
||||
|
||||
The system is split into two independently deployable processes and one
|
||||
frontend:
|
||||
|
||||
```
|
||||
koin_ping_0.2.0/
|
||||
├── backend/ # Go monorepo root
|
||||
│ ├── cmd/api/ # HTTP REST API server
|
||||
│ ├── cmd/poller/ # Blockchain polling daemon
|
||||
│ └── internal/
|
||||
│ ├── config/ # Environment-based config loading
|
||||
│ ├── database/ # pgx connection pool
|
||||
│ ├── domain/ # Shared domain types
|
||||
│ ├── firebase/ # Firebase auth client
|
||||
│ ├── handlers/ # HTTP handler wiring
|
||||
│ ├── middleware/ # Auth middleware
|
||||
│ ├── models/ # SQL persistence layer
|
||||
│ ├── notifications/ # Discord webhook delivery
|
||||
│ ├── protocols/ethereum/ # Ethereum JSON-RPC client
|
||||
│ ├── services/ # Observer and evaluator business logic
|
||||
│ └── wei/ # ETH/Wei conversion utilities
|
||||
└── frontend/ # React + Vite SPA
|
||||
└── src/
|
||||
├── api/ # Fetch wrappers for the REST API
|
||||
├── components/ # Reusable UI components
|
||||
├── contexts/ # React contexts (auth, etc.)
|
||||
├── firebase/ # Firebase SDK initialization
|
||||
└── pages/ # Top-level route pages
|
||||
```
|
||||
|
||||
**API server** (`cmd/api`): standard-library `net/http` with Firebase JWT
|
||||
authentication middleware. Exposes CRUD endpoints for addresses, alert rules,
|
||||
alert events, and notification configuration.
|
||||
|
||||
**Poller** (`cmd/poller`): long-running daemon that polls Ethereum via JSON-RPC,
|
||||
compares observed transactions against persisted alert rules, fires alert events
|
||||
to the database, and dispatches Discord notifications.
|
||||
|
||||
**Frontend** (`frontend/`): React 19 SPA built with Vite. Authenticates with
|
||||
Firebase, communicates with the API via fetch, and renders the address/alert
|
||||
management UI.
|
||||
|
||||
## Setting Up Your Alert Platforms
|
||||
|
||||
Koin Ping can send real-time alerts to **Telegram**, **Discord**, **Slack**, and **Email**. Each channel is configured per-user through the **Notification Settings** panel on the Alerts page.
|
||||
|
||||
Below are step-by-step guides for setting up each platform.
|
||||
|
||||
---
|
||||
|
||||
### Telegram
|
||||
|
||||
To receive alerts via Telegram, you need to create a bot and get your chat ID.
|
||||
|
||||
#### 1. Create a Telegram Bot
|
||||
|
||||
1. Open Telegram and search for **@BotFather** (look for the blue verified checkmark).
|
||||
2. Open the conversation with BotFather and send: `/newbot`
|
||||
3. BotFather will ask for a **display name** — enter something like `Koin Ping Alerts`.
|
||||
4. BotFather will ask for a **username** — it must end in `bot`, e.g. `MyKoinPingBot`.
|
||||
5. BotFather will reply with your **Bot Token** — a string that looks like `123456789:ABCdefGHIjklMNOpqrSTUvwxYZ`. Copy it.
|
||||
|
||||
#### 2. Get Your Chat ID
|
||||
|
||||
1. In Telegram, search for the bot username you just created and open the chat.
|
||||
2. Tap **Start** or send any message (e.g. `hello`).
|
||||
3. Open the following URL in your browser, replacing `YOUR_BOT_TOKEN` with the token from step 1:
|
||||
|
||||
```
|
||||
https://api.telegram.org/botYOUR_BOT_TOKEN/getUpdates
|
||||
```
|
||||
|
||||
2. Install dependencies (already done):
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
4. In the JSON response, find the `"chat"` object — the `"id"` field is your **Chat ID** (a numeric value).
|
||||
|
||||
3. Create a `.env` file (see backend/README.md for required variables)
|
||||
> **Tip:** If the `'result"` array is empty, make sure you sent a message to your bot first, then refresh the page.
|
||||
|
||||
4. Start the development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
#### 3. Save in Koin Ping
|
||||
|
||||
The backend will run on `http://localhost:3001`
|
||||
1. Go to the **Alerts** page in Koin Ping.
|
||||
2. In the **Notification Settings** panel, find the **Telegram** section.
|
||||
3. Paste your **Bot Token** and **Chat ID** into the corresponding fields.
|
||||
4. Click **Save Settings**.
|
||||
5. Click **Test All Channels** to verify — you should receive a test message from your bot in Telegram.
|
||||
|
||||
### Frontend Setup
|
||||
---
|
||||
|
||||
1. Navigate to the frontend directory:
|
||||
```bash
|
||||
cd frontend
|
||||
```
|
||||
*Guides for Discord, Slack, and Email coming soon.*
|
||||
|
||||
2. Install dependencies (already done):
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
## License
|
||||
|
||||
MIT. See [LICENSE](LICENSE).
|
||||
|
||||
## Author
|
||||
|
||||
Steven Jannette
|
||||
|
||||
3. Start the development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The frontend will run on `http://localhost:3000`
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# Server
|
||||
PORT=3001
|
||||
API_BASE_PATH=/v1
|
||||
NODE_ENV=development
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/koin_ping
|
||||
|
||||
# Ethereum JSON-RPC
|
||||
ETH_RPC_URL=https://mainnet.infura.io/v3/YOUR-PROJECT-ID
|
||||
|
||||
# Polling interval (ms, minimum 1000)
|
||||
POLL_INTERVAL_MS=60000
|
||||
|
||||
# Firebase
|
||||
FIREBASE_PROJECT_ID=koin-ping
|
||||
@@ -1,4 +0,0 @@
|
||||
Run Backend:
|
||||
|
||||
cd /Users/kjannette/workspace/koin_ping/backend-go
|
||||
go run ./cmd/api
|
||||
@@ -1,105 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/config"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/database"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/firebase"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/handlers"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
|
||||
)
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
pool, err := database.Connect(cfg.DSN())
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to database: %v", err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
if err := firebase.Init(cfg.FirebaseProjectID); err != nil {
|
||||
log.Fatalf("Failed to initialize Firebase: %v", err)
|
||||
}
|
||||
|
||||
var eth ethereum.EthereumObserver
|
||||
if cfg.EthRPCURL != "" {
|
||||
eth, err = ethereum.NewJsonRpcEthereum(cfg.EthRPCURL)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to create Ethereum observer: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
addressModel := models.NewAddressModel(pool)
|
||||
alertRuleModel := models.NewAlertRuleModel(pool)
|
||||
alertEventModel := models.NewAlertEventModel(pool)
|
||||
notifConfigModel := models.NewNotificationConfigModel(pool)
|
||||
checkpointModel := models.NewCheckpointModel(pool)
|
||||
|
||||
addressHandler := handlers.NewAddressHandler(addressModel)
|
||||
alertRuleHandler := handlers.NewAlertRuleHandler(alertRuleModel, addressModel)
|
||||
alertEventHandler := handlers.NewAlertEventHandler(alertEventModel)
|
||||
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel)
|
||||
statusHandler := handlers.NewStatusHandler(eth, checkpointModel)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
b := cfg.APIBasePath
|
||||
|
||||
mux.HandleFunc("GET "+b+"/health", handlers.HealthCheck)
|
||||
mux.HandleFunc("GET "+b+"/status", statusHandler.SystemStatus)
|
||||
|
||||
mux.Handle("POST "+b+"/addresses", middleware.Authenticate(http.HandlerFunc(addressHandler.Create)))
|
||||
mux.Handle("GET "+b+"/addresses", middleware.Authenticate(http.HandlerFunc(addressHandler.List)))
|
||||
mux.Handle("DELETE "+b+"/addresses/{addressId}", middleware.Authenticate(http.HandlerFunc(addressHandler.Remove)))
|
||||
|
||||
mux.Handle("POST "+b+"/addresses/{addressId}/alerts", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Create)))
|
||||
mux.Handle("GET "+b+"/addresses/{addressId}/alerts", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.ListByAddress)))
|
||||
mux.Handle("PATCH "+b+"/alerts/{alertId}", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.UpdateStatus)))
|
||||
mux.Handle("DELETE "+b+"/alerts/{alertId}", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Remove)))
|
||||
|
||||
mux.Handle("GET "+b+"/alert-events", middleware.Authenticate(http.HandlerFunc(alertEventHandler.List)))
|
||||
|
||||
mux.Handle("GET "+b+"/notification-config", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.GetConfig)))
|
||||
mux.Handle("PUT "+b+"/notification-config", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.UpdateConfig)))
|
||||
mux.Handle("DELETE "+b+"/notification-config", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.DeleteConfig)))
|
||||
mux.Handle("POST "+b+"/notification-config/test", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.TestWebhook)))
|
||||
|
||||
handler := corsMiddleware(mux)
|
||||
|
||||
addr := fmt.Sprintf(":%d", cfg.Port)
|
||||
log.Printf("Server running on port %d", cfg.Port)
|
||||
log.Printf("API base path: %s", cfg.APIBasePath)
|
||||
log.Printf("Environment: %s", cfg.NodeEnv)
|
||||
|
||||
if err := http.ListenAndServe(addr, handler); err != nil {
|
||||
log.Fatalf("Server failed: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
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, PATCH, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port int
|
||||
APIBasePath string
|
||||
DatabaseURL string
|
||||
DBHost string
|
||||
DBPort int
|
||||
DBUser string
|
||||
DBPassword string
|
||||
DBName string
|
||||
FirebaseProjectID string
|
||||
EthRPCURL string
|
||||
PollIntervalMS int
|
||||
NodeEnv string
|
||||
|
||||
SMTPHost string
|
||||
SMTPPort int
|
||||
SMTPUser string
|
||||
SMTPPassword string
|
||||
SMTPFrom string
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
cfg := &Config{
|
||||
Port: getEnvInt("PORT", 3001),
|
||||
APIBasePath: getEnv("API_BASE_PATH", "/v1"),
|
||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||
DBHost: getEnv("DB_HOST", "localhost"),
|
||||
DBPort: getEnvInt("DB_PORT", 5432),
|
||||
DBUser: os.Getenv("DB_USER"),
|
||||
DBPassword: os.Getenv("DB_PASSWORD"),
|
||||
DBName: os.Getenv("DB_NAME"),
|
||||
FirebaseProjectID: os.Getenv("FIREBASE_PROJECT_ID"),
|
||||
EthRPCURL: os.Getenv("ETH_RPC_URL"),
|
||||
PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", 60000),
|
||||
NodeEnv: getEnv("NODE_ENV", "development"),
|
||||
|
||||
SMTPHost: os.Getenv("SMTP_HOST"),
|
||||
SMTPPort: getEnvInt("SMTP_PORT", 587),
|
||||
SMTPUser: os.Getenv("SMTP_USER"),
|
||||
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
|
||||
SMTPFrom: os.Getenv("SMTP_FROM"),
|
||||
}
|
||||
|
||||
if cfg.PollIntervalMS < 1000 {
|
||||
return nil, fmt.Errorf("POLL_INTERVAL_MS must be >= 1000, got %d", cfg.PollIntervalMS)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) DSN() string {
|
||||
if c.DatabaseURL != "" {
|
||||
// pgx defaults to sslmode=prefer, which fails against local Postgres.
|
||||
// Append sslmode=disable if not already specified.
|
||||
if !strings.Contains(c.DatabaseURL, "sslmode=") {
|
||||
sep := "?"
|
||||
if strings.Contains(c.DatabaseURL, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
return c.DatabaseURL + sep + "sslmode=disable"
|
||||
}
|
||||
return c.DatabaseURL
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
|
||||
c.DBHost, c.DBPort, c.DBUser, c.DBPassword, c.DBName,
|
||||
)
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getEnvInt(key string, fallback int) int {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoad_Defaults(t *testing.T) {
|
||||
for _, key := range []string{"PORT", "API_BASE_PATH", "POLL_INTERVAL_MS", "NODE_ENV", "SMTP_PORT"} {
|
||||
os.Unsetenv(key)
|
||||
}
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Port != 3001 {
|
||||
t.Errorf("Port = %d, want 3001", cfg.Port)
|
||||
}
|
||||
if cfg.APIBasePath != "/v1" {
|
||||
t.Errorf("APIBasePath = %q, want /v1", cfg.APIBasePath)
|
||||
}
|
||||
if cfg.PollIntervalMS != 60000 {
|
||||
t.Errorf("PollIntervalMS = %d, want 60000", cfg.PollIntervalMS)
|
||||
}
|
||||
if cfg.NodeEnv != "development" {
|
||||
t.Errorf("NodeEnv = %q, want development", cfg.NodeEnv)
|
||||
}
|
||||
if cfg.SMTPPort != 587 {
|
||||
t.Errorf("SMTPPort = %d, want 587", cfg.SMTPPort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_InvalidPollInterval(t *testing.T) {
|
||||
os.Setenv("POLL_INTERVAL_MS", "500")
|
||||
defer os.Unsetenv("POLL_INTERVAL_MS")
|
||||
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for poll interval < 1000")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDSN_DatabaseURL(t *testing.T) {
|
||||
cfg := &Config{DatabaseURL: "postgres://user:pass@localhost/db"}
|
||||
dsn := cfg.DSN()
|
||||
if !strings.Contains(dsn, "sslmode=disable") {
|
||||
t.Error("DSN should append sslmode=disable")
|
||||
}
|
||||
if !strings.HasPrefix(dsn, "postgres://user:pass@localhost/db") {
|
||||
t.Error("DSN should preserve original URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDSN_DatabaseURL_WithSSLMode(t *testing.T) {
|
||||
cfg := &Config{DatabaseURL: "postgres://user:pass@localhost/db?sslmode=require"}
|
||||
dsn := cfg.DSN()
|
||||
if strings.Count(dsn, "sslmode=") != 1 {
|
||||
t.Error("DSN should not duplicate sslmode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDSN_Components(t *testing.T) {
|
||||
cfg := &Config{
|
||||
DBHost: "myhost",
|
||||
DBPort: 5433,
|
||||
DBUser: "myuser",
|
||||
DBPassword: "mypass",
|
||||
DBName: "mydb",
|
||||
}
|
||||
dsn := cfg.DSN()
|
||||
if !strings.Contains(dsn, "host=myhost") {
|
||||
t.Error("DSN should contain host")
|
||||
}
|
||||
if !strings.Contains(dsn, "port=5433") {
|
||||
t.Error("DSN should contain port")
|
||||
}
|
||||
if !strings.Contains(dsn, "user=myuser") {
|
||||
t.Error("DSN should contain user")
|
||||
}
|
||||
if !strings.Contains(dsn, "dbname=mydb") {
|
||||
t.Error("DSN should contain dbname")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnv(t *testing.T) {
|
||||
os.Setenv("TEST_KEY_KOINPING", "value")
|
||||
defer os.Unsetenv("TEST_KEY_KOINPING")
|
||||
|
||||
if got := getEnv("TEST_KEY_KOINPING", "fallback"); got != "value" {
|
||||
t.Errorf("got %q, want %q", got, "value")
|
||||
}
|
||||
if got := getEnv("NONEXISTENT_KEY_KOINPING", "fallback"); got != "fallback" {
|
||||
t.Errorf("got %q, want %q", got, "fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnvInt(t *testing.T) {
|
||||
os.Setenv("TEST_INT_KOINPING", "42")
|
||||
defer os.Unsetenv("TEST_INT_KOINPING")
|
||||
|
||||
if got := getEnvInt("TEST_INT_KOINPING", 0); got != 42 {
|
||||
t.Errorf("got %d, want 42", got)
|
||||
}
|
||||
if got := getEnvInt("NONEXISTENT_INT_KOINPING", 99); got != 99 {
|
||||
t.Errorf("got %d, want 99", got)
|
||||
}
|
||||
|
||||
os.Setenv("TEST_INT_KOINPING", "notanumber")
|
||||
if got := getEnvInt("TEST_INT_KOINPING", 99); got != 99 {
|
||||
t.Errorf("got %d, want 99 for invalid int", got)
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type Address struct {
|
||||
ID int `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Address string `json:"address"`
|
||||
Label *string `json:"label"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type AlertType string
|
||||
|
||||
const (
|
||||
AlertIncomingTx AlertType = "incoming_tx"
|
||||
AlertOutgoingTx AlertType = "outgoing_tx"
|
||||
AlertLargeTransfer AlertType = "large_transfer"
|
||||
AlertBalanceBelow AlertType = "balance_below"
|
||||
)
|
||||
|
||||
var ValidAlertTypes = []AlertType{
|
||||
AlertIncomingTx,
|
||||
AlertOutgoingTx,
|
||||
AlertLargeTransfer,
|
||||
AlertBalanceBelow,
|
||||
}
|
||||
|
||||
var ThresholdRequiredTypes = []AlertType{
|
||||
AlertLargeTransfer,
|
||||
AlertBalanceBelow,
|
||||
}
|
||||
|
||||
func IsValidAlertType(t string) bool {
|
||||
for _, v := range ValidAlertTypes {
|
||||
if string(v) == t {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func IsThresholdRequired(t AlertType) bool {
|
||||
for _, v := range ThresholdRequiredTypes {
|
||||
if v == t {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type AlertRule struct {
|
||||
ID int `json:"id"`
|
||||
AddressID int `json:"address_id"`
|
||||
Type AlertType `json:"type"`
|
||||
Threshold *float64 `json:"threshold"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type AlertEvent struct {
|
||||
ID int `json:"id"`
|
||||
AlertRuleID int `json:"alert_rule_id"`
|
||||
Message string `json:"message"`
|
||||
AddressLabel *string `json:"address_label"`
|
||||
TxHash *string `json:"tx_hash"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
type AddressCheckpoint struct {
|
||||
AddressID int `json:"address_id"`
|
||||
LastCheckedBlock int `json:"last_checked_block"`
|
||||
LastCheckedAt time.Time `json:"last_checked_at"`
|
||||
}
|
||||
|
||||
type CheckpointDetail struct {
|
||||
AddressID int `json:"address_id"`
|
||||
Address string `json:"address"`
|
||||
Label *string `json:"label"`
|
||||
LastCheckedBlock int `json:"last_checked_block"`
|
||||
LastCheckedAt time.Time `json:"last_checked_at"`
|
||||
}
|
||||
|
||||
type NotificationConfig struct {
|
||||
UserID string `json:"user_id"`
|
||||
DiscordWebhookURL *string `json:"discord_webhook_url"`
|
||||
SlackWebhookURL *string `json:"slack_webhook_url"`
|
||||
Email *string `json:"email"`
|
||||
NotificationEnabled bool `json:"notification_enabled"`
|
||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type NormalizedTx struct {
|
||||
Hash string `json:"hash"`
|
||||
From string `json:"from"`
|
||||
To *string `json:"to"`
|
||||
Value string `json:"value"` // Wei as string for precision
|
||||
BlockNumber int `json:"block_number"`
|
||||
BlockTimestamp int64 `json:"block_timestamp"`
|
||||
}
|
||||
|
||||
type Direction string
|
||||
|
||||
const (
|
||||
DirectionIncoming Direction = "incoming"
|
||||
DirectionOutgoing Direction = "outgoing"
|
||||
)
|
||||
|
||||
type ObservedTx struct {
|
||||
NormalizedTx
|
||||
AddressID int `json:"address_id"`
|
||||
Direction Direction `json:"direction"`
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsValidAlertType(t *testing.T) {
|
||||
valid := []string{"incoming_tx", "outgoing_tx", "large_transfer", "balance_below"}
|
||||
for _, v := range valid {
|
||||
if !IsValidAlertType(v) {
|
||||
t.Errorf("IsValidAlertType(%q) = false, want true", v)
|
||||
}
|
||||
}
|
||||
|
||||
invalid := []string{"", "invalid", "INCOMING_TX", "send", "receive"}
|
||||
for _, v := range invalid {
|
||||
if IsValidAlertType(v) {
|
||||
t.Errorf("IsValidAlertType(%q) = true, want false", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsThresholdRequired(t *testing.T) {
|
||||
required := []AlertType{AlertLargeTransfer, AlertBalanceBelow}
|
||||
for _, at := range required {
|
||||
if !IsThresholdRequired(at) {
|
||||
t.Errorf("IsThresholdRequired(%q) = false, want true", at)
|
||||
}
|
||||
}
|
||||
|
||||
notRequired := []AlertType{AlertIncomingTx, AlertOutgoingTx}
|
||||
for _, at := range notRequired {
|
||||
if IsThresholdRequired(at) {
|
||||
t.Errorf("IsThresholdRequired(%q) = true, want false", at)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
|
||||
)
|
||||
|
||||
func HealthCheck(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"status": "ok",
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
"service": "koin-ping-backend",
|
||||
})
|
||||
}
|
||||
|
||||
type StatusHandler struct {
|
||||
eth ethereum.EthereumObserver
|
||||
checkpoints *models.CheckpointModel
|
||||
}
|
||||
|
||||
func NewStatusHandler(eth ethereum.EthereumObserver, checkpoints *models.CheckpointModel) *StatusHandler {
|
||||
return &StatusHandler{eth: eth, checkpoints: checkpoints}
|
||||
}
|
||||
|
||||
func (h *StatusHandler) SystemStatus(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
status := "healthy"
|
||||
|
||||
if h.eth == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"latestBlock": 0,
|
||||
"lag": 0,
|
||||
"trackedAddresses": 0,
|
||||
"status": "no_rpc",
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
latestBlock, err := h.eth.GetLatestBlockNumber(ctx)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get latest block: %v", err)
|
||||
status = "degraded"
|
||||
latestBlock = 0
|
||||
}
|
||||
|
||||
lag := 0
|
||||
checkpoints, err := h.checkpoints.ListAll(ctx)
|
||||
if err != nil {
|
||||
log.Printf("Failed to list checkpoints: %v", err)
|
||||
} else if len(checkpoints) > 0 && latestBlock > 0 {
|
||||
minChecked := checkpoints[0].LastCheckedBlock
|
||||
for _, cp := range checkpoints[1:] {
|
||||
if cp.LastCheckedBlock < minChecked {
|
||||
minChecked = cp.LastCheckedBlock
|
||||
}
|
||||
}
|
||||
lag = latestBlock - minChecked
|
||||
}
|
||||
|
||||
if lag > 50 {
|
||||
status = "syncing"
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"latestBlock": latestBlock,
|
||||
"lag": lag,
|
||||
"trackedAddresses": len(checkpoints),
|
||||
"status": status,
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
fbauth "github.com/kjannette/koin-ping/backend-go/internal/firebase"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
UserIDKey contextKey = "user_id"
|
||||
UserEmailKey contextKey = "user_email"
|
||||
)
|
||||
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func Authenticate(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
log.Println("No Authorization header or invalid format")
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "UNAUTHORIZED",
|
||||
Message: "No authentication token provided",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if token == "" {
|
||||
log.Println("Empty token")
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "UNAUTHORIZED",
|
||||
Message: "Invalid token format",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("Verifying Firebase token...")
|
||||
decoded, err := fbauth.Auth().VerifyIDToken(r.Context(), token)
|
||||
if err != nil {
|
||||
log.Printf("Token verification failed: %v", err)
|
||||
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "expired") {
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "TOKEN_EXPIRED",
|
||||
Message: "Authentication token has expired",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "UNAUTHORIZED",
|
||||
Message: "Failed to verify authentication token",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
userID := decoded.UID
|
||||
email, _ := decoded.Claims["email"].(string)
|
||||
|
||||
log.Printf("Token verified! User ID: %s, Email: %s", userID, email)
|
||||
|
||||
ctx := context.WithValue(r.Context(), UserIDKey, userID)
|
||||
ctx = context.WithValue(ctx, UserEmailKey, email)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func GetUserID(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(UserIDKey).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func GetUserEmail(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(UserEmailKey).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
)
|
||||
|
||||
type AlertRuleModel struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewAlertRuleModel(pool *pgxpool.Pool) *AlertRuleModel {
|
||||
return &AlertRuleModel{pool: pool}
|
||||
}
|
||||
|
||||
func (m *AlertRuleModel) Create(ctx context.Context, addressID int, alertType domain.AlertType, threshold *float64) (*domain.AlertRule, error) {
|
||||
var r domain.AlertRule
|
||||
err := m.pool.QueryRow(ctx,
|
||||
`INSERT INTO alert_rules (address_id, type, threshold, enabled)
|
||||
VALUES ($1, $2, $3, TRUE)
|
||||
RETURNING id, address_id, type, threshold, enabled, created_at`,
|
||||
addressID, string(alertType), threshold,
|
||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (m *AlertRuleModel) ListByAddress(ctx context.Context, addressID int) ([]domain.AlertRule, error) {
|
||||
rows, err := m.pool.Query(ctx,
|
||||
`SELECT id, address_id, type, threshold, enabled, created_at
|
||||
FROM alert_rules
|
||||
WHERE address_id = $1
|
||||
ORDER BY created_at DESC`,
|
||||
addressID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var rules []domain.AlertRule
|
||||
for rows.Next() {
|
||||
var r domain.AlertRule
|
||||
if err := rows.Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules = append(rules, r)
|
||||
}
|
||||
return rules, rows.Err()
|
||||
}
|
||||
|
||||
func (m *AlertRuleModel) FindByID(ctx context.Context, id int, userID *string) (*domain.AlertRule, error) {
|
||||
var r domain.AlertRule
|
||||
var err error
|
||||
|
||||
if userID != nil {
|
||||
err = m.pool.QueryRow(ctx,
|
||||
`SELECT ar.id, ar.address_id, ar.type, ar.threshold, ar.enabled, ar.created_at
|
||||
FROM alert_rules ar
|
||||
JOIN addresses a ON a.id = ar.address_id
|
||||
WHERE ar.id = $1 AND a.user_id = $2`,
|
||||
id, *userID,
|
||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt)
|
||||
} else {
|
||||
err = m.pool.QueryRow(ctx,
|
||||
`SELECT id, address_id, type, threshold, enabled, created_at
|
||||
FROM alert_rules
|
||||
WHERE id = $1`,
|
||||
id,
|
||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if err.Error() == "no rows in result set" {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (m *AlertRuleModel) UpdateEnabled(ctx context.Context, id int, enabled bool) (*domain.AlertRule, error) {
|
||||
var r domain.AlertRule
|
||||
err := m.pool.QueryRow(ctx,
|
||||
`UPDATE alert_rules
|
||||
SET enabled = $2
|
||||
WHERE id = $1
|
||||
RETURNING id, address_id, type, threshold, enabled, created_at`,
|
||||
id, enabled,
|
||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt)
|
||||
if err != nil {
|
||||
if err.Error() == "no rows in result set" {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (m *AlertRuleModel) Remove(ctx context.Context, id int) (bool, error) {
|
||||
tag, err := m.pool.Exec(ctx,
|
||||
`DELETE FROM alert_rules WHERE id = $1`,
|
||||
id,
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return tag.RowsAffected() > 0, nil
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
)
|
||||
|
||||
type NotificationConfigModel struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewNotificationConfigModel(pool *pgxpool.Pool) *NotificationConfigModel {
|
||||
return &NotificationConfigModel{pool: pool}
|
||||
}
|
||||
|
||||
func (m *NotificationConfigModel) GetConfig(ctx context.Context, userID string) (*domain.NotificationConfig, error) {
|
||||
var c domain.NotificationConfig
|
||||
err := m.pool.QueryRow(ctx,
|
||||
`SELECT user_id, discord_webhook_url, slack_webhook_url,
|
||||
email, notification_enabled, created_at, updated_at
|
||||
FROM user_notification_configs
|
||||
WHERE user_id = $1`,
|
||||
userID,
|
||||
).Scan(&c.UserID, &c.DiscordWebhookURL, &c.SlackWebhookURL,
|
||||
&c.Email, &c.NotificationEnabled, &c.CreatedAt, &c.UpdatedAt)
|
||||
if err != nil {
|
||||
if err.Error() == "no rows in result set" {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (m *NotificationConfigModel) UpsertConfig(ctx context.Context, userID string, cfg domain.NotificationConfig) (*domain.NotificationConfig, error) {
|
||||
var c domain.NotificationConfig
|
||||
err := m.pool.QueryRow(ctx,
|
||||
`INSERT INTO user_notification_configs
|
||||
(user_id, discord_webhook_url, slack_webhook_url, email, notification_enabled, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW())
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE SET
|
||||
discord_webhook_url = COALESCE($2, user_notification_configs.discord_webhook_url),
|
||||
slack_webhook_url = COALESCE($3, user_notification_configs.slack_webhook_url),
|
||||
email = COALESCE($4, user_notification_configs.email),
|
||||
notification_enabled = $5,
|
||||
updated_at = NOW()
|
||||
RETURNING user_id, discord_webhook_url, slack_webhook_url,
|
||||
email, notification_enabled, created_at, updated_at`,
|
||||
userID, cfg.DiscordWebhookURL, cfg.SlackWebhookURL,
|
||||
cfg.Email, cfg.NotificationEnabled,
|
||||
).Scan(&c.UserID, &c.DiscordWebhookURL, &c.SlackWebhookURL,
|
||||
&c.Email, &c.NotificationEnabled, &c.CreatedAt, &c.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (m *NotificationConfigModel) Remove(ctx context.Context, userID string) (bool, error) {
|
||||
tag, err := m.pool.Exec(ctx,
|
||||
`DELETE FROM user_notification_configs WHERE user_id = $1`,
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return tag.RowsAffected() > 0, nil
|
||||
}
|
||||
|
||||
func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.NotificationConfig, error) {
|
||||
rows, err := m.pool.Query(ctx,
|
||||
`SELECT user_id, discord_webhook_url, slack_webhook_url, email
|
||||
FROM user_notification_configs
|
||||
WHERE notification_enabled = TRUE`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var configs []domain.NotificationConfig
|
||||
for rows.Next() {
|
||||
var c domain.NotificationConfig
|
||||
if err := rows.Scan(&c.UserID, &c.DiscordWebhookURL, &c.SlackWebhookURL, &c.Email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.NotificationEnabled = true
|
||||
configs = append(configs, c)
|
||||
}
|
||||
return configs, rows.Err()
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSendDiscordNotification(t *testing.T) {
|
||||
var receivedPayload discordPayload
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
json.Unmarshal(body, &receivedPayload)
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
meta := AlertMetadata{
|
||||
TxHash: "0xabc123",
|
||||
AddressLabel: "Treasury",
|
||||
AlertType: "incoming_tx",
|
||||
Address: "0x1234567890abcdef1234567890abcdef12345678",
|
||||
}
|
||||
|
||||
sent, err := SendDiscordNotification(server.URL, "Incoming transaction: 5.5 ETH received", meta)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !sent {
|
||||
t.Fatal("expected sent to be true")
|
||||
}
|
||||
|
||||
if len(receivedPayload.Embeds) != 1 {
|
||||
t.Fatalf("expected 1 embed, got %d", len(receivedPayload.Embeds))
|
||||
}
|
||||
embed := receivedPayload.Embeds[0]
|
||||
if embed.Title != "Koin Ping Alert" {
|
||||
t.Errorf("title = %q, want %q", embed.Title, "Koin Ping Alert")
|
||||
}
|
||||
if embed.Color != 0x00ff00 {
|
||||
t.Errorf("color = %x, want %x (green for incoming_tx)", embed.Color, 0x00ff00)
|
||||
}
|
||||
if len(embed.Fields) != 3 {
|
||||
t.Errorf("expected 3 fields (address, blockchain address, tx), got %d", len(embed.Fields))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendDiscordNotification_ServerError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
sent, err := SendDiscordNotification(server.URL, "test", AlertMetadata{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bad response")
|
||||
}
|
||||
if sent {
|
||||
t.Fatal("expected sent to be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestDiscordWebhook(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ok, err := TestDiscordWebhook(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected ok to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorForAlertType(t *testing.T) {
|
||||
tests := map[string]int{
|
||||
"incoming_tx": 0x00ff00,
|
||||
"outgoing_tx": 0xff9900,
|
||||
"large_transfer": 0xff0000,
|
||||
"balance_below": 0xff0000,
|
||||
"unknown": 0x0099ff,
|
||||
}
|
||||
for alertType, want := range tests {
|
||||
if got := colorForAlertType(alertType); got != want {
|
||||
t.Errorf("colorForAlertType(%q) = %x, want %x", alertType, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type SMTPConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
From string
|
||||
}
|
||||
|
||||
func (c *SMTPConfig) IsConfigured() bool {
|
||||
return c.Host != "" && c.From != ""
|
||||
}
|
||||
|
||||
func (c *SMTPConfig) addr() string {
|
||||
return fmt.Sprintf("%s:%d", c.Host, c.Port)
|
||||
}
|
||||
|
||||
func SendEmailNotification(cfg SMTPConfig, toEmail, message string, meta AlertMetadata) (bool, error) {
|
||||
if !cfg.IsConfigured() {
|
||||
return false, fmt.Errorf("SMTP is not configured")
|
||||
}
|
||||
|
||||
subject := fmt.Sprintf("Koin Ping Alert: %s", humanAlertType(meta.AlertType))
|
||||
|
||||
body := buildEmailBody(message, meta)
|
||||
|
||||
msg := strings.Join([]string{
|
||||
fmt.Sprintf("From: %s", cfg.From),
|
||||
fmt.Sprintf("To: %s", toEmail),
|
||||
fmt.Sprintf("Subject: %s", subject),
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/html; charset=UTF-8",
|
||||
"",
|
||||
body,
|
||||
}, "\r\n")
|
||||
|
||||
var auth smtp.Auth
|
||||
if cfg.Username != "" {
|
||||
auth = smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
|
||||
}
|
||||
|
||||
err := smtp.SendMail(cfg.addr(), auth, cfg.From, []string{toEmail}, []byte(msg))
|
||||
if err != nil {
|
||||
log.Printf("Failed to send email notification to %s: %v", toEmail, err)
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func TestEmailNotification(cfg SMTPConfig, toEmail string) (bool, error) {
|
||||
meta := AlertMetadata{AlertType: "test"}
|
||||
return SendEmailNotification(cfg, toEmail,
|
||||
"This is a test notification from Koin Ping. Your email is configured correctly!", meta)
|
||||
}
|
||||
|
||||
func buildEmailBody(message string, meta AlertMetadata) string {
|
||||
txSection := ""
|
||||
if meta.TxHash != "" {
|
||||
txSection = fmt.Sprintf(
|
||||
`<tr><td style="padding:8px 0;color:#666">Transaction</td>`+
|
||||
`<td style="padding:8px 0"><a href="https://etherscan.io/tx/%s" style="color:#4f46e5">View on Etherscan</a></td></tr>`,
|
||||
meta.TxHash,
|
||||
)
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f5f5f5">
|
||||
<div style="max-width:560px;margin:24px auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.1)">
|
||||
<div style="background:#4f46e5;padding:20px 24px">
|
||||
<h1 style="margin:0;color:#fff;font-size:18px">Koin Ping Alert</h1>
|
||||
</div>
|
||||
<div style="padding:24px">
|
||||
<p style="margin:0 0 16px;font-size:15px;color:#333">%s</p>
|
||||
<table style="width:100%%;border-collapse:collapse;font-size:14px">
|
||||
<tr><td style="padding:8px 0;color:#666">Address</td><td style="padding:8px 0">%s</td></tr>
|
||||
<tr><td style="padding:8px 0;color:#666">Blockchain Address</td><td style="padding:8px 0;font-family:monospace;font-size:12px">%s</td></tr>
|
||||
%s
|
||||
</table>
|
||||
</div>
|
||||
<div style="padding:16px 24px;background:#fafafa;font-size:12px;color:#999;text-align:center">
|
||||
Sent by Koin Ping
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`, message, meta.AddressLabel, meta.Address, txSection)
|
||||
}
|
||||
|
||||
func humanAlertType(alertType string) string {
|
||||
switch alertType {
|
||||
case "incoming_tx":
|
||||
return "Incoming Transaction"
|
||||
case "outgoing_tx":
|
||||
return "Outgoing Transaction"
|
||||
case "large_transfer":
|
||||
return "Large Transfer"
|
||||
case "balance_below":
|
||||
return "Balance Below Threshold"
|
||||
default:
|
||||
return "Alert"
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSMTPConfig_IsConfigured(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg SMTPConfig
|
||||
want bool
|
||||
}{
|
||||
{"fully configured", SMTPConfig{Host: "smtp.example.com", Port: 587, From: "noreply@example.com"}, true},
|
||||
{"missing host", SMTPConfig{Port: 587, From: "noreply@example.com"}, false},
|
||||
{"missing from", SMTPConfig{Host: "smtp.example.com", Port: 587}, false},
|
||||
{"empty", SMTPConfig{}, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.cfg.IsConfigured(); got != tt.want {
|
||||
t.Errorf("IsConfigured() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMTPConfig_Addr(t *testing.T) {
|
||||
cfg := SMTPConfig{Host: "smtp.example.com", Port: 587}
|
||||
if got := cfg.addr(); got != "smtp.example.com:587" {
|
||||
t.Errorf("addr() = %q, want %q", got, "smtp.example.com:587")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildEmailBody(t *testing.T) {
|
||||
meta := AlertMetadata{
|
||||
TxHash: "0xabc123",
|
||||
AddressLabel: "Treasury",
|
||||
AlertType: "incoming_tx",
|
||||
Address: "0x1234567890abcdef1234567890abcdef12345678",
|
||||
}
|
||||
|
||||
body := buildEmailBody("Incoming transaction: 5.5 ETH received", meta)
|
||||
|
||||
if !strings.Contains(body, "Koin Ping Alert") {
|
||||
t.Error("body should contain title")
|
||||
}
|
||||
if !strings.Contains(body, "Incoming transaction: 5.5 ETH received") {
|
||||
t.Error("body should contain message")
|
||||
}
|
||||
if !strings.Contains(body, "Treasury") {
|
||||
t.Error("body should contain address label")
|
||||
}
|
||||
if !strings.Contains(body, "0x1234567890abcdef") {
|
||||
t.Error("body should contain blockchain address")
|
||||
}
|
||||
if !strings.Contains(body, "etherscan.io/tx/0xabc123") {
|
||||
t.Error("body should contain etherscan link")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildEmailBody_NoTxHash(t *testing.T) {
|
||||
meta := AlertMetadata{
|
||||
AddressLabel: "Cold Storage",
|
||||
AlertType: "balance_below",
|
||||
Address: "0xabcdef",
|
||||
}
|
||||
|
||||
body := buildEmailBody("Balance dropped below threshold", meta)
|
||||
|
||||
if strings.Contains(body, "etherscan.io") {
|
||||
t.Error("body should not contain etherscan link when no tx hash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanAlertType(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"incoming_tx": "Incoming Transaction",
|
||||
"outgoing_tx": "Outgoing Transaction",
|
||||
"large_transfer": "Large Transfer",
|
||||
"balance_below": "Balance Below Threshold",
|
||||
"unknown": "Alert",
|
||||
"test": "Alert",
|
||||
}
|
||||
for alertType, want := range tests {
|
||||
if got := humanAlertType(alertType); got != want {
|
||||
t.Errorf("humanAlertType(%q) = %q, want %q", alertType, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendEmailNotification_NotConfigured(t *testing.T) {
|
||||
cfg := SMTPConfig{}
|
||||
sent, err := SendEmailNotification(cfg, "user@example.com", "test", AlertMetadata{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unconfigured SMTP")
|
||||
}
|
||||
if sent {
|
||||
t.Fatal("expected sent to be false")
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type slackBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text *slackText `json:"text,omitempty"`
|
||||
Fields []slackText `json:"fields,omitempty"`
|
||||
Elements []slackText `json:"elements,omitempty"`
|
||||
}
|
||||
|
||||
type slackText struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type slackPayload struct {
|
||||
Blocks []slackBlock `json:"blocks"`
|
||||
}
|
||||
|
||||
func SendSlackNotification(webhookURL, message string, meta AlertMetadata) (bool, error) {
|
||||
etherscanLink := ""
|
||||
if meta.TxHash != "" {
|
||||
etherscanLink = fmt.Sprintf("<%s|View on Etherscan>", "https://etherscan.io/tx/"+meta.TxHash)
|
||||
}
|
||||
|
||||
blocks := []slackBlock{
|
||||
{
|
||||
Type: "header",
|
||||
Text: &slackText{Type: "plain_text", Text: emojiForAlertType(meta.AlertType) + " Koin Ping Alert"},
|
||||
},
|
||||
{
|
||||
Type: "section",
|
||||
Text: &slackText{Type: "mrkdwn", Text: message},
|
||||
},
|
||||
{
|
||||
Type: "section",
|
||||
Fields: []slackText{
|
||||
{Type: "mrkdwn", Text: fmt.Sprintf("*Address:*\n%s", meta.AddressLabel)},
|
||||
{Type: "mrkdwn", Text: fmt.Sprintf("*Blockchain Address:*\n`%s`", meta.Address)},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if etherscanLink != "" {
|
||||
blocks = append(blocks, slackBlock{
|
||||
Type: "section",
|
||||
Text: &slackText{Type: "mrkdwn", Text: fmt.Sprintf("*Transaction:* %s", etherscanLink)},
|
||||
})
|
||||
}
|
||||
|
||||
blocks = append(blocks, slackBlock{
|
||||
Type: "context",
|
||||
Elements: []slackText{
|
||||
{Type: "mrkdwn", Text: fmt.Sprintf("Koin Ping | %s", time.Now().UTC().Format(time.RFC3339))},
|
||||
},
|
||||
})
|
||||
|
||||
payload := slackPayload{Blocks: blocks}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("marshal slack payload: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
log.Printf("Failed to send Slack notification: %v", err)
|
||||
return false, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
log.Printf("Slack webhook failed: HTTP %d", resp.StatusCode)
|
||||
return false, fmt.Errorf("slack webhook failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func TestSlackWebhook(webhookURL string) (bool, error) {
|
||||
payload := slackPayload{
|
||||
Blocks: []slackBlock{
|
||||
{
|
||||
Type: "section",
|
||||
Text: &slackText{
|
||||
Type: "mrkdwn",
|
||||
Text: "Koin Ping test notification — Your Slack webhook is configured correctly!",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
log.Printf("Slack webhook test failed: %v", err)
|
||||
return false, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return resp.StatusCode >= 200 && resp.StatusCode < 300, nil
|
||||
}
|
||||
|
||||
func emojiForAlertType(alertType string) string {
|
||||
switch alertType {
|
||||
case "incoming_tx":
|
||||
return "📥"
|
||||
case "outgoing_tx":
|
||||
return "📤"
|
||||
case "large_transfer":
|
||||
return "🚨"
|
||||
case "balance_below":
|
||||
return "⚠️"
|
||||
default:
|
||||
return "🔔"
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSendSlackNotification(t *testing.T) {
|
||||
var receivedPayload slackPayload
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.Header.Get("Content-Type") != "application/json" {
|
||||
t.Errorf("expected application/json content type")
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
json.Unmarshal(body, &receivedPayload)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
meta := AlertMetadata{
|
||||
TxHash: "0xabc123",
|
||||
AddressLabel: "Treasury",
|
||||
AlertType: "incoming_tx",
|
||||
Address: "0x1234567890abcdef1234567890abcdef12345678",
|
||||
}
|
||||
|
||||
sent, err := SendSlackNotification(server.URL, "Incoming transaction: 5.5 ETH received", meta)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !sent {
|
||||
t.Fatal("expected sent to be true")
|
||||
}
|
||||
|
||||
if len(receivedPayload.Blocks) < 3 {
|
||||
t.Fatalf("expected at least 3 blocks, got %d", len(receivedPayload.Blocks))
|
||||
}
|
||||
if receivedPayload.Blocks[0].Type != "header" {
|
||||
t.Errorf("first block should be header, got %s", receivedPayload.Blocks[0].Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendSlackNotification_ServerError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
sent, err := SendSlackNotification(server.URL, "test", AlertMetadata{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for server error response")
|
||||
}
|
||||
if sent {
|
||||
t.Fatal("expected sent to be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendSlackNotification_NoTxHash(t *testing.T) {
|
||||
var receivedPayload slackPayload
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
json.Unmarshal(body, &receivedPayload)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
meta := AlertMetadata{
|
||||
AddressLabel: "Treasury",
|
||||
AlertType: "balance_below",
|
||||
Address: "0x1234567890abcdef1234567890abcdef12345678",
|
||||
}
|
||||
|
||||
sent, err := SendSlackNotification(server.URL, "Balance dropped below threshold", meta)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !sent {
|
||||
t.Fatal("expected sent to be true")
|
||||
}
|
||||
|
||||
for _, block := range receivedPayload.Blocks {
|
||||
if block.Text != nil && block.Type == "section" {
|
||||
if block.Text.Text == "*Transaction:*" {
|
||||
t.Error("should not include transaction block when TxHash is empty")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestSlackWebhook(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ok, err := TestSlackWebhook(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected ok to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmojiForAlertType(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"incoming_tx": "📥",
|
||||
"outgoing_tx": "📤",
|
||||
"large_transfer": "🚨",
|
||||
"balance_below": "⚠️",
|
||||
"unknown": "🔔",
|
||||
}
|
||||
for alertType, want := range tests {
|
||||
if got := emojiForAlertType(alertType); got != want {
|
||||
t.Errorf("emojiForAlertType(%q) = %q, want %q", alertType, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
package ethereum
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
)
|
||||
|
||||
const rpcTimeoutMS = 30000
|
||||
|
||||
type JsonRpcEthereum struct {
|
||||
rpcURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewJsonRpcEthereum(rpcURL string) (*JsonRpcEthereum, error) {
|
||||
if rpcURL == "" {
|
||||
return nil, fmt.Errorf("JsonRpcEthereum requires a valid RPC URL")
|
||||
}
|
||||
return &JsonRpcEthereum{
|
||||
rpcURL: rpcURL,
|
||||
client: &http.Client{
|
||||
Timeout: time.Duration(rpcTimeoutMS) * time.Millisecond,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type rpcRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int64 `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params []interface{} `json:"params"`
|
||||
}
|
||||
|
||||
type rpcResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int64 `json:"id"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
|
||||
type rpcError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (j *JsonRpcEthereum) callRPC(ctx context.Context, method string, params ...interface{}) (json.RawMessage, error) {
|
||||
if params == nil {
|
||||
params = []interface{}{}
|
||||
}
|
||||
|
||||
body, err := json.Marshal(rpcRequest{
|
||||
JSONRPC: "2.0",
|
||||
ID: time.Now().UnixMilli(),
|
||||
Method: method,
|
||||
Params: params,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal RPC request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, j.rpcURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create RPC request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := j.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("RPC call failed [%s]: %w", method, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d: %s for %s", resp.StatusCode, resp.Status, method)
|
||||
}
|
||||
|
||||
var rpcResp rpcResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rpcResp); err != nil {
|
||||
return nil, fmt.Errorf("decode RPC response: %w", err)
|
||||
}
|
||||
|
||||
if rpcResp.Error != nil {
|
||||
return nil, fmt.Errorf("RPC Error [%s]: %s (code: %d)", method, rpcResp.Error.Message, rpcResp.Error.Code)
|
||||
}
|
||||
|
||||
return rpcResp.Result, nil
|
||||
}
|
||||
|
||||
func (j *JsonRpcEthereum) GetLatestBlockNumber(ctx context.Context) (int, error) {
|
||||
result, err := j.callRPC(ctx, "eth_blockNumber")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var hexBlock string
|
||||
if err := json.Unmarshal(result, &hexBlock); err != nil {
|
||||
return 0, fmt.Errorf("unmarshal block number: %w", err)
|
||||
}
|
||||
|
||||
return hexToInt(hexBlock)
|
||||
}
|
||||
|
||||
type rpcBlock struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
Transactions []rpcTx `json:"transactions"`
|
||||
}
|
||||
|
||||
type rpcTx struct {
|
||||
Hash string `json:"hash"`
|
||||
From string `json:"from"`
|
||||
To *string `json:"to"`
|
||||
Value string `json:"value"`
|
||||
BlockNumber string `json:"blockNumber"`
|
||||
}
|
||||
|
||||
func (j *JsonRpcEthereum) GetBlockTransactions(ctx context.Context, blockNumber int) ([]domain.NormalizedTx, error) {
|
||||
hexBlock := fmt.Sprintf("0x%x", blockNumber)
|
||||
|
||||
result, err := j.callRPC(ctx, "eth_getBlockByNumber", hexBlock, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if string(result) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var block rpcBlock
|
||||
if err := json.Unmarshal(result, &block); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal block: %w", err)
|
||||
}
|
||||
|
||||
blockTimestamp, err := hexToInt64(block.Timestamp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse block timestamp: %w", err)
|
||||
}
|
||||
|
||||
txs := make([]domain.NormalizedTx, 0, len(block.Transactions))
|
||||
for _, tx := range block.Transactions {
|
||||
bn, _ := hexToInt(tx.BlockNumber)
|
||||
|
||||
var toLower *string
|
||||
if tx.To != nil {
|
||||
s := strings.ToLower(*tx.To)
|
||||
toLower = &s
|
||||
}
|
||||
|
||||
txs = append(txs, domain.NormalizedTx{
|
||||
Hash: tx.Hash,
|
||||
From: strings.ToLower(tx.From),
|
||||
To: toLower,
|
||||
Value: hexToDecimalString(tx.Value),
|
||||
BlockNumber: bn,
|
||||
BlockTimestamp: blockTimestamp,
|
||||
})
|
||||
}
|
||||
|
||||
return txs, nil
|
||||
}
|
||||
|
||||
func (j *JsonRpcEthereum) GetBalance(ctx context.Context, address string) (string, error) {
|
||||
result, err := j.callRPC(ctx, "eth_getBalance", address, "latest")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var hexBalance string
|
||||
if err := json.Unmarshal(result, &hexBalance); err != nil {
|
||||
return "", fmt.Errorf("unmarshal balance: %w", err)
|
||||
}
|
||||
|
||||
return hexToDecimalString(hexBalance), nil
|
||||
}
|
||||
|
||||
func hexToInt(hex string) (int, error) {
|
||||
hex = strings.TrimPrefix(hex, "0x")
|
||||
n, ok := new(big.Int).SetString(hex, 16)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("invalid hex: %s", hex)
|
||||
}
|
||||
return int(n.Int64()), nil
|
||||
}
|
||||
|
||||
func hexToInt64(hex string) (int64, error) {
|
||||
hex = strings.TrimPrefix(hex, "0x")
|
||||
n, ok := new(big.Int).SetString(hex, 16)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("invalid hex: %s", hex)
|
||||
}
|
||||
return n.Int64(), nil
|
||||
}
|
||||
|
||||
func hexToDecimalString(hex string) string {
|
||||
if hex == "" || hex == "0x" || hex == "0x0" {
|
||||
return "0"
|
||||
}
|
||||
clean := strings.TrimPrefix(hex, "0x")
|
||||
n, ok := new(big.Int).SetString(clean, 16)
|
||||
if !ok {
|
||||
return "0"
|
||||
}
|
||||
return n.String()
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package ethereum
|
||||
|
||||
import (
|
||||
"math/big"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHexToInt(t *testing.T) {
|
||||
tests := []struct {
|
||||
hex string
|
||||
want int
|
||||
}{
|
||||
{"0x0", 0},
|
||||
{"0x1", 1},
|
||||
{"0xa", 10},
|
||||
{"0xff", 255},
|
||||
{"0x100", 256},
|
||||
{"0x1234", 4660},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got, err := hexToInt(tt.hex)
|
||||
if err != nil {
|
||||
t.Fatalf("hexToInt(%q) error: %v", tt.hex, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("hexToInt(%q) = %d, want %d", tt.hex, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHexToInt_Invalid(t *testing.T) {
|
||||
_, err := hexToInt("xyz")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid hex")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHexToInt64(t *testing.T) {
|
||||
got, err := hexToInt64("0x5f5e100")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != 100000000 {
|
||||
t.Errorf("got %d, want 100000000", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHexToDecimalString(t *testing.T) {
|
||||
tests := []struct {
|
||||
hex string
|
||||
want string
|
||||
}{
|
||||
{"0x0", "0"},
|
||||
{"0x", "0"},
|
||||
{"", "0"},
|
||||
{"0x1", "1"},
|
||||
{"0xde0b6b3a7640000", "1000000000000000000"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := hexToDecimalString(tt.hex)
|
||||
if got != tt.want {
|
||||
t.Errorf("hexToDecimalString(%q) = %q, want %q", tt.hex, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHexToDecimalString_LargeValues(t *testing.T) {
|
||||
expected := new(big.Int).Mul(
|
||||
big.NewInt(100),
|
||||
new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil),
|
||||
)
|
||||
hex := "0x" + expected.Text(16)
|
||||
got := hexToDecimalString(hex)
|
||||
if got != expected.String() {
|
||||
t.Errorf("got %q, want %q", got, expected.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewJsonRpcEthereum_EmptyURL(t *testing.T) {
|
||||
_, err := NewJsonRpcEthereum("")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewJsonRpcEthereum_ValidURL(t *testing.T) {
|
||||
eth, err := NewJsonRpcEthereum("https://example.com/rpc")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if eth == nil {
|
||||
t.Fatal("expected non-nil client")
|
||||
}
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/notifications"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/wei"
|
||||
)
|
||||
|
||||
type EvaluatorService struct {
|
||||
eth ethereum.EthereumObserver
|
||||
alertRules *models.AlertRuleModel
|
||||
alertEvents *models.AlertEventModel
|
||||
addresses *models.AddressModel
|
||||
notifConfigs *models.NotificationConfigModel
|
||||
smtpConfig notifications.SMTPConfig
|
||||
}
|
||||
|
||||
func NewEvaluatorService(
|
||||
eth ethereum.EthereumObserver,
|
||||
alertRules *models.AlertRuleModel,
|
||||
alertEvents *models.AlertEventModel,
|
||||
addresses *models.AddressModel,
|
||||
notifConfigs *models.NotificationConfigModel,
|
||||
smtpCfg notifications.SMTPConfig,
|
||||
) *EvaluatorService {
|
||||
return &EvaluatorService{
|
||||
eth: eth,
|
||||
alertRules: alertRules,
|
||||
alertEvents: alertEvents,
|
||||
addresses: addresses,
|
||||
notifConfigs: notifConfigs,
|
||||
smtpConfig: smtpCfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *EvaluatorService) Evaluate(ctx context.Context, observations []domain.ObservedTx) (int, error) {
|
||||
alertsFired := 0
|
||||
|
||||
for _, obs := range observations {
|
||||
fired, err := s.evaluateObservation(ctx, obs)
|
||||
if err != nil {
|
||||
log.Printf("Error evaluating observation for address ID %d: %v", obs.AddressID, err)
|
||||
continue
|
||||
}
|
||||
alertsFired += fired
|
||||
}
|
||||
|
||||
return alertsFired, nil
|
||||
}
|
||||
|
||||
func (s *EvaluatorService) evaluateObservation(ctx context.Context, obs domain.ObservedTx) (int, error) {
|
||||
rules, err := s.alertRules.ListByAddress(ctx, obs.AddressID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
alertsFired := 0
|
||||
for _, rule := range rules {
|
||||
if !rule.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
matches, err := s.ruleMatches(ctx, rule, obs)
|
||||
if err != nil {
|
||||
log.Printf("Error matching rule %d: %v", rule.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if matches {
|
||||
if err := s.fireAlert(ctx, rule, obs); err != nil {
|
||||
log.Printf("Error firing alert for rule %d: %v", rule.ID, err)
|
||||
continue
|
||||
}
|
||||
alertsFired++
|
||||
}
|
||||
}
|
||||
|
||||
return alertsFired, nil
|
||||
}
|
||||
|
||||
func (s *EvaluatorService) ruleMatches(ctx context.Context, rule domain.AlertRule, obs domain.ObservedTx) (bool, error) {
|
||||
switch rule.Type {
|
||||
case domain.AlertIncomingTx:
|
||||
return obs.Direction == domain.DirectionIncoming, nil
|
||||
case domain.AlertOutgoingTx:
|
||||
return obs.Direction == domain.DirectionOutgoing, nil
|
||||
case domain.AlertLargeTransfer:
|
||||
return s.matchesLargeTransfer(rule, obs)
|
||||
case domain.AlertBalanceBelow:
|
||||
return s.matchesBalanceBelow(ctx, rule, obs)
|
||||
default:
|
||||
log.Printf("Unknown rule type: %s", rule.Type)
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *EvaluatorService) matchesLargeTransfer(rule domain.AlertRule, obs domain.ObservedTx) (bool, error) {
|
||||
if rule.Threshold == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
thresholdWei, err := wei.FromEth(*rule.Threshold)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return wei.GreaterThanOrEqual(obs.Value, thresholdWei)
|
||||
}
|
||||
|
||||
// matchesBalanceBelow only triggers after outgoing transactions, since incoming
|
||||
// transactions increase balance and can't cause it to drop below threshold.
|
||||
func (s *EvaluatorService) matchesBalanceBelow(ctx context.Context, rule domain.AlertRule, obs domain.ObservedTx) (bool, error) {
|
||||
if rule.Threshold == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if obs.Direction != domain.DirectionOutgoing {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
addr, err := s.addresses.FindByID(ctx, obs.AddressID, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if addr == nil {
|
||||
log.Printf("Address ID %d not found", obs.AddressID)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
balanceWei, err := s.eth.GetBalance(ctx, addr.Address)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
thresholdWei, err := wei.FromEth(*rule.Threshold)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return wei.LessThan(balanceWei, thresholdWei)
|
||||
}
|
||||
|
||||
func (s *EvaluatorService) fireAlert(ctx context.Context, rule domain.AlertRule, obs domain.ObservedTx) error {
|
||||
addr, err := s.addresses.FindByID(ctx, obs.AddressID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
addressLabel := "Unknown"
|
||||
if addr != nil {
|
||||
if addr.Label != nil {
|
||||
addressLabel = *addr.Label
|
||||
} else {
|
||||
addressLabel = addr.Address
|
||||
}
|
||||
}
|
||||
|
||||
message := s.buildMessage(rule, obs)
|
||||
txHash := &obs.Hash
|
||||
|
||||
_, err = s.alertEvents.Create(ctx, rule.ID, message, &addressLabel, txHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("[ALERT FIRED] Rule %d (%s) - %s - TX: %s", rule.ID, rule.Type, message, obs.Hash)
|
||||
|
||||
if addr != nil {
|
||||
go s.sendNotifications(ctx, addr.UserID, message, obs, addressLabel, rule, addr.Address)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *EvaluatorService) sendNotifications(ctx context.Context, userID, message string, obs domain.ObservedTx, addressLabel string, rule domain.AlertRule, address string) {
|
||||
notifConfig, err := s.notifConfigs.GetConfig(ctx, userID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get notification config: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if notifConfig == nil || !notifConfig.NotificationEnabled {
|
||||
return
|
||||
}
|
||||
|
||||
meta := notifications.AlertMetadata{
|
||||
TxHash: obs.Hash,
|
||||
AddressLabel: addressLabel,
|
||||
AlertType: string(rule.Type),
|
||||
Address: address,
|
||||
}
|
||||
|
||||
if notifConfig.DiscordWebhookURL != nil && *notifConfig.DiscordWebhookURL != "" {
|
||||
sent, err := notifications.SendDiscordNotification(*notifConfig.DiscordWebhookURL, message, meta)
|
||||
if err != nil || !sent {
|
||||
log.Printf("Discord notification failed for user %s: %v", userID, err)
|
||||
} else {
|
||||
log.Printf("Discord notification sent to user %s", userID)
|
||||
}
|
||||
}
|
||||
|
||||
if notifConfig.SlackWebhookURL != nil && *notifConfig.SlackWebhookURL != "" {
|
||||
sent, err := notifications.SendSlackNotification(*notifConfig.SlackWebhookURL, message, meta)
|
||||
if err != nil || !sent {
|
||||
log.Printf("Slack notification failed for user %s: %v", userID, err)
|
||||
} else {
|
||||
log.Printf("Slack notification sent to user %s", userID)
|
||||
}
|
||||
}
|
||||
|
||||
if notifConfig.Email != nil && *notifConfig.Email != "" && s.smtpConfig.IsConfigured() {
|
||||
sent, err := notifications.SendEmailNotification(s.smtpConfig, *notifConfig.Email, message, meta)
|
||||
if err != nil || !sent {
|
||||
log.Printf("Email notification failed for user %s: %v", userID, err)
|
||||
} else {
|
||||
log.Printf("Email notification sent to user %s (%s)", userID, *notifConfig.Email)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.ObservedTx) string {
|
||||
switch rule.Type {
|
||||
case domain.AlertIncomingTx:
|
||||
ethStr, _ := wei.FormatAsEth(obs.Value, 4)
|
||||
return fmt.Sprintf("Incoming transaction: %s received", ethStr)
|
||||
case domain.AlertOutgoingTx:
|
||||
ethStr, _ := wei.FormatAsEth(obs.Value, 4)
|
||||
return fmt.Sprintf("Outgoing transaction: %s sent", ethStr)
|
||||
case domain.AlertLargeTransfer:
|
||||
ethStr, _ := wei.FormatAsEth(obs.Value, 4)
|
||||
threshold := float64(0)
|
||||
if rule.Threshold != nil {
|
||||
threshold = *rule.Threshold
|
||||
}
|
||||
return fmt.Sprintf("Large transfer detected: %s (threshold: %g ETH)", ethStr, threshold)
|
||||
case domain.AlertBalanceBelow:
|
||||
threshold := float64(0)
|
||||
if rule.Threshold != nil {
|
||||
threshold = *rule.Threshold
|
||||
}
|
||||
return fmt.Sprintf("Balance dropped below threshold of %g ETH", threshold)
|
||||
default:
|
||||
return "Alert triggered"
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
)
|
||||
|
||||
func TestFilterRelevantTransactions(t *testing.T) {
|
||||
to1 := "0xaaaa"
|
||||
to2 := "0xbbbb"
|
||||
to3 := "0xcccc"
|
||||
|
||||
txs := []domain.NormalizedTx{
|
||||
{Hash: "0x1", From: "0xaaaa", To: &to2, Value: "1000"},
|
||||
{Hash: "0x2", From: "0xbbbb", To: &to1, Value: "2000"},
|
||||
{Hash: "0x3", From: "0xcccc", To: &to3, Value: "3000"},
|
||||
{Hash: "0x4", From: "0xdddd", To: nil, Value: "0"},
|
||||
}
|
||||
|
||||
t.Run("finds outgoing", func(t *testing.T) {
|
||||
result := filterRelevantTransactions(txs, "0xAAAA")
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 relevant txs (1 from, 1 to), got %d", len(result))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("finds incoming", func(t *testing.T) {
|
||||
result := filterRelevantTransactions(txs, "0xBBBB")
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 relevant txs, got %d", len(result))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no match", func(t *testing.T) {
|
||||
result := filterRelevantTransactions(txs, "0x9999")
|
||||
if len(result) != 0 {
|
||||
t.Fatalf("expected 0 relevant txs, got %d", len(result))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("case insensitive", func(t *testing.T) {
|
||||
result := filterRelevantTransactions(txs, "0xAAAA")
|
||||
if len(result) == 0 {
|
||||
t.Fatal("should match case-insensitively")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateObservedTx(t *testing.T) {
|
||||
to := "0xaaaa"
|
||||
tx := domain.NormalizedTx{
|
||||
Hash: "0xhash",
|
||||
From: "0xbbbb",
|
||||
To: &to,
|
||||
Value: "1000",
|
||||
}
|
||||
|
||||
t.Run("incoming direction", func(t *testing.T) {
|
||||
addr := domain.Address{ID: 1, Address: "0xaaaa"}
|
||||
obs := createObservedTx(tx, addr)
|
||||
if obs.Direction != domain.DirectionIncoming {
|
||||
t.Errorf("expected incoming, got %s", obs.Direction)
|
||||
}
|
||||
if obs.AddressID != 1 {
|
||||
t.Errorf("expected address ID 1, got %d", obs.AddressID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("outgoing direction", func(t *testing.T) {
|
||||
addr := domain.Address{ID: 2, Address: "0xbbbb"}
|
||||
obs := createObservedTx(tx, addr)
|
||||
if obs.Direction != domain.DirectionOutgoing {
|
||||
t.Errorf("expected outgoing, got %s", obs.Direction)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetStartBlock(t *testing.T) {
|
||||
s := &ObserverService{}
|
||||
|
||||
t.Run("no checkpoint uses latest", func(t *testing.T) {
|
||||
got := s.getStartBlock(0, false, 1000)
|
||||
if got != 1000 {
|
||||
t.Errorf("expected 1000, got %d", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with checkpoint uses next block", func(t *testing.T) {
|
||||
got := s.getStartBlock(999, true, 1000)
|
||||
if got != 1000 {
|
||||
t.Errorf("expected 1000, got %d", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetEndBlock(t *testing.T) {
|
||||
s := &ObserverService{}
|
||||
|
||||
t.Run("caps at latest", func(t *testing.T) {
|
||||
got := s.getEndBlock(990, 1000)
|
||||
if got != 1000 {
|
||||
t.Errorf("expected 1000, got %d", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("caps at maxBlocksPerRun", func(t *testing.T) {
|
||||
got := s.getEndBlock(0, 200)
|
||||
if got != maxBlocksPerRun-1 {
|
||||
t.Errorf("expected %d, got %d", maxBlocksPerRun-1, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
package wei
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestToEth(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
wei string
|
||||
want float64
|
||||
wantErr bool
|
||||
}{
|
||||
{"zero string", "0", 0, false},
|
||||
{"empty string", "", 0, false},
|
||||
{"1 ETH", "1000000000000000000", 1.0, false},
|
||||
{"0.5 ETH", "500000000000000000", 0.5, false},
|
||||
{"10 ETH", "10000000000000000000", 10.0, false},
|
||||
{"small amount", "1000000000000000", 0.001, false},
|
||||
{"1 Wei", "1", 1e-18, false},
|
||||
{"invalid", "notanumber", 0, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ToEth(tt.wei)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if math.Abs(got-tt.want) > 1e-10 {
|
||||
t.Fatalf("ToEth(%q) = %v, want %v", tt.wei, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromEth(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
eth float64
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"zero", 0, "0", false},
|
||||
{"1 ETH", 1.0, "1000000000000000000", false},
|
||||
{"0.5 ETH", 0.5, "500000000000000000", false},
|
||||
{"10 ETH", 10.0, "10000000000000000000", false},
|
||||
{"0.001 ETH", 0.001, "1000000000000000", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := FromEth(tt.eth)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("FromEth(%v) = %q, want %q", tt.eth, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
values := []float64{0.001, 0.1, 1.0, 5.5, 10.0, 100.0}
|
||||
for _, eth := range values {
|
||||
weiStr, err := FromEth(eth)
|
||||
if err != nil {
|
||||
t.Fatalf("FromEth(%v) error: %v", eth, err)
|
||||
}
|
||||
back, err := ToEth(weiStr)
|
||||
if err != nil {
|
||||
t.Fatalf("ToEth(%q) error: %v", weiStr, err)
|
||||
}
|
||||
if math.Abs(back-eth) > 1e-10 {
|
||||
t.Fatalf("round-trip %v → %q → %v (diff: %v)", eth, weiStr, back, back-eth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompare(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"equal", "1000", "1000", 0},
|
||||
{"a > b", "2000", "1000", 1},
|
||||
{"a < b", "1000", "2000", -1},
|
||||
{"zero equal", "0", "0", 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := Compare(tt.a, tt.b)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("Compare(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGreaterThanOrEqual(t *testing.T) {
|
||||
ok, err := GreaterThanOrEqual("2000", "1000")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected true")
|
||||
}
|
||||
|
||||
ok, err = GreaterThanOrEqual("1000", "1000")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected true for equal")
|
||||
}
|
||||
|
||||
ok, err = GreaterThanOrEqual("500", "1000")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("expected false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLessThan(t *testing.T) {
|
||||
ok, err := LessThan("500", "1000")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected true")
|
||||
}
|
||||
|
||||
ok, err = LessThan("1000", "1000")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("expected false for equal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatAsEth(t *testing.T) {
|
||||
got, err := FormatAsEth("1000000000000000000", 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "1.0000 ETH" {
|
||||
t.Fatalf("got %q, want %q", got, "1.0000 ETH")
|
||||
}
|
||||
|
||||
got, err = FormatAsEth("500000000000000000", 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "0.50 ETH" {
|
||||
t.Fatalf("got %q, want %q", got, "0.50 ETH")
|
||||
}
|
||||
}
|
||||
32
backend/.golangci.yml
Normal file
32
backend/.golangci.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
version: "2"
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
modules-download-mode: readonly
|
||||
|
||||
linters:
|
||||
default: all
|
||||
disable:
|
||||
# Genuinely incompatible with project patterns
|
||||
- exhaustruct # Requires all struct fields
|
||||
- depguard # Dependency allow/block lists
|
||||
- godot # Requires comments to end with periods
|
||||
- wsl # Deprecated, replaced by wsl_v5
|
||||
- wrapcheck # Too verbose for internal packages
|
||||
- varnamelen # Short names like db, id are idiomatic Go
|
||||
|
||||
linters-settings:
|
||||
lll:
|
||||
line-length: 88
|
||||
funlen:
|
||||
lines: 80
|
||||
statements: 50
|
||||
cyclop:
|
||||
max-complexity: 15
|
||||
dupl:
|
||||
threshold: 100
|
||||
|
||||
issues:
|
||||
exclude-use-default: false
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
@@ -37,16 +37,20 @@ cp ENV_TEMPLATE.md .env
|
||||
|
||||
## Firebase Setup
|
||||
|
||||
For Firebase Admin SDK to work, you need to set up Application Default Credentials:
|
||||
For Firebase Admin SDK to work, you need to set up Application Default
|
||||
Credentials:
|
||||
|
||||
**Option 1: Use Firebase Project ID (easiest for development)**
|
||||
|
||||
- Just set `FIREBASE_PROJECT_ID` in .env
|
||||
- Firebase Admin will use Application Default Credentials
|
||||
|
||||
**Option 2: Use Service Account Key (production)**
|
||||
|
||||
1. Go to Firebase Console > Project Settings > Service Accounts
|
||||
2. Click "Generate new private key"
|
||||
3. Download the JSON file
|
||||
4. Either:
|
||||
- Set `GOOGLE_APPLICATION_CREDENTIALS=/path/to/serviceAccountKey.json` in .env
|
||||
- Or keep it in backend/ and add to .gitignore
|
||||
- Set `GOOGLE_APPLICATION_CREDENTIALS=/path/to/serviceAccountKey.json` in
|
||||
.env
|
||||
- Or keep it in backend/ and add to .gitignore
|
||||
@@ -37,7 +37,7 @@ tidy:
|
||||
|
||||
# Database setup
|
||||
db-setup:
|
||||
psql -d koin_ping_dev -f infra/schema.sql
|
||||
psql -d koin_ping -f infra/schema.sql
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
32
backend/README.md
Normal file
32
backend/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
Backend startup quickstarat:
|
||||
-----------------------------> BEST
|
||||
## 1. brew services start postgresql@15
|
||||
|
||||
OR
|
||||
|
||||
/opt/homebrew/opt/postgresql@15/bin/pg_ctl -D /opt/homebrew/var/postgresql@15 start
|
||||
|
||||
## 2. ALLIN ONE:
|
||||
Make dev-all — Runs both the API and poller concurrently.
|
||||
-----------------------------> BEST
|
||||
|
||||
- OR -
|
||||
|
||||
|
||||
## 3. Option 1: Single command (both API + poller)
|
||||
cd /Users/kjannette/workspace/koin_ping_0.2.0/backendmake dev-all
|
||||
|
||||
## 4. Option 2: Two separate terminals
|
||||
Terminal 1 (API server):
|
||||
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend go run ./cmd/api
|
||||
|
||||
make run — Builds and runs the API server.
|
||||
make dev — Runs the API server with auto-reload via air (falls back to go run if air isn't installed).
|
||||
|
||||
## 5. Terminal 2 (Poller):
|
||||
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend
|
||||
go run ./cmd/poller
|
||||
|
||||
make poller — Builds and runs the poller.
|
||||
make poller-dev — Runs the poller with auto-reload.
|
||||
|
||||
176
backend/cmd/api/main.go
Normal file
176
backend/cmd/api/main.go
Normal file
@@ -0,0 +1,176 @@
|
||||
// Package main is the entry point for the API server.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||
"github.com/kjannette/koin-ping/backend/internal/database"
|
||||
"github.com/kjannette/koin-ping/backend/internal/firebase"
|
||||
"github.com/kjannette/koin-ping/backend/internal/handlers"
|
||||
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend/internal/services"
|
||||
)
|
||||
|
||||
const (
|
||||
// max duration to read a request.
|
||||
serverReadTimeoutSeconds = 5
|
||||
// maxiduration to write a response.
|
||||
serverWriteTimeoutSeconds = 10
|
||||
)
|
||||
|
||||
//nolint:funlen
|
||||
func main() {
|
||||
_ = godotenv.Load() // .env is optional; env vars can also be set externally
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
pool, err := database.Connect(cfg.DSN())
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to database: %v", err)
|
||||
}
|
||||
|
||||
if err := firebase.Init(cfg.FirebaseProjectID); err != nil {
|
||||
log.Fatalf("Failed to initialize Firebase: %v", err)
|
||||
}
|
||||
|
||||
defer database.Close()
|
||||
|
||||
userModel := models.NewUserModel(pool)
|
||||
addressModel := models.NewAddressModel(pool)
|
||||
alertRuleModel := models.NewAlertRuleModel(pool)
|
||||
alertEventModel := models.NewAlertEventModel(pool)
|
||||
checkpointModel := models.NewCheckpointModel(pool)
|
||||
notifConfigModel := models.NewNotificationConfigModel(pool)
|
||||
|
||||
emailDigestSvc := services.NewEmailDigestService(
|
||||
cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel,
|
||||
)
|
||||
|
||||
addressHandler := handlers.NewAddressHandler(addressModel, userModel)
|
||||
alertRuleHandler := handlers.NewAlertRuleHandler(alertRuleModel, addressModel, userModel)
|
||||
alertEventHandler := handlers.NewAlertEventHandler(alertEventModel)
|
||||
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, userModel, cfg)
|
||||
emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel)
|
||||
statusHandler := handlers.NewStatusHandler(checkpointModel)
|
||||
stripeHandler := handlers.NewStripeHandler(userModel, cfg)
|
||||
accountHandler := handlers.NewAccountHandler(userModel, addressModel, cfg)
|
||||
|
||||
authenticate := middleware.Authenticate(userModel)
|
||||
requireSub := middleware.RequireSubscription(userModel)
|
||||
|
||||
// authAndSub chains authentication + subscription check for protected routes.
|
||||
authAndSub := func(h http.Handler) http.Handler {
|
||||
return authenticate(requireSub(h))
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
b := cfg.APIBasePath // e.g. "/v1"
|
||||
|
||||
// Public routes
|
||||
mux.HandleFunc("GET "+b+"/health", handlers.HealthCheck)
|
||||
mux.HandleFunc("GET "+b+"/status", statusHandler.GetStatus)
|
||||
|
||||
// Stripe webhook (public — called by Stripe, not authenticated)
|
||||
mux.HandleFunc("POST "+b+"/stripe/webhook", stripeHandler.HandleWebhook)
|
||||
|
||||
// Onboarding checkout (public — account doesn't exist yet)
|
||||
mux.HandleFunc("POST "+b+"/stripe/create-onboarding-checkout", stripeHandler.CreateOnboardingCheckout)
|
||||
|
||||
// Stripe routes (auth required, NO subscription required)
|
||||
mux.Handle("POST "+b+"/stripe/create-checkout-session",
|
||||
authenticate(http.HandlerFunc(stripeHandler.CreateCheckoutSession)))
|
||||
mux.Handle("GET "+b+"/stripe/subscription-status",
|
||||
authenticate(http.HandlerFunc(stripeHandler.GetSubscriptionStatus)))
|
||||
mux.Handle("POST "+b+"/stripe/verify-checkout",
|
||||
authenticate(http.HandlerFunc(stripeHandler.VerifyCheckoutSession)))
|
||||
mux.Handle("POST "+b+"/stripe/create-portal-session",
|
||||
authenticate(http.HandlerFunc(stripeHandler.CreatePortalSession)))
|
||||
mux.Handle("POST "+b+"/stripe/activate-free",
|
||||
authenticate(http.HandlerFunc(stripeHandler.ActivateFreeTier)))
|
||||
|
||||
// Account route (auth required, NO subscription required)
|
||||
mux.Handle("GET "+b+"/user/account",
|
||||
authenticate(http.HandlerFunc(accountHandler.GetAccount)))
|
||||
|
||||
// Authenticated + subscribed routes — addresses
|
||||
mux.Handle("POST "+b+"/addresses",
|
||||
authAndSub(http.HandlerFunc(addressHandler.Create)))
|
||||
mux.Handle("GET "+b+"/addresses",
|
||||
authAndSub(http.HandlerFunc(addressHandler.List)))
|
||||
mux.Handle("DELETE "+b+"/addresses/{addressId}",
|
||||
authAndSub(http.HandlerFunc(addressHandler.Remove)))
|
||||
mux.Handle("PATCH "+b+"/addresses/{addressId}",
|
||||
authAndSub(http.HandlerFunc(addressHandler.UpdateLabel)))
|
||||
|
||||
// Authenticated + subscribed routes — alert rules
|
||||
mux.Handle("POST "+b+"/addresses/{addressId}/alerts",
|
||||
authAndSub(http.HandlerFunc(alertRuleHandler.Create)))
|
||||
mux.Handle("GET "+b+"/addresses/{addressId}/alerts",
|
||||
authAndSub(http.HandlerFunc(alertRuleHandler.ListByAddress)))
|
||||
mux.Handle("PATCH "+b+"/alerts/{alertId}",
|
||||
authAndSub(http.HandlerFunc(alertRuleHandler.UpdateStatus)))
|
||||
mux.Handle("DELETE "+b+"/alerts/{alertId}",
|
||||
authAndSub(http.HandlerFunc(alertRuleHandler.Remove)))
|
||||
|
||||
// Authenticated + subscribed routes — alert events
|
||||
mux.Handle("GET "+b+"/alert-events",
|
||||
authAndSub(http.HandlerFunc(alertEventHandler.List)))
|
||||
|
||||
// Authenticated + subscribed routes — notification config
|
||||
mux.Handle("GET "+b+"/notification-config",
|
||||
authAndSub(http.HandlerFunc(notifConfigHandler.GetConfig)))
|
||||
mux.Handle("PUT "+b+"/notification-config",
|
||||
authAndSub(http.HandlerFunc(notifConfigHandler.UpdateConfig)))
|
||||
mux.Handle("DELETE "+b+"/notification-config",
|
||||
authAndSub(http.HandlerFunc(notifConfigHandler.DeleteConfig)))
|
||||
mux.Handle("POST "+b+"/notification-config/test",
|
||||
authAndSub(http.HandlerFunc(notifConfigHandler.TestChannels)))
|
||||
|
||||
// Authenticated + subscribed routes — email digest
|
||||
mux.Handle("POST "+b+"/email/setup",
|
||||
authAndSub(http.HandlerFunc(emailDigestHandler.SetupEmail)))
|
||||
mux.Handle("POST "+b+"/email/digest",
|
||||
authAndSub(http.HandlerFunc(emailDigestHandler.SendDigest)))
|
||||
|
||||
handler := corsMiddleware(mux)
|
||||
|
||||
addr := fmt.Sprintf(":%d", cfg.Port)
|
||||
log.Printf("Server running on port %d", cfg.Port)
|
||||
log.Printf("API base path: %s", cfg.APIBasePath)
|
||||
log.Printf("Environment: %s", cfg.NodeEnv)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: handler,
|
||||
ReadTimeout: serverReadTimeoutSeconds * time.Second,
|
||||
WriteTimeout: serverWriteTimeoutSeconds * time.Second,
|
||||
}
|
||||
if err := server.ListenAndServe(); err != nil {
|
||||
log.Fatalf("Server failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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, PATCH, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package main is the entry point for the blockchain observer poller.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -11,14 +12,21 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/config"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/database"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/notifications"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/services"
|
||||
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||
"github.com/kjannette/koin-ping/backend/internal/database"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend/internal/protocols/ethereum"
|
||||
"github.com/kjannette/koin-ping/backend/internal/services"
|
||||
)
|
||||
|
||||
const (
|
||||
// separatorWidth is the number of characters in log separator lines.
|
||||
separatorWidth = 60
|
||||
// msPerSecond converts milliseconds to seconds.
|
||||
msPerSecond = 1000
|
||||
)
|
||||
|
||||
//nolint:funlen
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
|
||||
@@ -35,29 +43,26 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to database: %v", err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
eth, err := ethereum.NewJsonRpcEthereum(cfg.EthRPCURL)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create Ethereum observer: %v", err)
|
||||
}
|
||||
|
||||
defer database.Close()
|
||||
|
||||
userModel := models.NewUserModel(pool)
|
||||
addressModel := models.NewAddressModel(pool)
|
||||
alertRuleModel := models.NewAlertRuleModel(pool)
|
||||
alertEventModel := models.NewAlertEventModel(pool)
|
||||
checkpointModel := models.NewCheckpointModel(pool)
|
||||
notifConfigModel := models.NewNotificationConfigModel(pool)
|
||||
|
||||
smtpCfg := notifications.SMTPConfig{
|
||||
Host: cfg.SMTPHost,
|
||||
Port: cfg.SMTPPort,
|
||||
Username: cfg.SMTPUser,
|
||||
Password: cfg.SMTPPassword,
|
||||
From: cfg.SMTPFrom,
|
||||
}
|
||||
|
||||
observer := services.NewObserverService(eth, addressModel, checkpointModel)
|
||||
evaluator := services.NewEvaluatorService(eth, alertRuleModel, alertEventModel, addressModel, notifConfigModel, smtpCfg)
|
||||
evaluator := services.NewEvaluatorService(
|
||||
eth, alertRuleModel, alertEventModel, addressModel, userModel, notifConfigModel,
|
||||
)
|
||||
digestSvc := services.NewEmailDigestService(cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
@@ -67,53 +72,74 @@ func main() {
|
||||
go func() {
|
||||
<-sigCh
|
||||
log.Println()
|
||||
log.Println(strings.Repeat("=", 60))
|
||||
log.Println(strings.Repeat("=", separatorWidth))
|
||||
log.Println("Shutting down poller gracefully...")
|
||||
log.Println(strings.Repeat("=", 60))
|
||||
log.Println(strings.Repeat("=", separatorWidth))
|
||||
cancel()
|
||||
}()
|
||||
|
||||
interval := time.Duration(cfg.PollIntervalMS) * time.Millisecond
|
||||
digestInterval := time.Duration(cfg.DigestIntervalHours) * time.Hour
|
||||
|
||||
log.Println(strings.Repeat("=", 60))
|
||||
log.Println(strings.Repeat("=", separatorWidth))
|
||||
log.Println("Koin Ping Observer Poller Starting")
|
||||
log.Println(strings.Repeat("=", 60))
|
||||
log.Println(strings.Repeat("=", separatorWidth))
|
||||
log.Printf("RPC URL: %s", cfg.EthRPCURL)
|
||||
log.Printf("Poll Interval: %dms (%ds)", cfg.PollIntervalMS, cfg.PollIntervalMS/1000)
|
||||
log.Println(strings.Repeat("=", 60))
|
||||
log.Printf("Poll Interval: %dms (%ds)", cfg.PollIntervalMS, cfg.PollIntervalMS/msPerSecond)
|
||||
log.Printf("Digest Interval: %dh", cfg.DigestIntervalHours)
|
||||
log.Println(strings.Repeat("=", separatorWidth))
|
||||
|
||||
runCycle(ctx, observer, evaluator)
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
digestTicker := time.NewTicker(digestInterval)
|
||||
defer digestTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Println("Poller stopped")
|
||||
|
||||
return
|
||||
case <-ticker.C:
|
||||
runCycle(ctx, observer, evaluator)
|
||||
case <-digestTicker.C:
|
||||
sent, digestErr := digestSvc.SendDigestsForAllUsers(ctx)
|
||||
if digestErr != nil {
|
||||
log.Printf("Email digest failed: %v", digestErr)
|
||||
} else {
|
||||
log.Printf("Sent %d email digests", sent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runCycle(ctx context.Context, observer *services.ObserverService, evaluator *services.EvaluatorService) {
|
||||
func runCycle(
|
||||
ctx context.Context,
|
||||
observer *services.ObserverService,
|
||||
evaluator *services.EvaluatorService,
|
||||
) {
|
||||
startTime := time.Now()
|
||||
log.Printf("[%s] Starting observation cycle...", time.Now().UTC().Format(time.RFC3339))
|
||||
|
||||
observations, err := observer.RunOnce(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[%s] Observation cycle failed: %v", time.Now().UTC().Format(time.RFC3339), err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
alertsFired, err := evaluator.Evaluate(ctx, observations)
|
||||
if err != nil {
|
||||
log.Printf("[%s] Evaluation failed: %v", time.Now().UTC().Format(time.RFC3339), err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
evaluator.WaitForNotifications()
|
||||
|
||||
duration := time.Since(startTime)
|
||||
log.Printf("[%s] Cycle complete: %d observations, %d alerts fired in %s",
|
||||
time.Now().UTC().Format(time.RFC3339),
|
||||
@@ -1,4 +1,4 @@
|
||||
module github.com/kjannette/koin-ping/backend-go
|
||||
module github.com/kjannette/koin-ping/backend
|
||||
|
||||
go 1.25.0
|
||||
|
||||
@@ -42,7 +42,9 @@ require (
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||
github.com/resend/resend-go/v3 v3.1.1 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
|
||||
github.com/stripe/stripe-go/v82 v82.5.1 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
|
||||
@@ -92,6 +92,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1
|
||||
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/resend/resend-go/v3 v3.1.1 h1:Uwpf/tZU+O/r/3nMWE6zUAMIG9dX/vTBS3wlQzYJKSw=
|
||||
github.com/resend/resend-go/v3 v3.1.1/go.mod h1:iI7VA0NoGjWvsNii5iNC5Dy0llsI3HncXPejhniYzwE=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
@@ -99,6 +101,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stripe/stripe-go/v82 v82.5.1 h1:05q6ZDKoe8PLMpQV072obF74HCgP4XJeJYoNuRSX2+8=
|
||||
github.com/stripe/stripe-go/v82 v82.5.1/go.mod h1:majCQX6AfObAvJiHraPi/5udwHi4ojRvJnnxckvHrX8=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
@@ -1,4 +1,2 @@
|
||||
ALTER TABLE user_notification_configs
|
||||
DROP COLUMN IF EXISTS telegram_chat_id,
|
||||
DROP COLUMN IF EXISTS telegram_bot_token,
|
||||
ADD COLUMN IF NOT EXISTS slack_webhook_url TEXT;
|
||||
3
backend/infra/migrations/004_alert_event_dedup.sql
Normal file
3
backend/infra/migrations/004_alert_event_dedup.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_alert_events_dedup
|
||||
ON alert_events (alert_rule_id, tx_hash)
|
||||
WHERE tx_hash IS NOT NULL;
|
||||
13
backend/infra/migrations/005_create_users_table.sql
Normal file
13
backend/infra/migrations/005_create_users_table.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- Migration 005: Create users table with UUID primary key
|
||||
-- This establishes a local user record for each Firebase-authenticated user.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
firebase_uid VARCHAR(128) NOT NULL UNIQUE,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
display_name VARCHAR(255),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_firebase_uid ON users(firebase_uid);
|
||||
73
backend/infra/migrations/006_migrate_user_ids_to_uuid.sql
Normal file
73
backend/infra/migrations/006_migrate_user_ids_to_uuid.sql
Normal file
@@ -0,0 +1,73 @@
|
||||
-- Migration 006: Migrate user_id columns from Firebase UID strings to user UUIDs
|
||||
--
|
||||
-- Prerequisites: migration 005 (users table) must be applied first.
|
||||
-- This migration backfills the users table from existing data, then swaps the
|
||||
-- VARCHAR user_id columns for UUID foreign keys referencing users(id).
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. Backfill users table from existing Firebase UIDs in addresses
|
||||
INSERT INTO users (firebase_uid, email)
|
||||
SELECT DISTINCT user_id, ''
|
||||
FROM addresses
|
||||
WHERE user_id IS NOT NULL
|
||||
ON CONFLICT (firebase_uid) DO NOTHING;
|
||||
|
||||
-- 2. Backfill from notification configs (catches users with configs but no addresses)
|
||||
INSERT INTO users (firebase_uid, email)
|
||||
SELECT DISTINCT user_id, ''
|
||||
FROM user_notification_configs
|
||||
WHERE user_id IS NOT NULL
|
||||
ON CONFLICT (firebase_uid) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 3. Migrate addresses.user_id from VARCHAR to UUID
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE addresses ADD COLUMN user_uuid UUID;
|
||||
|
||||
UPDATE addresses a
|
||||
SET user_uuid = u.id
|
||||
FROM users u
|
||||
WHERE u.firebase_uid = a.user_id;
|
||||
|
||||
-- Drop old constraints and column
|
||||
ALTER TABLE addresses DROP CONSTRAINT IF EXISTS addresses_user_id_address_key;
|
||||
DROP INDEX IF EXISTS idx_addresses_user_id;
|
||||
ALTER TABLE addresses DROP COLUMN user_id;
|
||||
|
||||
-- Rename and constrain
|
||||
ALTER TABLE addresses RENAME COLUMN user_uuid TO user_id;
|
||||
ALTER TABLE addresses ALTER COLUMN user_id SET NOT NULL;
|
||||
ALTER TABLE addresses ADD CONSTRAINT fk_addresses_user
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
ALTER TABLE addresses ADD CONSTRAINT addresses_user_id_address_key
|
||||
UNIQUE (user_id, address);
|
||||
CREATE INDEX idx_addresses_user_id ON addresses(user_id);
|
||||
|
||||
-- ============================================================
|
||||
-- 4. Migrate user_notification_configs.user_id from VARCHAR to UUID
|
||||
-- ============================================================
|
||||
|
||||
-- Drop the PK first (it's on user_id)
|
||||
ALTER TABLE user_notification_configs DROP CONSTRAINT IF EXISTS user_notification_configs_pkey;
|
||||
DROP INDEX IF EXISTS idx_notification_configs_enabled;
|
||||
|
||||
ALTER TABLE user_notification_configs ADD COLUMN user_uuid UUID;
|
||||
|
||||
UPDATE user_notification_configs nc
|
||||
SET user_uuid = u.id
|
||||
FROM users u
|
||||
WHERE u.firebase_uid = nc.user_id;
|
||||
|
||||
ALTER TABLE user_notification_configs DROP COLUMN user_id;
|
||||
ALTER TABLE user_notification_configs RENAME COLUMN user_uuid TO user_id;
|
||||
ALTER TABLE user_notification_configs ALTER COLUMN user_id SET NOT NULL;
|
||||
ALTER TABLE user_notification_configs ADD CONSTRAINT user_notification_configs_pkey
|
||||
PRIMARY KEY (user_id);
|
||||
ALTER TABLE user_notification_configs ADD CONSTRAINT fk_notification_configs_user
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
CREATE INDEX idx_notification_configs_enabled
|
||||
ON user_notification_configs(notification_enabled);
|
||||
|
||||
COMMIT;
|
||||
8
backend/infra/migrations/007_add_stripe_to_users.sql
Normal file
8
backend/infra/migrations/007_add_stripe_to_users.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- Migration 007: Add Stripe subscription fields to users table
|
||||
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS stripe_customer_id VARCHAR(255);
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS stripe_subscription_id VARCHAR(255);
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS subscription_status VARCHAR(50) DEFAULT 'none';
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS subscription_created_at TIMESTAMP;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_stripe_customer_id ON users(stripe_customer_id);
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE alert_rules ADD COLUMN minimum DECIMAL(20, 6);
|
||||
ALTER TABLE alert_rules ADD COLUMN maximum DECIMAL(20, 6);
|
||||
|
||||
ALTER TABLE alert_rules ADD CONSTRAINT non_negative_minimum CHECK (minimum IS NULL OR minimum >= 0);
|
||||
ALTER TABLE alert_rules ADD CONSTRAINT non_negative_maximum CHECK (maximum IS NULL OR maximum >= 0);
|
||||
ALTER TABLE alert_rules ADD CONSTRAINT min_lte_max CHECK (minimum IS NULL OR maximum IS NULL OR minimum <= maximum);
|
||||
8
backend/infra/migrations/009_add_subscription_tier.sql
Normal file
8
backend/infra/migrations/009_add_subscription_tier.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS subscription_tier VARCHAR(20) DEFAULT 'free';
|
||||
|
||||
-- Existing active/trialing subscribers were on the single paid plan,
|
||||
-- which is now the "premium" tier. Backfill them so they aren't downgraded.
|
||||
UPDATE users
|
||||
SET subscription_tier = 'premium'
|
||||
WHERE subscription_status IN ('active', 'trialing')
|
||||
AND stripe_subscription_id IS NOT NULL;
|
||||
@@ -4,15 +4,29 @@ DROP TABLE IF EXISTS alert_rules CASCADE;
|
||||
DROP TABLE IF EXISTS address_checkpoints CASCADE;
|
||||
DROP TABLE IF EXISTS user_notification_configs CASCADE;
|
||||
DROP TABLE IF EXISTS addresses CASCADE;
|
||||
DROP TABLE IF EXISTS users CASCADE;
|
||||
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
firebase_uid VARCHAR(128) NOT NULL UNIQUE,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
display_name VARCHAR(255),
|
||||
stripe_customer_id VARCHAR(255),
|
||||
stripe_subscription_id VARCHAR(255),
|
||||
subscription_status VARCHAR(50) DEFAULT 'none',
|
||||
subscription_tier VARCHAR(20) DEFAULT 'free',
|
||||
subscription_created_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE addresses (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id VARCHAR(128) NOT NULL, -- Firebase user ID (multi-user support)
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
address VARCHAR(42) NOT NULL, -- Ethereum address format (0x + 40 hex chars)
|
||||
label VARCHAR(255), -- Optional human-readable label
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
|
||||
-- Unique users can track the same address independently
|
||||
UNIQUE(user_id, address)
|
||||
);
|
||||
|
||||
@@ -22,6 +36,8 @@ CREATE TABLE alert_rules (
|
||||
address_id INTEGER NOT NULL REFERENCES addresses(id) ON DELETE CASCADE,
|
||||
type VARCHAR(50) NOT NULL, -- 'incoming_tx', 'outgoing_tx', 'large_transfer', 'balance_below'
|
||||
threshold DECIMAL(20, 6), -- ETH amount threshold (nullable for tx types that don't need it)
|
||||
minimum DECIMAL(20, 6), -- Optional min amount filter for incoming/outgoing alerts
|
||||
maximum DECIMAL(20, 6), -- Optional max amount filter for incoming/outgoing alerts
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
|
||||
@@ -31,7 +47,11 @@ CREATE TABLE alert_rules (
|
||||
|
||||
CONSTRAINT positive_threshold CHECK (
|
||||
threshold IS NULL OR threshold > 0
|
||||
)
|
||||
),
|
||||
|
||||
CONSTRAINT non_negative_minimum CHECK (minimum IS NULL OR minimum >= 0),
|
||||
CONSTRAINT non_negative_maximum CHECK (maximum IS NULL OR maximum >= 0),
|
||||
CONSTRAINT min_lte_max CHECK (minimum IS NULL OR maximum IS NULL OR minimum <= maximum)
|
||||
);
|
||||
|
||||
|
||||
@@ -51,16 +71,20 @@ CREATE TABLE address_checkpoints (
|
||||
);
|
||||
|
||||
CREATE TABLE user_notification_configs (
|
||||
user_id VARCHAR(128) PRIMARY KEY,
|
||||
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
discord_webhook_url TEXT, -- Discord webhook URL (nullable)
|
||||
slack_webhook_url TEXT, -- Slack incoming webhook URL (nullable)
|
||||
telegram_chat_id VARCHAR(128), -- Telegram chat ID (nullable)
|
||||
telegram_bot_token VARCHAR(255), -- Telegram bot token (nullable)
|
||||
email VARCHAR(255), -- Email for notifications (nullable)
|
||||
slack_webhook_url TEXT, -- Slack incoming webhook URL (nullable)
|
||||
notification_enabled BOOLEAN DEFAULT TRUE, -- Master on/off switch
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create indexes for common queries
|
||||
CREATE INDEX idx_users_firebase_uid ON users(firebase_uid);
|
||||
CREATE INDEX idx_users_stripe_customer_id ON users(stripe_customer_id);
|
||||
CREATE INDEX idx_addresses_user_id ON addresses(user_id);
|
||||
CREATE INDEX idx_alert_rules_address_id ON alert_rules(address_id);
|
||||
CREATE INDEX idx_alert_rules_enabled ON alert_rules(enabled);
|
||||
126
backend/internal/config/config.go
Normal file
126
backend/internal/config/config.go
Normal file
@@ -0,0 +1,126 @@
|
||||
//loads environment-based configuration.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPort = 3001
|
||||
defaultDBPort = 5432
|
||||
defaultPollIntervalMS = 60000
|
||||
minPollIntervalMS = 1000
|
||||
defaultDigestIntervalHours = 24
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port int
|
||||
APIBasePath string
|
||||
DatabaseURL string
|
||||
DBHost string
|
||||
DBPort int
|
||||
DBUser string
|
||||
DBPassword string
|
||||
DBName string
|
||||
FirebaseProjectID string
|
||||
EthRPCURL string
|
||||
PollIntervalMS int
|
||||
NodeEnv string
|
||||
ResendAPIKey string
|
||||
EmailFrom string
|
||||
DigestIntervalHours int
|
||||
StripeSecretKey string
|
||||
StripeWebhookSecret string
|
||||
StripePriceIDPremium string
|
||||
StripePriceIDPro string
|
||||
StripePublishableKey string
|
||||
FrontendURL string
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables and returns a Config.
|
||||
func Load() (*Config, error) {
|
||||
cfg := &Config{
|
||||
Port: getEnvInt("PORT", defaultPort),
|
||||
APIBasePath: getEnv("API_BASE_PATH", "/v1"),
|
||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||
DBHost: getEnv("DB_HOST", "localhost"),
|
||||
DBPort: getEnvInt("DB_PORT", defaultDBPort),
|
||||
DBUser: os.Getenv("DB_USER"),
|
||||
DBPassword: os.Getenv("DB_PASSWORD"),
|
||||
DBName: os.Getenv("DB_NAME"),
|
||||
FirebaseProjectID: os.Getenv("FIREBASE_PROJECT_ID"),
|
||||
EthRPCURL: os.Getenv("ETH_RPC_URL"),
|
||||
PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", defaultPollIntervalMS),
|
||||
NodeEnv: getEnv("NODE_ENV", "development"),
|
||||
ResendAPIKey: os.Getenv("RESEND_API_KEY"),
|
||||
EmailFrom: getEnv("EMAIL_FROM", "Koin Ping <alerts@koinping.com>"),
|
||||
DigestIntervalHours: getEnvInt("DIGEST_INTERVAL_HOURS", defaultDigestIntervalHours),
|
||||
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
|
||||
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
|
||||
StripePriceIDPremium: os.Getenv("STRIPE_PRICE_ID_PREMIUM"),
|
||||
StripePriceIDPro: os.Getenv("STRIPE_PRICE_ID_PRO"),
|
||||
StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"),
|
||||
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
|
||||
}
|
||||
|
||||
if cfg.PollIntervalMS < minPollIntervalMS {
|
||||
return nil, fmt.Errorf("POLL_INTERVAL_MS must be >= 1000, got %d", cfg.PollIntervalMS) //nolint:err113
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) DSN() string {
|
||||
if c.DatabaseURL != "" {
|
||||
// Append sslmode=disable if not already specified.
|
||||
if !strings.Contains(c.DatabaseURL, "sslmode=") {
|
||||
sep := "?"
|
||||
if strings.Contains(c.DatabaseURL, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
|
||||
return c.DatabaseURL + sep + "sslmode=disable"
|
||||
}
|
||||
|
||||
return c.DatabaseURL
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
|
||||
c.DBHost, c.DBPort, c.DBUser, c.DBPassword, c.DBName,
|
||||
)
|
||||
}
|
||||
|
||||
// TierForPriceID maps a Stripe price ID back to the corresponding
|
||||
// subscription tier. Returns empty string if the price is unrecognised.
|
||||
func (c *Config) TierForPriceID(priceID string) string {
|
||||
switch priceID {
|
||||
case c.StripePriceIDPremium:
|
||||
return "premium"
|
||||
case c.StripePriceIDPro:
|
||||
return "pro"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getEnvInt(key string, fallback int) int {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
28
backend/internal/config/config_test.go
Normal file
28
backend/internal/config/config_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTierForPriceID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := &Config{
|
||||
StripePriceIDPremium: "price_premium_123",
|
||||
StripePriceIDPro: "price_pro_456",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
priceID string
|
||||
want string
|
||||
}{
|
||||
{"price_premium_123", "premium"},
|
||||
{"price_pro_456", "pro"},
|
||||
{"price_unknown", ""},
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := cfg.TierForPriceID(tt.priceID); got != tt.want {
|
||||
t.Errorf("TierForPriceID(%q) = %q, want %q", tt.priceID, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,20 +9,34 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var pool *pgxpool.Pool
|
||||
const (
|
||||
|
||||
maxConnIdleSeconds = 30
|
||||
|
||||
maxConnLifetimeMinutes = 5
|
||||
|
||||
connectTimeoutSeconds = 10
|
||||
|
||||
maxConns = 20
|
||||
|
||||
minConns = 2
|
||||
)
|
||||
|
||||
var pool *pgxpool.Pool //nolint:gochecknoglobals
|
||||
|
||||
// establishPostgreSQL connection pool using the given DSN.
|
||||
func Connect(dsn string) (*pgxpool.Pool, error) {
|
||||
cfg, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse database config: %w", err)
|
||||
}
|
||||
|
||||
cfg.MaxConns = 20
|
||||
cfg.MinConns = 2
|
||||
cfg.MaxConnIdleTime = 30 * time.Second
|
||||
cfg.MaxConnLifetime = 5 * time.Minute
|
||||
cfg.MaxConns = maxConns
|
||||
cfg.MinConns = minConns
|
||||
cfg.MaxConnIdleTime = maxConnIdleSeconds * time.Second
|
||||
cfg.MaxConnLifetime = maxConnLifetimeMinutes * time.Minute
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), connectTimeoutSeconds*time.Second)
|
||||
defer cancel()
|
||||
|
||||
p, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||
@@ -32,11 +46,13 @@ func Connect(dsn string) (*pgxpool.Pool, error) {
|
||||
|
||||
if err := p.Ping(ctx); err != nil {
|
||||
p.Close()
|
||||
|
||||
return nil, fmt.Errorf("ping database: %w", err)
|
||||
}
|
||||
|
||||
log.Println("Connected to PostgreSQL database")
|
||||
pool = p
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
211
backend/internal/domain/types.go
Normal file
211
backend/internal/domain/types.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type SubscriptionTier string
|
||||
|
||||
const (
|
||||
TierFree SubscriptionTier = "free"
|
||||
TierPremium SubscriptionTier = "premium"
|
||||
TierPro SubscriptionTier = "pro"
|
||||
)
|
||||
|
||||
func IsValidTier(t string) bool {
|
||||
switch SubscriptionTier(t) {
|
||||
case TierFree, TierPremium, TierPro:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const unlimitedLimit = -1
|
||||
|
||||
type TierLimits struct {
|
||||
MaxAddresses int `json:"max_addresses"`
|
||||
MaxAlertTypes int `json:"max_alert_types"`
|
||||
AllowedChannels []string `json:"allowed_channels"`
|
||||
}
|
||||
|
||||
func GetTierLimits(tier SubscriptionTier) TierLimits {
|
||||
switch tier {
|
||||
case TierPremium:
|
||||
return TierLimits{
|
||||
MaxAddresses: 3,
|
||||
MaxAlertTypes: 2,
|
||||
AllowedChannels: []string{"email", "discord", "telegram"},
|
||||
}
|
||||
case TierPro:
|
||||
return TierLimits{
|
||||
MaxAddresses: unlimitedLimit,
|
||||
MaxAlertTypes: unlimitedLimit,
|
||||
AllowedChannels: []string{"email", "discord", "telegram", "slack"},
|
||||
}
|
||||
default:
|
||||
return TierLimits{
|
||||
MaxAddresses: 1,
|
||||
MaxAlertTypes: 1,
|
||||
AllowedChannels: []string{"email"},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l TierLimits) IsUnlimitedAddresses() bool { return l.MaxAddresses == unlimitedLimit }
|
||||
func (l TierLimits) IsUnlimitedAlertTypes() bool { return l.MaxAlertTypes == unlimitedLimit }
|
||||
|
||||
func (l TierLimits) ChannelAllowed(channel string) bool {
|
||||
for _, c := range l.AllowedChannels {
|
||||
if c == channel {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
FirebaseUID string `json:"-"`
|
||||
Email string `json:"email"`
|
||||
DisplayName *string `json:"display_name"` //nolint:tagliatelle
|
||||
StripeCustomerID *string `json:"-"`
|
||||
StripeSubscriptionID *string `json:"-"`
|
||||
SubscriptionStatus string `json:"subscription_status"` //nolint:tagliatelle
|
||||
SubscriptionTier SubscriptionTier `json:"subscription_tier"` //nolint:tagliatelle
|
||||
SubscriptionCreatedAt *time.Time `json:"subscription_created_at,omitempty"` //nolint:tagliatelle
|
||||
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
|
||||
UpdatedAt time.Time `json:"updated_at"` //nolint:tagliatelle
|
||||
}
|
||||
|
||||
type Address struct {
|
||||
ID int `json:"id"`
|
||||
UserID string `json:"user_id"` //nolint:tagliatelle
|
||||
Address string `json:"address"`
|
||||
Label *string `json:"label"`
|
||||
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
|
||||
}
|
||||
|
||||
type AlertType string
|
||||
|
||||
func (a AlertType) String() string { return string(a) }
|
||||
|
||||
const (
|
||||
AlertIncomingTx AlertType = "incoming_tx"
|
||||
AlertOutgoingTx AlertType = "outgoing_tx"
|
||||
AlertLargeTransfer AlertType = "large_transfer"
|
||||
AlertBalanceBelow AlertType = "balance_below"
|
||||
)
|
||||
|
||||
var ValidAlertTypes = []AlertType{ //nolint:gochecknoglobals
|
||||
AlertIncomingTx,
|
||||
AlertOutgoingTx,
|
||||
AlertLargeTransfer,
|
||||
AlertBalanceBelow,
|
||||
}
|
||||
|
||||
// ThresholdRequiredTypes lists alert types that require a threshold value.
|
||||
var ThresholdRequiredTypes = []AlertType{ //nolint:gochecknoglobals
|
||||
AlertLargeTransfer,
|
||||
AlertBalanceBelow,
|
||||
}
|
||||
|
||||
// returns true if the given string matches a known AlertType.
|
||||
func IsValidAlertType(t string) bool {
|
||||
for _, v := range ValidAlertTypes {
|
||||
if string(v) == t {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func IsThresholdRequired(t AlertType) bool {
|
||||
for _, v := range ThresholdRequiredTypes {
|
||||
if v == t {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
type AlertRule struct {
|
||||
ID int `json:"id"`
|
||||
AddressID int `json:"address_id"` //nolint:tagliatelle
|
||||
Type AlertType `json:"type"`
|
||||
Threshold *float64 `json:"threshold"`
|
||||
Minimum *float64 `json:"minimum"`
|
||||
Maximum *float64 `json:"maximum"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
|
||||
}
|
||||
|
||||
type AlertEvent struct {
|
||||
ID int `json:"id"`
|
||||
AlertRuleID int `json:"alert_rule_id"` //nolint:tagliatelle
|
||||
Message string `json:"message"`
|
||||
AddressLabel *string `json:"address_label"` //nolint:tagliatelle
|
||||
TxHash *string `json:"tx_hash"` //nolint:tagliatelle
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
type AddressCheckpoint struct {
|
||||
AddressID int `json:"address_id"` //nolint:tagliatelle
|
||||
LastCheckedBlock int `json:"last_checked_block"` //nolint:tagliatelle
|
||||
LastCheckedAt time.Time `json:"last_checked_at"` //nolint:tagliatelle
|
||||
}
|
||||
|
||||
type CheckpointDetail struct {
|
||||
AddressID int `json:"address_id"` //nolint:tagliatelle
|
||||
Address string `json:"address"`
|
||||
Label *string `json:"label"`
|
||||
LastCheckedBlock int `json:"last_checked_block"` //nolint:tagliatelle
|
||||
LastCheckedAt time.Time `json:"last_checked_at"` //nolint:tagliatelle
|
||||
}
|
||||
|
||||
// holds a user's notification preferences.
|
||||
type NotificationConfig struct {
|
||||
UserID string `json:"user_id"` //nolint:tagliatelle
|
||||
DiscordWebhookURL *string `json:"discord_webhook_url"` //nolint:tagliatelle
|
||||
TelegramChatID *string `json:"telegram_chat_id"` //nolint:tagliatelle
|
||||
TelegramBotToken *string `json:"telegram_bot_token"` //nolint:tagliatelle
|
||||
Email *string `json:"email"`
|
||||
SlackWebhookURL *string `json:"slack_webhook_url"` //nolint:tagliatelle
|
||||
NotificationEnabled bool `json:"notification_enabled"` //nolint:tagliatelle
|
||||
CreatedAt *time.Time `json:"created_at,omitempty"` //nolint:tagliatelle
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"` //nolint:tagliatelle
|
||||
}
|
||||
|
||||
// NormalizedTx is a blockchain transaction normalized for internal use.
|
||||
type NormalizedTx struct {
|
||||
Hash string `json:"hash"`
|
||||
From string `json:"from"`
|
||||
To *string `json:"to"`
|
||||
Value string `json:"value"` // Wei as string for precision
|
||||
BlockNumber int `json:"block_number"` //nolint:tagliatelle
|
||||
BlockTimestamp int64 `json:"block_timestamp"` //nolint:tagliatelle
|
||||
|
||||
// ERC-20 token transfer fields (nil for native ETH transfers)
|
||||
TokenContract *string `json:"token_contract,omitempty"` //nolint:tagliatelle
|
||||
TokenSymbol *string `json:"token_symbol,omitempty"` //nolint:tagliatelle
|
||||
TokenDecimals *int `json:"token_decimals,omitempty"` //nolint:tagliatelle
|
||||
TokenValue *string `json:"token_value,omitempty"` //nolint:tagliatelle
|
||||
}
|
||||
|
||||
func (tx NormalizedTx) IsTokenTransfer() bool {
|
||||
return tx.TokenContract != nil
|
||||
}
|
||||
|
||||
type Direction string
|
||||
|
||||
func (d Direction) String() string { return string(d) }
|
||||
|
||||
const (
|
||||
DirectionIncoming Direction = "incoming"
|
||||
DirectionOutgoing Direction = "outgoing"
|
||||
)
|
||||
|
||||
type ObservedTx struct {
|
||||
NormalizedTx
|
||||
AddressID int `json:"address_id"` //nolint:tagliatelle
|
||||
Direction Direction `json:"direction"`
|
||||
}
|
||||
104
backend/internal/domain/types_test.go
Normal file
104
backend/internal/domain/types_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsValidTier(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
valid := []string{"free", "premium", "pro"}
|
||||
for _, tier := range valid {
|
||||
if !IsValidTier(tier) {
|
||||
t.Errorf("expected %q to be valid", tier)
|
||||
}
|
||||
}
|
||||
|
||||
invalid := []string{"", "basic", "enterprise", "FREE", "Pro"}
|
||||
for _, tier := range invalid {
|
||||
if IsValidTier(tier) {
|
||||
t.Errorf("expected %q to be invalid", tier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTierLimits_Free(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
limits := GetTierLimits(TierFree)
|
||||
if limits.MaxAddresses != 1 {
|
||||
t.Errorf("free MaxAddresses = %d, want 1", limits.MaxAddresses)
|
||||
}
|
||||
if limits.MaxAlertTypes != 1 {
|
||||
t.Errorf("free MaxAlertTypes = %d, want 1", limits.MaxAlertTypes)
|
||||
}
|
||||
if len(limits.AllowedChannels) != 1 || limits.AllowedChannels[0] != "email" {
|
||||
t.Errorf("free AllowedChannels = %v, want [email]", limits.AllowedChannels)
|
||||
}
|
||||
if limits.IsUnlimitedAddresses() {
|
||||
t.Error("free should not have unlimited addresses")
|
||||
}
|
||||
if limits.IsUnlimitedAlertTypes() {
|
||||
t.Error("free should not have unlimited alert types")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTierLimits_Premium(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
limits := GetTierLimits(TierPremium)
|
||||
if limits.MaxAddresses != 3 {
|
||||
t.Errorf("premium MaxAddresses = %d, want 3", limits.MaxAddresses)
|
||||
}
|
||||
if limits.MaxAlertTypes != 2 {
|
||||
t.Errorf("premium MaxAlertTypes = %d, want 2", limits.MaxAlertTypes)
|
||||
}
|
||||
if !limits.ChannelAllowed("email") {
|
||||
t.Error("premium should allow email")
|
||||
}
|
||||
if !limits.ChannelAllowed("discord") {
|
||||
t.Error("premium should allow discord")
|
||||
}
|
||||
if !limits.ChannelAllowed("telegram") {
|
||||
t.Error("premium should allow telegram")
|
||||
}
|
||||
if limits.ChannelAllowed("slack") {
|
||||
t.Error("premium should NOT allow slack")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTierLimits_Pro(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
limits := GetTierLimits(TierPro)
|
||||
if !limits.IsUnlimitedAddresses() {
|
||||
t.Error("pro should have unlimited addresses")
|
||||
}
|
||||
if !limits.IsUnlimitedAlertTypes() {
|
||||
t.Error("pro should have unlimited alert types")
|
||||
}
|
||||
for _, ch := range []string{"email", "discord", "telegram", "slack"} {
|
||||
if !limits.ChannelAllowed(ch) {
|
||||
t.Errorf("pro should allow %s", ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTierLimits_Unknown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
limits := GetTierLimits(SubscriptionTier("unknown"))
|
||||
if limits.MaxAddresses != 1 {
|
||||
t.Errorf("unknown tier should default to free limits, got MaxAddresses=%d", limits.MaxAddresses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelAllowed_NotInList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
limits := GetTierLimits(TierFree)
|
||||
if limits.ChannelAllowed("discord") {
|
||||
t.Error("free should not allow discord")
|
||||
}
|
||||
if limits.ChannelAllowed("nonexistent") {
|
||||
t.Error("nonexistent channel should not be allowed")
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,10 @@ import (
|
||||
"google.golang.org/api/option"
|
||||
)
|
||||
|
||||
var (
|
||||
authClient *auth.Client
|
||||
once sync.Once
|
||||
initErr error
|
||||
var ( //nolint:gochecknoglobals
|
||||
authClient *auth.Client //nolint:gochecknoglobals
|
||||
once sync.Once //nolint:gochecknoglobals
|
||||
errInit error //nolint:gochecknoglobals
|
||||
)
|
||||
|
||||
func Init(projectID string) error {
|
||||
@@ -30,18 +30,20 @@ func Init(projectID string) error {
|
||||
app, err = fb.NewApp(ctx, nil)
|
||||
}
|
||||
if err != nil {
|
||||
initErr = fmt.Errorf("initialize firebase app: %w", err)
|
||||
errInit = fmt.Errorf("initialize firebase app: %w", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
authClient, err = app.Auth(ctx)
|
||||
if err != nil {
|
||||
initErr = fmt.Errorf("initialize firebase auth: %w", err)
|
||||
errInit = fmt.Errorf("initialize firebase auth: %w", err)
|
||||
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
return initErr
|
||||
return errInit
|
||||
}
|
||||
|
||||
func Auth() *auth.Client {
|
||||
88
backend/internal/handlers/account.go
Normal file
88
backend/internal/handlers/account.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
)
|
||||
|
||||
type AccountHandler struct {
|
||||
users *models.UserModel
|
||||
addresses *models.AddressModel
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewAccountHandler(users *models.UserModel, addresses *models.AddressModel, cfg *config.Config) *AccountHandler {
|
||||
return &AccountHandler{users: users, addresses: addresses, cfg: cfg}
|
||||
}
|
||||
|
||||
type accountResponse struct {
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
UserName string `json:"user_name"`
|
||||
SubscriptionStatus string `json:"subscription_status"`
|
||||
SubscriptionTier string `json:"subscription_tier"`
|
||||
SubscriptionPlan string `json:"subscription_plan"`
|
||||
TierLimits domain.TierLimits `json:"tier_limits"`
|
||||
AddressCount int `json:"address_count"`
|
||||
MemberSince *string `json:"member_since,omitempty"`
|
||||
NextBillingDate *string `json:"next_billing_date,omitempty"`
|
||||
CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
|
||||
PeriodEndDate *string `json:"period_end_date,omitempty"`
|
||||
}
|
||||
|
||||
var tierPlanLabels = map[domain.SubscriptionTier]string{ //nolint:gochecknoglobals
|
||||
domain.TierFree: "Free Trial",
|
||||
domain.TierPremium: "Premium / $1.99 mo",
|
||||
domain.TierPro: "Pro / $11.99 mo",
|
||||
}
|
||||
|
||||
func (h *AccountHandler) GetAccount(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
email := middleware.GetUserEmail(r.Context())
|
||||
|
||||
user, err := h.users.GetByID(r.Context(), userID)
|
||||
if err != nil || user == nil {
|
||||
log.Printf("Account: failed to get user %s: %v", userID, err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to load user")
|
||||
return
|
||||
}
|
||||
|
||||
addrCount, err := h.addresses.CountByUser(r.Context(), userID)
|
||||
if err != nil {
|
||||
log.Printf("Account: failed to count addresses for %s: %v", userID, err)
|
||||
addrCount = 0
|
||||
}
|
||||
|
||||
planLabel := tierPlanLabels[user.SubscriptionTier]
|
||||
if planLabel == "" {
|
||||
planLabel = "Free Trial"
|
||||
}
|
||||
|
||||
resp := accountResponse{
|
||||
UserID: user.ID,
|
||||
Email: email,
|
||||
UserName: email,
|
||||
SubscriptionStatus: user.SubscriptionStatus,
|
||||
SubscriptionTier: string(user.SubscriptionTier),
|
||||
SubscriptionPlan: planLabel,
|
||||
TierLimits: domain.GetTierLimits(user.SubscriptionTier),
|
||||
AddressCount: addrCount,
|
||||
}
|
||||
|
||||
if user.SubscriptionCreatedAt != nil {
|
||||
t := user.SubscriptionCreatedAt.Format(time.DateOnly)
|
||||
resp.MemberSince = &t
|
||||
}
|
||||
|
||||
resp.NextBillingDate = nil
|
||||
resp.CancelAtPeriodEnd = false
|
||||
resp.PeriodEndDate = nil
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
@@ -1,25 +1,28 @@
|
||||
// Package handlers implements HTTP request handlers for the API.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
)
|
||||
|
||||
var ethAddressRe = regexp.MustCompile(`^0x[a-fA-F0-9]{40}$`)
|
||||
|
||||
type AddressHandler struct {
|
||||
addresses *models.AddressModel
|
||||
users *models.UserModel
|
||||
}
|
||||
|
||||
func NewAddressHandler(addresses *models.AddressModel) *AddressHandler {
|
||||
return &AddressHandler{addresses: addresses}
|
||||
func NewAddressHandler(addresses *models.AddressModel, users *models.UserModel) *AddressHandler {
|
||||
return &AddressHandler{addresses: addresses, users: users}
|
||||
}
|
||||
|
||||
func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -32,29 +35,56 @@ func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
log.Printf("Failed to decode address request body: %v", err)
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if body.Address == "" {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Address is required")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !ethAddressRe.MatchString(body.Address) {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid Ethereum address format")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.users.GetByID(r.Context(), userID)
|
||||
if err != nil || user == nil {
|
||||
log.Printf("Failed to get user %s for tier check: %v", userID, err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to verify account")
|
||||
return
|
||||
}
|
||||
|
||||
limits := domain.GetTierLimits(user.SubscriptionTier)
|
||||
if !limits.IsUnlimitedAddresses() {
|
||||
count, err := h.addresses.CountByUser(r.Context(), userID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to count addresses for user %s: %v", userID, err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create address")
|
||||
return
|
||||
}
|
||||
if count >= limits.MaxAddresses {
|
||||
writeError(w, http.StatusForbidden, "TIER_LIMIT_REACHED",
|
||||
fmt.Sprintf("Your %s plan allows %d address(es). Upgrade to track more.", user.SubscriptionTier, limits.MaxAddresses))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("User %s creating address: %s", userID, body.Address)
|
||||
|
||||
addr, err := h.addresses.Create(r.Context(), userID, body.Address, body.Label)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "23505") || strings.Contains(err.Error(), "unique") {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "You are already tracking this address")
|
||||
|
||||
return
|
||||
}
|
||||
log.Printf("Error creating address: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create address")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -62,6 +92,7 @@ func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, addr)
|
||||
}
|
||||
|
||||
// List handles GET requests to list all tracked addresses for the current user.
|
||||
func (h *AddressHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
log.Printf("User %s listing addresses", userID)
|
||||
@@ -70,6 +101,7 @@ func (h *AddressHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
log.Printf("Error listing addresses: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list addresses")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -81,11 +113,51 @@ func (h *AddressHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, addresses)
|
||||
}
|
||||
|
||||
// handles PATCH requests to update an address label.
|
||||
func (h *AddressHandler) UpdateLabel(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
addressID, ok := parseIntParam(r.PathValue("addressId"))
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid address ID")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Label *string `json:"label"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("User %s updating label for address ID: %d", userID, addressID)
|
||||
|
||||
addr, err := h.addresses.UpdateLabel(r.Context(), addressID, userID, body.Label)
|
||||
if err != nil {
|
||||
log.Printf("Error updating address label: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update address")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if addr == nil {
|
||||
writeError(w, http.StatusNotFound, "NOT_FOUND", "Address not found")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, addr)
|
||||
}
|
||||
|
||||
// Remove handles DELETE requests to remove a tracked address.
|
||||
func (h *AddressHandler) Remove(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
addressID, ok := parseIntParam(r.PathValue("addressId"))
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid address ID")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -95,12 +167,14 @@ func (h *AddressHandler) Remove(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
log.Printf("Error deleting address: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete address")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !deleted {
|
||||
log.Printf("Address %d not found or not owned by user", addressID)
|
||||
writeError(w, http.StatusNotFound, "NOT_FOUND", "Address not found")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5,18 +5,22 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
)
|
||||
|
||||
// AlertEventHandler handles HTTP requests for alert event history.
|
||||
type AlertEventHandler struct {
|
||||
alertEvents *models.AlertEventModel
|
||||
}
|
||||
|
||||
// NewAlertEventHandler creates a new AlertEventHandler.
|
||||
func NewAlertEventHandler(alertEvents *models.AlertEventModel) *AlertEventHandler {
|
||||
return &AlertEventHandler{alertEvents: alertEvents}
|
||||
}
|
||||
|
||||
// List handles GET requests to list recent alert events for the current user.
|
||||
func (h *AlertEventHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
|
||||
@@ -32,6 +36,7 @@ func (h *AlertEventHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if limit < 1 || limit > 100 {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Limit must be between 1 and 100")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -39,10 +44,15 @@ func (h *AlertEventHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
log.Printf("Error listing alert events: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list alert events")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Found %d alert events for user", len(events))
|
||||
|
||||
if events == nil {
|
||||
events = []domain.AlertEvent{}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, events)
|
||||
}
|
||||
@@ -2,24 +2,28 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
)
|
||||
|
||||
var errThresholdFormat = errors.New("unsupported threshold format")
|
||||
|
||||
type AlertRuleHandler struct {
|
||||
alertRules *models.AlertRuleModel
|
||||
addresses *models.AddressModel
|
||||
users *models.UserModel
|
||||
}
|
||||
|
||||
func NewAlertRuleHandler(alertRules *models.AlertRuleModel, addresses *models.AddressModel) *AlertRuleHandler {
|
||||
return &AlertRuleHandler{alertRules: alertRules, addresses: addresses}
|
||||
func NewAlertRuleHandler(alertRules *models.AlertRuleModel, addresses *models.AddressModel, users *models.UserModel) *AlertRuleHandler {
|
||||
return &AlertRuleHandler{alertRules: alertRules, addresses: addresses, users: users}
|
||||
}
|
||||
|
||||
func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -27,16 +31,20 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
addressID, ok := parseIntParam(r.PathValue("addressId"))
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid address ID")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Type string `json:"type"`
|
||||
Threshold json.RawMessage `json:"threshold"`
|
||||
Minimum json.RawMessage `json:"minimum"`
|
||||
Maximum json.RawMessage `json:"maximum"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
log.Printf("Failed to decode alert request body: %v", err)
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -44,6 +52,41 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
log.Printf("Failed to parse threshold: %v", err)
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "threshold must be a valid number")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
minimum, err := parseThreshold(body.Minimum)
|
||||
if err != nil {
|
||||
log.Printf("Failed to parse minimum: %v", err)
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be a valid number")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
maximum, err := parseThreshold(body.Maximum)
|
||||
if err != nil {
|
||||
log.Printf("Failed to parse maximum: %v", err)
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be a valid number")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if minimum != nil && *minimum < 0 {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be non-negative")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if maximum != nil && *maximum < 0 {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be non-negative")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if minimum != nil && maximum != nil && *minimum > *maximum {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must not exceed maximum")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -51,16 +94,18 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if body.Type == "" {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Alert type is required")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !domain.IsValidAlertType(body.Type) {
|
||||
types := make([]string, len(domain.ValidAlertTypes))
|
||||
for i, t := range domain.ValidAlertTypes {
|
||||
types[i] = string(t)
|
||||
types[i] = t.String()
|
||||
}
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
|
||||
fmt.Sprintf("Invalid alert type. Must be one of: %s", strings.Join(types, ", ")))
|
||||
"Invalid alert type. Must be one of: "+strings.Join(types, ", "))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -69,6 +114,7 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
if threshold == nil || *threshold <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
|
||||
fmt.Sprintf("Alert type '%s' requires a positive threshold value", body.Type))
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -77,18 +123,43 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
log.Printf("Error finding address: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule")
|
||||
|
||||
return
|
||||
}
|
||||
if addr == nil {
|
||||
log.Printf("Address %d not found or not owned by user", addressID)
|
||||
writeError(w, http.StatusNotFound, "NOT_FOUND", "Address not found")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
newAlert, err := h.alertRules.Create(r.Context(), addressID, alertType, threshold)
|
||||
user, userErr := h.users.GetByID(r.Context(), userID)
|
||||
if userErr != nil || user == nil {
|
||||
log.Printf("Failed to get user %s for tier check: %v", userID, userErr)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to verify account")
|
||||
return
|
||||
}
|
||||
|
||||
limits := domain.GetTierLimits(user.SubscriptionTier)
|
||||
if !limits.IsUnlimitedAlertTypes() {
|
||||
typeCount, countErr := h.alertRules.CountDistinctTypesByAddress(r.Context(), addressID)
|
||||
if countErr != nil {
|
||||
log.Printf("Failed to count alert types for address %d: %v", addressID, countErr)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule")
|
||||
return
|
||||
}
|
||||
if typeCount >= limits.MaxAlertTypes {
|
||||
writeError(w, http.StatusForbidden, "TIER_LIMIT_REACHED",
|
||||
fmt.Sprintf("Your %s plan allows %d alert type(s) per address. Upgrade for more.", user.SubscriptionTier, limits.MaxAlertTypes))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
newAlert, err := h.alertRules.Create(r.Context(), addressID, alertType, threshold, minimum, maximum)
|
||||
if err != nil {
|
||||
log.Printf("Error creating alert rule: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -98,12 +169,12 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func parseThreshold(raw json.RawMessage) (*float64, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, nil
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
|
||||
// Check null before number -- json.Unmarshal treats null as valid for float64 (sets to 0).
|
||||
if string(raw) == "null" {
|
||||
return nil, nil
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
|
||||
var asNumber float64
|
||||
@@ -115,23 +186,26 @@ func parseThreshold(raw json.RawMessage) (*float64, error) {
|
||||
if err := json.Unmarshal(raw, &asString); err == nil {
|
||||
asString = strings.TrimSpace(asString)
|
||||
if asString == "" {
|
||||
return nil, nil
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
parsed, parseErr := strconv.ParseFloat(asString, 64)
|
||||
if parseErr != nil {
|
||||
return nil, parseErr
|
||||
}
|
||||
|
||||
return &parsed, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unsupported threshold format")
|
||||
return nil, errThresholdFormat
|
||||
}
|
||||
|
||||
// ListByAddress handles GET requests to list alert rules for an address.
|
||||
func (h *AlertRuleHandler) ListByAddress(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
addressID, ok := parseIntParam(r.PathValue("addressId"))
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid address ID")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -141,11 +215,13 @@ func (h *AlertRuleHandler) ListByAddress(w http.ResponseWriter, r *http.Request)
|
||||
if err != nil {
|
||||
log.Printf("Error finding address: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list alerts")
|
||||
|
||||
return
|
||||
}
|
||||
if addr == nil {
|
||||
log.Printf("Address %d not found or not owned by user", addressID)
|
||||
writeError(w, http.StatusNotFound, "NOT_FOUND", "Address not found")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -153,6 +229,7 @@ func (h *AlertRuleHandler) ListByAddress(w http.ResponseWriter, r *http.Request)
|
||||
if err != nil {
|
||||
log.Printf("Error listing alerts: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list alerts")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -164,27 +241,34 @@ func (h *AlertRuleHandler) ListByAddress(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusOK, alerts)
|
||||
}
|
||||
|
||||
// UpdateStatus handles PATCH requests to enable/disable an alert rule and/or update min/max thresholds.
|
||||
func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
alertID, ok := parseIntParam(r.PathValue("alertId"))
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid alert ID")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Minimum json.RawMessage `json:"minimum"`
|
||||
Maximum json.RawMessage `json:"maximum"`
|
||||
UpdateMinMax bool `json:"update_min_max"` //nolint:tagliatelle
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
log.Printf("Failed to decode update request body: %v", err)
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("User %s updating alert ID: %d", userID, alertID)
|
||||
|
||||
if body.Enabled == nil {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "enabled must be a boolean value")
|
||||
if body.Enabled == nil && !body.UpdateMinMax {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "must provide enabled (boolean) or update_min_max with minimum/maximum values")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -192,30 +276,84 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
|
||||
if err != nil {
|
||||
log.Printf("Error finding alert: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert")
|
||||
|
||||
return
|
||||
}
|
||||
if alert == nil {
|
||||
log.Printf("Alert %d not found or not owned by user", alertID)
|
||||
writeError(w, http.StatusNotFound, "NOT_FOUND", "Alert rule not found")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := h.alertRules.UpdateEnabled(r.Context(), alertID, *body.Enabled)
|
||||
if err != nil {
|
||||
log.Printf("Error updating alert: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert")
|
||||
return
|
||||
var updated *domain.AlertRule
|
||||
|
||||
if body.UpdateMinMax {
|
||||
minimum, parseErr := parseThreshold(body.Minimum)
|
||||
if parseErr != nil {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be a valid number")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
maximum, parseErr := parseThreshold(body.Maximum)
|
||||
if parseErr != nil {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be a valid number")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if minimum != nil && *minimum < 0 {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be non-negative")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if maximum != nil && *maximum < 0 {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be non-negative")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if minimum != nil && maximum != nil && *minimum > *maximum {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must not exceed maximum")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
updated, err = h.alertRules.UpdateThresholds(r.Context(), alertID, minimum, maximum)
|
||||
if err != nil {
|
||||
log.Printf("Error updating alert thresholds: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Alert %d thresholds updated: min=%v, max=%v", alertID, minimum, maximum)
|
||||
}
|
||||
|
||||
if body.Enabled != nil {
|
||||
updated, err = h.alertRules.UpdateEnabled(r.Context(), alertID, *body.Enabled)
|
||||
if err != nil {
|
||||
log.Printf("Error updating alert: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Alert %d updated: enabled=%v", alertID, *body.Enabled)
|
||||
}
|
||||
|
||||
log.Printf("Alert %d updated: enabled=%v", alertID, *body.Enabled)
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
// Remove handles DELETE requests to remove an alert rule.
|
||||
func (h *AlertRuleHandler) Remove(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
alertID, ok := parseIntParam(r.PathValue("alertId"))
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid alert ID")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -225,17 +363,20 @@ func (h *AlertRuleHandler) Remove(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
log.Printf("Error finding alert: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete alert")
|
||||
|
||||
return
|
||||
}
|
||||
if alert == nil {
|
||||
log.Printf("Alert %d not found or not owned by user", alertID)
|
||||
writeError(w, http.StatusNotFound, "NOT_FOUND", "Alert rule not found")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.alertRules.Remove(r.Context(), alertID); err != nil {
|
||||
log.Printf("Error deleting alert: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete alert")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//nolint:testpackage // parseThreshold is unexported; internal test package required
|
||||
package handlers
|
||||
|
||||
import (
|
||||
@@ -6,8 +7,13 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
//nolint:gocognit
|
||||
func TestParseThreshold(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("nil/empty raw message returns nil", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
val, err := parseThreshold(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -18,6 +24,8 @@ func TestParseThreshold(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("empty slice returns nil", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
val, err := parseThreshold(json.RawMessage{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -28,6 +36,8 @@ func TestParseThreshold(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("JSON null returns nil", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
val, err := parseThreshold(json.RawMessage("null"))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -38,6 +48,8 @@ func TestParseThreshold(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("number 10 returns 10.0", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
val, err := parseThreshold(json.RawMessage("10"))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -51,6 +63,8 @@ func TestParseThreshold(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("number 0.5 returns 0.5", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
val, err := parseThreshold(json.RawMessage("0.5"))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -61,6 +75,8 @@ func TestParseThreshold(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("string '10' returns 10.0", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
val, err := parseThreshold(json.RawMessage(`"10"`))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -71,6 +87,8 @@ func TestParseThreshold(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("string '0.001' returns 0.001", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
val, err := parseThreshold(json.RawMessage(`"0.001"`))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -81,6 +99,8 @@ func TestParseThreshold(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("string with spaces ' 10 ' returns 10.0", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
val, err := parseThreshold(json.RawMessage(`" 10 "`))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -91,6 +111,8 @@ func TestParseThreshold(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("empty string returns nil", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
val, err := parseThreshold(json.RawMessage(`""`))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -101,6 +123,8 @@ func TestParseThreshold(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("whitespace-only string returns nil", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
val, err := parseThreshold(json.RawMessage(`" "`))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -111,6 +135,8 @@ func TestParseThreshold(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("invalid string returns error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseThreshold(json.RawMessage(`"abc"`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-numeric string")
|
||||
@@ -118,6 +144,8 @@ func TestParseThreshold(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("boolean returns error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseThreshold(json.RawMessage("true"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for boolean")
|
||||
@@ -125,6 +153,8 @@ func TestParseThreshold(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("array returns error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseThreshold(json.RawMessage("[1,2]"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for array")
|
||||
@@ -132,7 +162,10 @@ func TestParseThreshold(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
//nolint:funlen
|
||||
func TestDecodeAlertBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Verifies that the struct used in Create handler can decode all
|
||||
// payload shapes the frontend might send.
|
||||
|
||||
@@ -183,12 +216,15 @@ func TestDecodeAlertBody(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var body alertBody
|
||||
err := json.Unmarshal([]byte(tt.payload), &body)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected decode error")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
@@ -199,6 +235,8 @@ func TestDecodeAlertBody(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestOldStructFailsWithStringThreshold(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Documents the original bug: *float64 cannot decode a string threshold.
|
||||
type oldAlertBody struct {
|
||||
Type string `json:"type"`
|
||||
101
backend/internal/handlers/email_digest.go
Normal file
101
backend/internal/handlers/email_digest.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend/internal/services"
|
||||
)
|
||||
|
||||
type EmailDigestHandler struct {
|
||||
digestSvc *services.EmailDigestService
|
||||
configs *models.NotificationConfigModel
|
||||
}
|
||||
|
||||
func NewEmailDigestHandler(
|
||||
digestSvc *services.EmailDigestService,
|
||||
configs *models.NotificationConfigModel,
|
||||
) *EmailDigestHandler {
|
||||
return &EmailDigestHandler{digestSvc: digestSvc, configs: configs}
|
||||
}
|
||||
|
||||
// SetupEmail reads the user's email from their notification config and sends
|
||||
// a confirmation message via Resend to verify the integration works.
|
||||
func (h *EmailDigestHandler) SetupEmail(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
|
||||
if !h.digestSvc.Configured() {
|
||||
writeError(w, http.StatusServiceUnavailable, "EMAIL_NOT_CONFIGURED",
|
||||
"Email service is not configured on the server")
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := h.configs.GetConfig(r.Context(), userID)
|
||||
if err != nil {
|
||||
log.Printf("Error getting notification config for email setup: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR",
|
||||
"Failed to load notification config")
|
||||
return
|
||||
}
|
||||
|
||||
if cfg == nil || cfg.Email == nil || *cfg.Email == "" {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
|
||||
"Save an email address in notification settings first")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.digestSvc.SetupEmail(*cfg.Email); err != nil {
|
||||
log.Printf("Email setup failed for user %s: %v", userID, err)
|
||||
writeError(w, http.StatusBadGateway, "EMAIL_SEND_FAILED",
|
||||
"Failed to send confirmation email — check server email config")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Email setup confirmation sent to user %s (%s)", userID, *cfg.Email)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"success": true,
|
||||
"email": *cfg.Email,
|
||||
"message": "Confirmation email sent",
|
||||
})
|
||||
}
|
||||
|
||||
// SendDigest compiles and sends a digest of recent alerts to the user's email.
|
||||
func (h *EmailDigestHandler) SendDigest(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
|
||||
if !h.digestSvc.Configured() {
|
||||
writeError(w, http.StatusServiceUnavailable, "EMAIL_NOT_CONFIGURED",
|
||||
"Email service is not configured on the server")
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := h.configs.GetConfig(r.Context(), userID)
|
||||
if err != nil {
|
||||
log.Printf("Error getting notification config for digest: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR",
|
||||
"Failed to load notification config")
|
||||
return
|
||||
}
|
||||
|
||||
if cfg == nil || cfg.Email == nil || *cfg.Email == "" {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
|
||||
"No email address configured")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.digestSvc.SendDigest(r.Context(), userID, *cfg.Email); err != nil {
|
||||
log.Printf("Digest send failed for user %s: %v", userID, err)
|
||||
writeError(w, http.StatusBadGateway, "DIGEST_SEND_FAILED",
|
||||
"Failed to send digest email")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Digest sent to user %s (%s)", userID, *cfg.Email)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"success": true,
|
||||
"email": *cfg.Email,
|
||||
"message": "Digest email sent",
|
||||
})
|
||||
}
|
||||
@@ -7,20 +7,23 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/notifications"
|
||||
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend/internal/notifications"
|
||||
)
|
||||
|
||||
var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
|
||||
|
||||
type NotificationConfigHandler struct {
|
||||
configs *models.NotificationConfigModel
|
||||
users *models.UserModel
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewNotificationConfigHandler(configs *models.NotificationConfigModel) *NotificationConfigHandler {
|
||||
return &NotificationConfigHandler{configs: configs}
|
||||
func NewNotificationConfigHandler(configs *models.NotificationConfigModel, users *models.UserModel, cfg *config.Config) *NotificationConfigHandler {
|
||||
return &NotificationConfigHandler{configs: configs, users: users, cfg: cfg}
|
||||
}
|
||||
|
||||
func (h *NotificationConfigHandler) GetConfig(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -51,8 +54,10 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
|
||||
|
||||
var body struct {
|
||||
DiscordWebhookURL *string `json:"discord_webhook_url"`
|
||||
SlackWebhookURL *string `json:"slack_webhook_url"`
|
||||
TelegramChatID *string `json:"telegram_chat_id"`
|
||||
TelegramBotToken *string `json:"telegram_bot_token"`
|
||||
Email *string `json:"email"`
|
||||
SlackWebhookURL *string `json:"slack_webhook_url"`
|
||||
NotificationEnabled *bool `json:"notification_enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
@@ -63,8 +68,9 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
|
||||
|
||||
log.Printf("User %s updating notification config", userID)
|
||||
|
||||
if body.DiscordWebhookURL == nil && body.SlackWebhookURL == nil &&
|
||||
body.Email == nil && body.NotificationEnabled == nil {
|
||||
if body.DiscordWebhookURL == nil && body.TelegramChatID == nil &&
|
||||
body.TelegramBotToken == nil && body.Email == nil &&
|
||||
body.SlackWebhookURL == nil && body.NotificationEnabled == nil {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
|
||||
"At least one configuration field must be provided")
|
||||
return
|
||||
@@ -90,6 +96,26 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
|
||||
return
|
||||
}
|
||||
|
||||
user, userErr := h.users.GetByID(r.Context(), userID)
|
||||
if userErr != nil || user == nil {
|
||||
log.Printf("Failed to get user %s for tier check: %v", userID, userErr)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to verify account")
|
||||
return
|
||||
}
|
||||
|
||||
limits := domain.GetTierLimits(user.SubscriptionTier)
|
||||
|
||||
if !limits.ChannelAllowed("discord") {
|
||||
body.DiscordWebhookURL = nil
|
||||
}
|
||||
if !limits.ChannelAllowed("telegram") {
|
||||
body.TelegramBotToken = nil
|
||||
body.TelegramChatID = nil
|
||||
}
|
||||
if !limits.ChannelAllowed("slack") {
|
||||
body.SlackWebhookURL = nil
|
||||
}
|
||||
|
||||
enabled := true
|
||||
if body.NotificationEnabled != nil {
|
||||
enabled = *body.NotificationEnabled
|
||||
@@ -97,8 +123,10 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
|
||||
|
||||
cfg := domain.NotificationConfig{
|
||||
DiscordWebhookURL: body.DiscordWebhookURL,
|
||||
SlackWebhookURL: body.SlackWebhookURL,
|
||||
TelegramChatID: body.TelegramChatID,
|
||||
TelegramBotToken: body.TelegramBotToken,
|
||||
Email: body.Email,
|
||||
SlackWebhookURL: body.SlackWebhookURL,
|
||||
NotificationEnabled: enabled,
|
||||
}
|
||||
|
||||
@@ -135,49 +163,75 @@ func (h *NotificationConfigHandler) DeleteConfig(w http.ResponseWriter, r *http.
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *NotificationConfigHandler) TestWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
_ = middleware.GetUserID(r.Context())
|
||||
|
||||
var body struct {
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if body.URL == "" {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "URL is required")
|
||||
return
|
||||
}
|
||||
|
||||
var ok bool
|
||||
var err error
|
||||
|
||||
switch body.Type {
|
||||
case "slack":
|
||||
if !strings.HasPrefix(body.URL, "https://hooks.slack.com/") {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid Slack webhook URL")
|
||||
return
|
||||
}
|
||||
ok, err = notifications.TestSlackWebhook(body.URL)
|
||||
case "discord":
|
||||
if !strings.HasPrefix(body.URL, "https://discord.com/api/webhooks/") {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid Discord webhook URL")
|
||||
return
|
||||
}
|
||||
ok, err = notifications.TestDiscordWebhook(body.URL)
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Type must be 'slack' or 'discord'")
|
||||
return
|
||||
}
|
||||
// TestChannels sends a test message to all configured notification channels.
|
||||
func (h *NotificationConfigHandler) TestChannels(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
log.Printf("User %s testing notification channels", userID)
|
||||
|
||||
cfg, err := h.configs.GetConfig(r.Context(), userID)
|
||||
if err != nil {
|
||||
log.Printf("Webhook test failed: %v", err)
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"success": false, "error": err.Error()})
|
||||
log.Printf("Error getting notification config for test: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to get notification config")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"success": ok})
|
||||
if cfg == nil {
|
||||
writeError(w, http.StatusNotFound, "NOT_FOUND", "No notification configuration found")
|
||||
return
|
||||
}
|
||||
|
||||
type channelResult struct {
|
||||
Channel string `json:"channel"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
var results []channelResult
|
||||
|
||||
if cfg.DiscordWebhookURL != nil && *cfg.DiscordWebhookURL != "" {
|
||||
ok, testErr := notifications.TestDiscordWebhook(*cfg.DiscordWebhookURL)
|
||||
res := channelResult{Channel: "discord", Success: ok}
|
||||
if testErr != nil {
|
||||
res.Error = testErr.Error()
|
||||
}
|
||||
results = append(results, res)
|
||||
}
|
||||
|
||||
if cfg.TelegramBotToken != nil && *cfg.TelegramBotToken != "" &&
|
||||
cfg.TelegramChatID != nil && *cfg.TelegramChatID != "" {
|
||||
ok, testErr := notifications.TestTelegramWebhook(*cfg.TelegramBotToken, *cfg.TelegramChatID)
|
||||
res := channelResult{Channel: "telegram", Success: ok}
|
||||
if testErr != nil {
|
||||
res.Error = testErr.Error()
|
||||
}
|
||||
results = append(results, res)
|
||||
}
|
||||
|
||||
if cfg.SlackWebhookURL != nil && *cfg.SlackWebhookURL != "" {
|
||||
ok, testErr := notifications.TestSlackWebhook(*cfg.SlackWebhookURL)
|
||||
res := channelResult{Channel: "slack", Success: ok}
|
||||
if testErr != nil {
|
||||
res.Error = testErr.Error()
|
||||
}
|
||||
results = append(results, res)
|
||||
}
|
||||
|
||||
if cfg.Email != nil && *cfg.Email != "" {
|
||||
ok, testErr := notifications.TestEmailNotification(
|
||||
h.cfg.ResendAPIKey, h.cfg.EmailFrom, *cfg.Email,
|
||||
)
|
||||
res := channelResult{Channel: "email", Success: ok}
|
||||
if testErr != nil {
|
||||
res.Error = testErr.Error()
|
||||
}
|
||||
results = append(results, res)
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "NO_CHANNELS",
|
||||
"No notification channels are configured")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"results": results})
|
||||
}
|
||||
71
backend/internal/handlers/status.go
Normal file
71
backend/internal/handlers/status.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
)
|
||||
|
||||
// StatusHandler handles the system status endpoint.
|
||||
type StatusHandler struct {
|
||||
checkpoints *models.CheckpointModel
|
||||
}
|
||||
|
||||
// NewStatusHandler creates a new StatusHandler.
|
||||
func NewStatusHandler(checkpoints *models.CheckpointModel) *StatusHandler {
|
||||
return &StatusHandler{checkpoints: checkpoints}
|
||||
}
|
||||
|
||||
// GetStatus returns real-time system status derived from checkpoint data.
|
||||
func (h *StatusHandler) GetStatus(w http.ResponseWriter, r *http.Request) {
|
||||
block, checkedAt, err := h.checkpoints.GetLatestBlock(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("Error querying latest block: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to get system status")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
latestBlock := 0
|
||||
lag := 0
|
||||
status := "starting"
|
||||
|
||||
if checkedAt != nil {
|
||||
lag = int(time.Since(*checkedAt).Seconds())
|
||||
if lag > 600 { //nolint:mnd
|
||||
status = "idle"
|
||||
} else {
|
||||
status = "active"
|
||||
}
|
||||
}
|
||||
|
||||
if block != nil {
|
||||
latestBlock = *block
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"status": status,
|
||||
"latestBlock": latestBlock,
|
||||
"lag": lag,
|
||||
"lastCheckedAt": checkedAtStr(checkedAt),
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func checkedAtStr(t *time.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func HealthCheck(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"status": "ok",
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
"service": "koin-ping-backend",
|
||||
})
|
||||
}
|
||||
429
backend/internal/handlers/stripe.go
Normal file
429
backend/internal/handlers/stripe.go
Normal file
@@ -0,0 +1,429 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/stripe/stripe-go/v82"
|
||||
portalsession "github.com/stripe/stripe-go/v82/billingportal/session"
|
||||
checkoutsession "github.com/stripe/stripe-go/v82/checkout/session"
|
||||
"github.com/stripe/stripe-go/v82/webhook"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
)
|
||||
|
||||
const webhookMaxBodyBytes = 65536
|
||||
|
||||
type StripeHandler struct {
|
||||
users *models.UserModel
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewStripeHandler(users *models.UserModel, cfg *config.Config) *StripeHandler {
|
||||
stripe.Key = cfg.StripeSecretKey
|
||||
return &StripeHandler{users: users, cfg: cfg}
|
||||
}
|
||||
|
||||
func (h *StripeHandler) priceIDForTier(tier domain.SubscriptionTier) (string, error) {
|
||||
switch tier {
|
||||
case domain.TierPremium:
|
||||
return h.cfg.StripePriceIDPremium, nil
|
||||
case domain.TierPro:
|
||||
return h.cfg.StripePriceIDPro, nil
|
||||
default:
|
||||
return "", fmt.Errorf("no Stripe price for tier %q", tier) //nolint:err113
|
||||
}
|
||||
}
|
||||
|
||||
// CreateCheckoutSession creates a Stripe Checkout session for the selected tier.
|
||||
func (h *StripeHandler) CreateCheckoutSession(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
|
||||
var body struct {
|
||||
Tier string `json:"tier"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if body.Tier == "" {
|
||||
body.Tier = "premium"
|
||||
}
|
||||
|
||||
tier := domain.SubscriptionTier(body.Tier)
|
||||
if tier != domain.TierPremium && tier != domain.TierPro {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Tier must be 'premium' or 'pro'")
|
||||
return
|
||||
}
|
||||
|
||||
priceID, err := h.priceIDForTier(tier)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.users.GetByID(r.Context(), userID)
|
||||
if err != nil || user == nil {
|
||||
log.Printf("Failed to get user %s: %v", userID, err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to load user")
|
||||
return
|
||||
}
|
||||
|
||||
params := &stripe.CheckoutSessionParams{
|
||||
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
||||
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
||||
{
|
||||
Price: stripe.String(priceID),
|
||||
Quantity: stripe.Int64(1),
|
||||
},
|
||||
},
|
||||
SuccessURL: stripe.String(h.cfg.FrontendURL + "/subscribe?payment=success&session_id={CHECKOUT_SESSION_ID}"),
|
||||
CancelURL: stripe.String(h.cfg.FrontendURL + "/subscribe?payment=cancelled"),
|
||||
ClientReferenceID: stripe.String(userID),
|
||||
CustomerEmail: stripe.String(user.Email),
|
||||
}
|
||||
|
||||
params.AddMetadata("tier", string(tier))
|
||||
|
||||
if user.StripeCustomerID != nil && *user.StripeCustomerID != "" {
|
||||
params.Customer = user.StripeCustomerID
|
||||
params.CustomerEmail = nil
|
||||
}
|
||||
|
||||
s, err := checkoutsession.New(params)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create Stripe checkout session: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "STRIPE_ERROR", "Failed to create checkout session")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"url": s.URL})
|
||||
}
|
||||
|
||||
// GetSubscriptionStatus returns the current user's subscription state.
|
||||
func (h *StripeHandler) GetSubscriptionStatus(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
|
||||
user, err := h.users.GetByID(r.Context(), userID)
|
||||
if err != nil || user == nil {
|
||||
log.Printf("Failed to get user %s: %v", userID, err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to load user")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"subscription_status": user.SubscriptionStatus,
|
||||
"subscription_tier": user.SubscriptionTier,
|
||||
"subscription_created_at": user.SubscriptionCreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// VerifyCheckoutSession retrieves a completed checkout session from Stripe,
|
||||
// confirms payment, and activates the user's subscription in the database.
|
||||
func (h *StripeHandler) VerifyCheckoutSession(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
|
||||
var body struct {
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.SessionID == "" {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Missing session_id")
|
||||
return
|
||||
}
|
||||
|
||||
s, err := checkoutsession.Get(body.SessionID, nil)
|
||||
if err != nil {
|
||||
log.Printf("Failed to retrieve checkout session %s: %v", body.SessionID, err)
|
||||
writeError(w, http.StatusBadRequest, "STRIPE_ERROR", "Invalid checkout session")
|
||||
return
|
||||
}
|
||||
|
||||
if s.ClientReferenceID != "" && s.ClientReferenceID != userID {
|
||||
writeError(w, http.StatusForbidden, "FORBIDDEN", "Session does not belong to this user")
|
||||
return
|
||||
}
|
||||
|
||||
if s.PaymentStatus != stripe.CheckoutSessionPaymentStatusPaid {
|
||||
writeError(w, http.StatusBadRequest, "PAYMENT_INCOMPLETE", "Payment has not been completed")
|
||||
return
|
||||
}
|
||||
|
||||
tier := domain.TierPremium
|
||||
if t, ok := s.Metadata["tier"]; ok && domain.IsValidTier(t) {
|
||||
tier = domain.SubscriptionTier(t)
|
||||
}
|
||||
|
||||
customerID := ""
|
||||
if s.Customer != nil {
|
||||
customerID = s.Customer.ID
|
||||
}
|
||||
subscriptionID := ""
|
||||
if s.Subscription != nil {
|
||||
subscriptionID = s.Subscription.ID
|
||||
}
|
||||
|
||||
if customerID != "" {
|
||||
if err := h.users.UpdateStripeCustomer(r.Context(), userID, customerID); err != nil {
|
||||
log.Printf("VerifyCheckout: failed to save customer ID: %v", err)
|
||||
}
|
||||
}
|
||||
if subscriptionID != "" && customerID != "" {
|
||||
if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active", tier); err != nil {
|
||||
log.Printf("VerifyCheckout: failed to activate subscription: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Checkout verified for user %s, customer %s, subscription %s, tier %s", userID, customerID, subscriptionID, tier)
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"subscription_status": "active",
|
||||
"subscription_tier": string(tier),
|
||||
})
|
||||
}
|
||||
|
||||
// ActivateFreeTier sets the user to the free tier without Stripe involvement.
|
||||
func (h *StripeHandler) ActivateFreeTier(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
|
||||
if err := h.users.ActivateFreeTier(r.Context(), userID); err != nil {
|
||||
log.Printf("ActivateFreeTier: failed for user %s: %v", userID, err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to activate free tier")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Free tier activated for user %s", userID)
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"subscription_status": "active",
|
||||
"subscription_tier": "free",
|
||||
})
|
||||
}
|
||||
|
||||
// CreateOnboardingCheckout creates a Stripe Checkout session for a user who
|
||||
// has not yet created an account. This is a public endpoint (no auth required).
|
||||
// The Firebase account is created on the frontend only after payment succeeds.
|
||||
func (h *StripeHandler) CreateOnboardingCheckout(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Tier string `json:"tier"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if body.Email == "" {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Email is required")
|
||||
return
|
||||
}
|
||||
|
||||
if body.Tier == "" {
|
||||
body.Tier = "premium"
|
||||
}
|
||||
|
||||
tier := domain.SubscriptionTier(body.Tier)
|
||||
if tier != domain.TierPremium && tier != domain.TierPro {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Tier must be 'premium' or 'pro'")
|
||||
return
|
||||
}
|
||||
|
||||
priceID, err := h.priceIDForTier(tier)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
params := &stripe.CheckoutSessionParams{
|
||||
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
||||
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
||||
{
|
||||
Price: stripe.String(priceID),
|
||||
Quantity: stripe.Int64(1),
|
||||
},
|
||||
},
|
||||
SuccessURL: stripe.String(h.cfg.FrontendURL + "/subscribe?payment=success&session_id={CHECKOUT_SESSION_ID}"),
|
||||
CancelURL: stripe.String(h.cfg.FrontendURL + "/subscribe?payment=cancelled"),
|
||||
CustomerEmail: stripe.String(body.Email),
|
||||
}
|
||||
params.AddMetadata("tier", string(tier))
|
||||
params.AddMetadata("onboarding", "true")
|
||||
|
||||
s, err := checkoutsession.New(params)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create onboarding checkout session: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "STRIPE_ERROR", "Failed to create checkout session")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"url": s.URL})
|
||||
}
|
||||
|
||||
// CreatePortalSession creates a Stripe Billing Portal session so the user can
|
||||
// manage their subscription (cancel, update payment method, view invoices).
|
||||
func (h *StripeHandler) CreatePortalSession(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
|
||||
user, err := h.users.GetByID(r.Context(), userID)
|
||||
if err != nil || user == nil {
|
||||
log.Printf("Portal: failed to get user %s: %v", userID, err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to load user")
|
||||
return
|
||||
}
|
||||
|
||||
if user.StripeCustomerID == nil || *user.StripeCustomerID == "" {
|
||||
writeError(w, http.StatusBadRequest, "NO_CUSTOMER", "No Stripe customer on file")
|
||||
return
|
||||
}
|
||||
|
||||
params := &stripe.BillingPortalSessionParams{
|
||||
Customer: user.StripeCustomerID,
|
||||
ReturnURL: stripe.String(h.cfg.FrontendURL + "/account"),
|
||||
}
|
||||
|
||||
s, err := portalsession.New(params)
|
||||
if err != nil {
|
||||
log.Printf("Portal: failed to create portal session: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "STRIPE_ERROR", "Failed to create portal session")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"url": s.URL})
|
||||
}
|
||||
|
||||
// HandleWebhook processes incoming Stripe webhook events.
|
||||
func (h *StripeHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
payload, err := io.ReadAll(io.LimitReader(r.Body, webhookMaxBodyBytes))
|
||||
if err != nil {
|
||||
log.Printf("Error reading webhook body: %v", err)
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
sig := r.Header.Get("Stripe-Signature")
|
||||
event, err := webhook.ConstructEvent(payload, sig, h.cfg.StripeWebhookSecret)
|
||||
if err != nil {
|
||||
log.Printf("Webhook signature verification failed: %v", err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
case "checkout.session.completed":
|
||||
h.handleCheckoutCompleted(r, event)
|
||||
case "customer.subscription.updated":
|
||||
h.handleSubscriptionUpdated(r, event)
|
||||
case "customer.subscription.deleted":
|
||||
h.handleSubscriptionDeleted(r, event)
|
||||
default:
|
||||
log.Printf("Unhandled Stripe event type: %s", event.Type)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *StripeHandler) handleCheckoutCompleted(r *http.Request, event stripe.Event) {
|
||||
var session stripe.CheckoutSession
|
||||
if err := json.Unmarshal(event.Data.Raw, &session); err != nil {
|
||||
log.Printf("Error parsing checkout session: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
userID := session.ClientReferenceID
|
||||
if userID == "" {
|
||||
log.Println("Checkout session missing client_reference_id")
|
||||
return
|
||||
}
|
||||
|
||||
tier := domain.TierPremium
|
||||
if t, ok := session.Metadata["tier"]; ok && domain.IsValidTier(t) {
|
||||
tier = domain.SubscriptionTier(t)
|
||||
}
|
||||
|
||||
customerID := ""
|
||||
if session.Customer != nil {
|
||||
customerID = session.Customer.ID
|
||||
}
|
||||
subscriptionID := ""
|
||||
if session.Subscription != nil {
|
||||
subscriptionID = session.Subscription.ID
|
||||
}
|
||||
|
||||
if customerID != "" {
|
||||
if err := h.users.UpdateStripeCustomer(r.Context(), userID, customerID); err != nil {
|
||||
log.Printf("Failed to save Stripe customer ID: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if subscriptionID != "" && customerID != "" {
|
||||
if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active", tier); err != nil {
|
||||
log.Printf("Failed to activate subscription: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Checkout completed for user %s, customer %s, subscription %s, tier %s", userID, customerID, subscriptionID, tier)
|
||||
}
|
||||
|
||||
func (h *StripeHandler) handleSubscriptionUpdated(r *http.Request, event stripe.Event) {
|
||||
var sub stripe.Subscription
|
||||
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
|
||||
log.Printf("Error parsing subscription update: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
customerID := ""
|
||||
if sub.Customer != nil {
|
||||
customerID = sub.Customer.ID
|
||||
}
|
||||
if customerID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
status := string(sub.Status)
|
||||
|
||||
// Detect tier from the subscription's current price so that
|
||||
// upgrades/downgrades via the Stripe portal are reflected.
|
||||
tier := domain.TierPremium
|
||||
if sub.Items != nil {
|
||||
for _, item := range sub.Items.Data {
|
||||
if item.Price != nil {
|
||||
if t := h.cfg.TierForPriceID(item.Price.ID); t != "" {
|
||||
tier = domain.SubscriptionTier(t)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.users.ActivateSubscription(r.Context(), customerID, sub.ID, status, tier); err != nil {
|
||||
log.Printf("Failed to update subscription: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("Subscription %s updated to %s (tier %s) for customer %s", sub.ID, status, tier, customerID)
|
||||
}
|
||||
|
||||
func (h *StripeHandler) handleSubscriptionDeleted(r *http.Request, event stripe.Event) {
|
||||
var sub stripe.Subscription
|
||||
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
|
||||
log.Printf("Error parsing subscription deletion: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
customerID := ""
|
||||
if sub.Customer != nil {
|
||||
customerID = sub.Customer.ID
|
||||
}
|
||||
if customerID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.users.UpdateSubscriptionStatus(r.Context(), customerID, "canceled"); err != nil {
|
||||
log.Printf("Failed to mark subscription canceled: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("Subscription canceled for customer %s", customerID)
|
||||
}
|
||||
161
backend/internal/middleware/auth.go
Normal file
161
backend/internal/middleware/auth.go
Normal file
@@ -0,0 +1,161 @@
|
||||
// Package middleware provides HTTP middleware for authentication and context injection.
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
fbauth "github.com/kjannette/koin-ping/backend/internal/firebase"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
UserIDKey contextKey = "user_id"
|
||||
UserEmailKey contextKey = "user_email"
|
||||
UserTierKey contextKey = "user_tier"
|
||||
)
|
||||
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v) //nolint:errcheck
|
||||
}
|
||||
|
||||
// Authenticate verifies the Firebase ID token and auto-provisions a local user
|
||||
// record. The local user UUID (not the Firebase UID) is placed into context so
|
||||
// all downstream handlers use it as the canonical user identifier.
|
||||
func Authenticate(userModel *models.UserModel) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
log.Println("No Authorization header or invalid format")
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "UNAUTHORIZED",
|
||||
Message: "No authentication token provided",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if token == "" {
|
||||
log.Println("Empty token")
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "UNAUTHORIZED",
|
||||
Message: "Invalid token format",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("Verifying Firebase token...")
|
||||
decoded, err := fbauth.Auth().VerifyIDToken(r.Context(), token)
|
||||
if err != nil {
|
||||
log.Printf("Token verification failed: %v", err)
|
||||
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "expired") {
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "TOKEN_EXPIRED",
|
||||
Message: "Authentication token has expired",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "UNAUTHORIZED",
|
||||
Message: "Failed to verify authentication token",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
firebaseUID := decoded.UID
|
||||
email, _ := decoded.Claims["email"].(string)
|
||||
|
||||
user, err := userModel.FindOrCreateByFirebaseUID(r.Context(), firebaseUID, email)
|
||||
if err != nil {
|
||||
log.Printf("Failed to provision local user for Firebase UID %s: %v", firebaseUID, err)
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||||
Error: "INTERNAL_ERROR",
|
||||
Message: "Failed to initialize user account",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Token verified! User UUID: %s, Email: %s", user.ID, email)
|
||||
|
||||
ctx := context.WithValue(r.Context(), UserIDKey, user.ID)
|
||||
ctx = context.WithValue(ctx, UserEmailKey, email)
|
||||
ctx = context.WithValue(ctx, UserTierKey, string(user.SubscriptionTier))
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RequireSubscription blocks requests from users without an active subscription.
|
||||
// Must be applied after Authenticate.
|
||||
func RequireSubscription(userModel *models.UserModel) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
userID := GetUserID(r.Context())
|
||||
if userID == "" {
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "UNAUTHORIZED",
|
||||
Message: "Authentication required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := userModel.GetByID(r.Context(), userID)
|
||||
if err != nil || user == nil {
|
||||
log.Printf("RequireSubscription: failed to load user %s: %v", userID, err)
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||||
Error: "INTERNAL_ERROR",
|
||||
Message: "Failed to verify subscription",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if user.SubscriptionStatus != "active" && user.SubscriptionStatus != "trialing" {
|
||||
writeJSON(w, http.StatusForbidden, errorResponse{
|
||||
Error: "SUBSCRIPTION_REQUIRED",
|
||||
Message: "An active subscription is required to use this feature",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func GetUserID(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(UserIDKey).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func GetUserEmail(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(UserEmailKey).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func GetUserTier(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(UserTierKey).(string); ok {
|
||||
return v
|
||||
}
|
||||
return "free"
|
||||
}
|
||||
60
backend/internal/middleware/auth_test.go
Normal file
60
backend/internal/middleware/auth_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetUserID_Empty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
if id := GetUserID(ctx); id != "" {
|
||||
t.Errorf("expected empty user ID, got %q", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserID_Set(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.WithValue(context.Background(), UserIDKey, "abc-123")
|
||||
if id := GetUserID(ctx); id != "abc-123" {
|
||||
t.Errorf("expected abc-123, got %q", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserEmail_Empty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
if email := GetUserEmail(ctx); email != "" {
|
||||
t.Errorf("expected empty email, got %q", email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserEmail_Set(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.WithValue(context.Background(), UserEmailKey, "test@example.com")
|
||||
if email := GetUserEmail(ctx); email != "test@example.com" {
|
||||
t.Errorf("expected test@example.com, got %q", email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserTier_Default(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
if tier := GetUserTier(ctx); tier != "free" {
|
||||
t.Errorf("expected default tier 'free', got %q", tier)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserTier_Set(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.WithValue(context.Background(), UserTierKey, "pro")
|
||||
if tier := GetUserTier(ctx); tier != "pro" {
|
||||
t.Errorf("expected 'pro', got %q", tier)
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@ package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
)
|
||||
|
||||
type AddressModel struct {
|
||||
@@ -53,12 +55,14 @@ func (m *AddressModel) ListByUser(ctx context.Context, userID string) ([]domain.
|
||||
return addresses, rows.Err()
|
||||
}
|
||||
|
||||
// ListAll returns all addresses system-wide (used by the poller).
|
||||
// ListAll returns addresses for users with an active subscription (used by the poller).
|
||||
func (m *AddressModel) ListAll(ctx context.Context) ([]domain.Address, error) {
|
||||
rows, err := m.pool.Query(ctx,
|
||||
`SELECT id, user_id, address, label, created_at
|
||||
FROM addresses
|
||||
ORDER BY created_at DESC`,
|
||||
`SELECT a.id, a.user_id, a.address, a.label, a.created_at
|
||||
FROM addresses a
|
||||
JOIN users u ON u.id = a.user_id
|
||||
WHERE u.subscription_status IN ('active', 'trialing')
|
||||
ORDER BY a.created_at DESC`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -97,7 +101,7 @@ func (m *AddressModel) FindByID(ctx context.Context, id int, userID *string) (*d
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if err.Error() == "no rows in result set" {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
@@ -105,6 +109,33 @@ func (m *AddressModel) FindByID(ctx context.Context, id int, userID *string) (*d
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// UpdateLabel updates the label for an address owned by userID.
|
||||
// Returns nil, nil if no row matched (address not found or not owned by user).
|
||||
func (m *AddressModel) UpdateLabel(ctx context.Context, id int, userID string, label *string) (*domain.Address, error) {
|
||||
var a domain.Address
|
||||
err := m.pool.QueryRow(ctx,
|
||||
`UPDATE addresses SET label = $3 WHERE id = $1 AND user_id = $2
|
||||
RETURNING id, user_id, address, label, created_at`,
|
||||
id, userID, label,
|
||||
).Scan(&a.ID, &a.UserID, &a.Address, &a.Label, &a.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (m *AddressModel) CountByUser(ctx context.Context, userID string) (int, error) {
|
||||
var count int
|
||||
err := m.pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM addresses WHERE user_id = $1`,
|
||||
userID,
|
||||
).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (m *AddressModel) Remove(ctx context.Context, id int, userID string) (bool, error) {
|
||||
tag, err := m.pool.Exec(ctx,
|
||||
`DELETE FROM addresses WHERE id = $1 AND user_id = $2`,
|
||||
@@ -2,9 +2,11 @@ package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
)
|
||||
|
||||
type AlertEventModel struct {
|
||||
@@ -71,10 +73,15 @@ func (m *AlertEventModel) Create(ctx context.Context, alertRuleID int, message s
|
||||
err := m.pool.QueryRow(ctx,
|
||||
`INSERT INTO alert_events (alert_rule_id, message, address_label, tx_hash)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING id, alert_rule_id, message, address_label, tx_hash, timestamp`,
|
||||
alertRuleID, message, addressLabel, txHash,
|
||||
).Scan(&e.ID, &e.AlertRuleID, &e.Message, &e.AddressLabel, &e.TxHash, &e.Timestamp)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// Duplicate silently skipped by ON CONFLICT DO NOTHING
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &e, nil
|
||||
142
backend/internal/models/alert_rule.go
Normal file
142
backend/internal/models/alert_rule.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
)
|
||||
|
||||
type AlertRuleModel struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewAlertRuleModel(pool *pgxpool.Pool) *AlertRuleModel {
|
||||
return &AlertRuleModel{pool: pool}
|
||||
}
|
||||
|
||||
func (m *AlertRuleModel) Create(ctx context.Context, addressID int, alertType domain.AlertType, threshold, minimum, maximum *float64) (*domain.AlertRule, error) {
|
||||
var r domain.AlertRule
|
||||
err := m.pool.QueryRow(ctx,
|
||||
`INSERT INTO alert_rules (address_id, type, threshold, minimum, maximum, enabled)
|
||||
VALUES ($1, $2, $3, $4, $5, TRUE)
|
||||
RETURNING id, address_id, type, threshold, minimum, maximum, enabled, created_at`,
|
||||
addressID, alertType.String(), threshold, minimum, maximum,
|
||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (m *AlertRuleModel) ListByAddress(ctx context.Context, addressID int) ([]domain.AlertRule, error) {
|
||||
rows, err := m.pool.Query(ctx,
|
||||
`SELECT id, address_id, type, threshold, minimum, maximum, enabled, created_at
|
||||
FROM alert_rules
|
||||
WHERE address_id = $1
|
||||
ORDER BY created_at DESC`,
|
||||
addressID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var rules []domain.AlertRule
|
||||
for rows.Next() {
|
||||
var r domain.AlertRule
|
||||
if err := rows.Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules = append(rules, r)
|
||||
}
|
||||
return rules, rows.Err()
|
||||
}
|
||||
|
||||
func (m *AlertRuleModel) FindByID(ctx context.Context, id int, userID *string) (*domain.AlertRule, error) {
|
||||
var r domain.AlertRule
|
||||
var err error
|
||||
|
||||
if userID != nil {
|
||||
err = m.pool.QueryRow(ctx,
|
||||
`SELECT ar.id, ar.address_id, ar.type, ar.threshold, ar.minimum, ar.maximum, ar.enabled, ar.created_at
|
||||
FROM alert_rules ar
|
||||
JOIN addresses a ON a.id = ar.address_id
|
||||
WHERE ar.id = $1 AND a.user_id = $2`,
|
||||
id, *userID,
|
||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
|
||||
} else {
|
||||
err = m.pool.QueryRow(ctx,
|
||||
`SELECT id, address_id, type, threshold, minimum, maximum, enabled, created_at
|
||||
FROM alert_rules
|
||||
WHERE id = $1`,
|
||||
id,
|
||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (m *AlertRuleModel) UpdateEnabled(ctx context.Context, id int, enabled bool) (*domain.AlertRule, error) {
|
||||
var r domain.AlertRule
|
||||
err := m.pool.QueryRow(ctx,
|
||||
`UPDATE alert_rules
|
||||
SET enabled = $2
|
||||
WHERE id = $1
|
||||
RETURNING id, address_id, type, threshold, minimum, maximum, enabled, created_at`,
|
||||
id, enabled,
|
||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (m *AlertRuleModel) UpdateThresholds(ctx context.Context, id int, minimum, maximum *float64) (*domain.AlertRule, error) {
|
||||
var r domain.AlertRule
|
||||
err := m.pool.QueryRow(ctx,
|
||||
`UPDATE alert_rules
|
||||
SET minimum = $2, maximum = $3
|
||||
WHERE id = $1
|
||||
RETURNING id, address_id, type, threshold, minimum, maximum, enabled, created_at`,
|
||||
id, minimum, maximum,
|
||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (m *AlertRuleModel) CountDistinctTypesByAddress(ctx context.Context, addressID int) (int, error) {
|
||||
var count int
|
||||
err := m.pool.QueryRow(ctx,
|
||||
`SELECT COUNT(DISTINCT type) FROM alert_rules WHERE address_id = $1`,
|
||||
addressID,
|
||||
).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (m *AlertRuleModel) Remove(ctx context.Context, id int) (bool, error) {
|
||||
tag, err := m.pool.Exec(ctx,
|
||||
`DELETE FROM alert_rules WHERE id = $1`,
|
||||
id,
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return tag.RowsAffected() > 0, nil
|
||||
}
|
||||
@@ -2,9 +2,12 @@ package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
)
|
||||
|
||||
type CheckpointModel struct {
|
||||
@@ -15,6 +18,20 @@ func NewCheckpointModel(pool *pgxpool.Pool) *CheckpointModel {
|
||||
return &CheckpointModel{pool: pool}
|
||||
}
|
||||
|
||||
// GetLatestBlock returns the highest last_checked_block and its timestamp across all addresses.
|
||||
// Returns nil, nil, nil when no checkpoints exist yet.
|
||||
func (m *CheckpointModel) GetLatestBlock(ctx context.Context) (*int, *time.Time, error) {
|
||||
var block *int
|
||||
var checkedAt *time.Time
|
||||
err := m.pool.QueryRow(ctx,
|
||||
`SELECT MAX(last_checked_block), MAX(last_checked_at) FROM address_checkpoints`,
|
||||
).Scan(&block, &checkedAt)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return block, checkedAt, nil
|
||||
}
|
||||
|
||||
// GetLastCheckedBlock returns the last checked block for an address, or -1 if never checked.
|
||||
func (m *CheckpointModel) GetLastCheckedBlock(ctx context.Context, addressID int) (int, bool, error) {
|
||||
var block int
|
||||
@@ -23,7 +40,7 @@ func (m *CheckpointModel) GetLastCheckedBlock(ctx context.Context, addressID int
|
||||
addressID,
|
||||
).Scan(&block)
|
||||
if err != nil {
|
||||
if err.Error() == "no rows in result set" {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, false, nil
|
||||
}
|
||||
return 0, false, err
|
||||
103
backend/internal/models/notification_config.go
Normal file
103
backend/internal/models/notification_config.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
)
|
||||
|
||||
type NotificationConfigModel struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewNotificationConfigModel(pool *pgxpool.Pool) *NotificationConfigModel {
|
||||
return &NotificationConfigModel{pool: pool}
|
||||
}
|
||||
|
||||
func (m *NotificationConfigModel) GetConfig(ctx context.Context, userID string) (*domain.NotificationConfig, error) {
|
||||
var c domain.NotificationConfig
|
||||
err := m.pool.QueryRow(ctx,
|
||||
`SELECT user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token,
|
||||
email, slack_webhook_url, notification_enabled, created_at, updated_at
|
||||
FROM user_notification_configs
|
||||
WHERE user_id = $1`,
|
||||
userID,
|
||||
).Scan(&c.UserID, &c.DiscordWebhookURL, &c.TelegramChatID, &c.TelegramBotToken,
|
||||
&c.Email, &c.SlackWebhookURL, &c.NotificationEnabled, &c.CreatedAt, &c.UpdatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (m *NotificationConfigModel) UpsertConfig(ctx context.Context, userID string, cfg domain.NotificationConfig) (*domain.NotificationConfig, error) {
|
||||
var c domain.NotificationConfig
|
||||
err := m.pool.QueryRow(ctx,
|
||||
`INSERT INTO user_notification_configs
|
||||
(user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token,
|
||||
email, slack_webhook_url, notification_enabled, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE SET
|
||||
discord_webhook_url = $2,
|
||||
telegram_chat_id = $3,
|
||||
telegram_bot_token = $4,
|
||||
email = $5,
|
||||
slack_webhook_url = $6,
|
||||
notification_enabled = $7,
|
||||
updated_at = NOW()
|
||||
RETURNING user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token,
|
||||
email, slack_webhook_url, notification_enabled, created_at, updated_at`,
|
||||
userID, cfg.DiscordWebhookURL, cfg.TelegramChatID, cfg.TelegramBotToken,
|
||||
cfg.Email, cfg.SlackWebhookURL, cfg.NotificationEnabled,
|
||||
).Scan(&c.UserID, &c.DiscordWebhookURL, &c.TelegramChatID, &c.TelegramBotToken,
|
||||
&c.Email, &c.SlackWebhookURL, &c.NotificationEnabled, &c.CreatedAt, &c.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (m *NotificationConfigModel) Remove(ctx context.Context, userID string) (bool, error) {
|
||||
tag, err := m.pool.Exec(ctx,
|
||||
`DELETE FROM user_notification_configs WHERE user_id = $1`,
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return tag.RowsAffected() > 0, nil
|
||||
}
|
||||
|
||||
func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.NotificationConfig, error) {
|
||||
rows, err := m.pool.Query(ctx,
|
||||
`SELECT nc.user_id, nc.discord_webhook_url, nc.telegram_chat_id, nc.telegram_bot_token,
|
||||
nc.email, nc.slack_webhook_url
|
||||
FROM user_notification_configs nc
|
||||
JOIN users u ON u.id = nc.user_id
|
||||
WHERE nc.notification_enabled = TRUE
|
||||
AND u.subscription_status IN ('active', 'trialing')`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var configs []domain.NotificationConfig
|
||||
for rows.Next() {
|
||||
var c domain.NotificationConfig
|
||||
if err := rows.Scan(&c.UserID, &c.DiscordWebhookURL, &c.TelegramChatID,
|
||||
&c.TelegramBotToken, &c.Email, &c.SlackWebhookURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.NotificationEnabled = true
|
||||
configs = append(configs, c)
|
||||
}
|
||||
return configs, rows.Err()
|
||||
}
|
||||
111
backend/internal/models/user.go
Normal file
111
backend/internal/models/user.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
)
|
||||
|
||||
type UserModel struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewUserModel(pool *pgxpool.Pool) *UserModel {
|
||||
return &UserModel{pool: pool}
|
||||
}
|
||||
|
||||
const userColumns = `id, firebase_uid, email, display_name,
|
||||
stripe_customer_id, stripe_subscription_id, subscription_status,
|
||||
subscription_tier, subscription_created_at, created_at, updated_at`
|
||||
|
||||
func scanUser(row pgx.Row) (*domain.User, error) {
|
||||
var u domain.User
|
||||
err := row.Scan(
|
||||
&u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName,
|
||||
&u.StripeCustomerID, &u.StripeSubscriptionID, &u.SubscriptionStatus,
|
||||
&u.SubscriptionTier, &u.SubscriptionCreatedAt, &u.CreatedAt, &u.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// FindOrCreateByFirebaseUID returns the local user for a Firebase UID,
|
||||
// creating one if it doesn't exist yet. On conflict (returning user) the
|
||||
// updated_at timestamp is refreshed.
|
||||
func (m *UserModel) FindOrCreateByFirebaseUID(ctx context.Context, firebaseUID, email string) (*domain.User, error) {
|
||||
row := m.pool.QueryRow(ctx,
|
||||
`INSERT INTO users (firebase_uid, email)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (firebase_uid) DO UPDATE SET updated_at = NOW()
|
||||
RETURNING `+userColumns,
|
||||
firebaseUID, email,
|
||||
)
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
func (m *UserModel) GetByID(ctx context.Context, id string) (*domain.User, error) {
|
||||
row := m.pool.QueryRow(ctx,
|
||||
`SELECT `+userColumns+` FROM users WHERE id = $1`, id,
|
||||
)
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
func (m *UserModel) UpdateStripeCustomer(ctx context.Context, userID, stripeCustomerID string) error {
|
||||
_, err := m.pool.Exec(ctx,
|
||||
`UPDATE users SET stripe_customer_id = $2, updated_at = NOW() WHERE id = $1`,
|
||||
userID, stripeCustomerID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *UserModel) ActivateSubscription(ctx context.Context, stripeCustomerID, subscriptionID, status string, tier domain.SubscriptionTier) error {
|
||||
_, err := m.pool.Exec(ctx,
|
||||
`UPDATE users
|
||||
SET stripe_subscription_id = $2,
|
||||
subscription_status = $3,
|
||||
subscription_tier = $4,
|
||||
subscription_created_at = COALESCE(subscription_created_at, NOW()),
|
||||
updated_at = NOW()
|
||||
WHERE stripe_customer_id = $1`,
|
||||
stripeCustomerID, subscriptionID, status, string(tier),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *UserModel) UpdateSubscriptionTier(ctx context.Context, userID string, tier domain.SubscriptionTier) error {
|
||||
_, err := m.pool.Exec(ctx,
|
||||
`UPDATE users SET subscription_tier = $2, updated_at = NOW() WHERE id = $1`,
|
||||
userID, string(tier),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *UserModel) ActivateFreeTier(ctx context.Context, userID string) error {
|
||||
_, err := m.pool.Exec(ctx,
|
||||
`UPDATE users
|
||||
SET subscription_status = 'active',
|
||||
subscription_tier = 'free',
|
||||
subscription_created_at = COALESCE(subscription_created_at, NOW()),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
userID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *UserModel) UpdateSubscriptionStatus(ctx context.Context, stripeCustomerID, status string) error {
|
||||
_, err := m.pool.Exec(ctx,
|
||||
`UPDATE users SET subscription_status = $2, updated_at = NOW()
|
||||
WHERE stripe_customer_id = $1`,
|
||||
stripeCustomerID, status,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package notifications
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -9,11 +10,28 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type AlertMetadata struct {
|
||||
TxHash string
|
||||
AddressLabel string
|
||||
AlertType string
|
||||
Address string
|
||||
const (
|
||||
// discordHTTPTimeoutSeconds is the timeout for Discord webhook requests.
|
||||
discordHTTPTimeoutSeconds = 10
|
||||
|
||||
// Alert color codes for Discord embeds.
|
||||
colorGreen = 0x00ff00
|
||||
colorOrange = 0xff9900
|
||||
colorRed = 0xff0000
|
||||
colorBlue = 0x0099ff
|
||||
)
|
||||
|
||||
var discordHTTPClient = &http.Client{ //nolint:gochecknoglobals
|
||||
Timeout: discordHTTPTimeoutSeconds * time.Second,
|
||||
}
|
||||
|
||||
// sends alert notifications via a Discord webhook.
|
||||
type DiscordNotifier struct {
|
||||
WebhookURL string
|
||||
}
|
||||
func (d *DiscordNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
|
||||
_, err := SendDiscordNotification(d.WebhookURL, message, meta)
|
||||
return err
|
||||
}
|
||||
|
||||
type discordEmbed struct {
|
||||
@@ -73,7 +91,9 @@ func SendDiscordNotification(webhookURL, message string, meta AlertMetadata) (bo
|
||||
return false, fmt.Errorf("marshal discord payload: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body))
|
||||
resp, err := discordHTTPClient.Post(
|
||||
webhookURL, "application/json", bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Failed to send Discord notification: %v", err)
|
||||
return false, err
|
||||
@@ -81,8 +101,12 @@ func SendDiscordNotification(webhookURL, message string, meta AlertMetadata) (bo
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
err := fmt.Errorf("discord webhook failed: HTTP %d", resp.StatusCode)
|
||||
log.Printf("Discord webhook failed: HTTP %d", resp.StatusCode)
|
||||
return false, fmt.Errorf("discord webhook failed: HTTP %d", resp.StatusCode)
|
||||
if isPermanentStatusCode(resp.StatusCode) {
|
||||
return false, &PermanentError{Err: err}
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
@@ -98,7 +122,9 @@ func TestDiscordWebhook(webhookURL string) (bool, error) {
|
||||
return false, err
|
||||
}
|
||||
|
||||
resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body))
|
||||
resp, err := discordHTTPClient.Post(
|
||||
webhookURL, "application/json", bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Discord webhook test failed: %v", err)
|
||||
return false, err
|
||||
@@ -111,14 +137,14 @@ func TestDiscordWebhook(webhookURL string) (bool, error) {
|
||||
func colorForAlertType(alertType string) int {
|
||||
switch alertType {
|
||||
case "incoming_tx":
|
||||
return 0x00ff00 // Green
|
||||
return colorGreen
|
||||
case "outgoing_tx":
|
||||
return 0xff9900 // Orange
|
||||
return colorOrange
|
||||
case "large_transfer":
|
||||
return 0xff0000 // Red
|
||||
return colorRed
|
||||
case "balance_below":
|
||||
return 0xff0000 // Red
|
||||
return colorRed
|
||||
default:
|
||||
return 0x0099ff // Blue
|
||||
return colorBlue
|
||||
}
|
||||
}
|
||||
158
backend/internal/notifications/email.go
Normal file
158
backend/internal/notifications/email.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type EmailNotifier struct {
|
||||
APIKey string
|
||||
From string
|
||||
To string
|
||||
}
|
||||
|
||||
func (e *EmailNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
|
||||
_, err := SendEmailNotification(e.APIKey, e.From, e.To, message, meta)
|
||||
return err
|
||||
}
|
||||
|
||||
const emailHTTPTimeoutSeconds = 10
|
||||
|
||||
var emailHTTPClient = &http.Client{ //nolint:gochecknoglobals
|
||||
Timeout: emailHTTPTimeoutSeconds * time.Second,
|
||||
}
|
||||
|
||||
type resendPayload struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
HTML string `json:"html"`
|
||||
}
|
||||
|
||||
func SendEmailNotification(apiKey, fromAddress, toAddress, message string, meta AlertMetadata) (bool, error) {
|
||||
if apiKey == "" {
|
||||
log.Printf("Skipping email notification: RESEND_API_KEY not configured")
|
||||
return false, nil
|
||||
}
|
||||
|
||||
subject := fmt.Sprintf("Koin Ping Alert: %s", alertTypeLabel(meta.AlertType))
|
||||
|
||||
txLink := ""
|
||||
if meta.TxHash != "" {
|
||||
txLink = fmt.Sprintf(
|
||||
`<p><a href="https://etherscan.io/tx/%s">View on Etherscan</a></p>`,
|
||||
meta.TxHash,
|
||||
)
|
||||
}
|
||||
|
||||
html := fmt.Sprintf(`
|
||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="color: #333;">Koin Ping Alert</h2>
|
||||
<p style="font-size: 16px;">%s</p>
|
||||
<table style="margin: 16px 0; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td style="padding: 4px 12px 4px 0; color: #666;">Address</td>
|
||||
<td style="padding: 4px 0;">%s</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 12px 4px 0; color: #666;">Blockchain</td>
|
||||
<td style="padding: 4px 0; font-family: monospace; font-size: 13px;">%s</td>
|
||||
</tr>
|
||||
</table>
|
||||
%s
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 24px 0;" />
|
||||
<p style="font-size: 12px; color: #999;">Sent by Koin Ping</p>
|
||||
</div>`,
|
||||
message, meta.AddressLabel, meta.Address, txLink)
|
||||
|
||||
payload := resendPayload{
|
||||
From: fromAddress,
|
||||
To: toAddress,
|
||||
Subject: subject,
|
||||
HTML: html,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("marshal email payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, "https://api.resend.com/emails", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("create email request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
resp, err := emailHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("Failed to send email notification: %v", err)
|
||||
return false, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
err := fmt.Errorf("resend API failed: HTTP %d", resp.StatusCode)
|
||||
log.Printf("Resend API failed: HTTP %d", resp.StatusCode)
|
||||
if isPermanentStatusCode(resp.StatusCode) {
|
||||
return false, &PermanentError{Err: err}
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func TestEmailNotification(apiKey, fromAddress, toAddress string) (bool, error) {
|
||||
if apiKey == "" {
|
||||
return false, fmt.Errorf("email not configured: RESEND_API_KEY not set") //nolint:err113
|
||||
}
|
||||
|
||||
payload := resendPayload{
|
||||
From: fromAddress,
|
||||
To: toAddress,
|
||||
Subject: "Koin Ping — Test Notification",
|
||||
HTML: `<p>Your email alerts are configured correctly!</p><p style="font-size:12px;color:#999;">Sent by Koin Ping</p>`,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, "https://api.resend.com/emails", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
resp, err := emailHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("Email test failed: %v", err)
|
||||
return false, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return resp.StatusCode >= 200 && resp.StatusCode < 300, nil
|
||||
}
|
||||
|
||||
func alertTypeLabel(alertType string) string {
|
||||
switch alertType {
|
||||
case "incoming_tx":
|
||||
return "Incoming Transaction"
|
||||
case "outgoing_tx":
|
||||
return "Outgoing Transaction"
|
||||
case "large_transfer":
|
||||
return "Large Transfer"
|
||||
case "balance_below":
|
||||
return "Balance Below Threshold"
|
||||
default:
|
||||
return "Alert"
|
||||
}
|
||||
}
|
||||
38
backend/internal/notifications/notifier.go
Normal file
38
backend/internal/notifications/notifier.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// AlertMetadata holds context about the alert being sent.
|
||||
type AlertMetadata struct {
|
||||
TxHash string
|
||||
AddressLabel string
|
||||
AlertType string
|
||||
Address string
|
||||
}
|
||||
|
||||
type Notifier interface {
|
||||
Send(ctx context.Context, message string, meta AlertMetadata) error
|
||||
}
|
||||
|
||||
// PermanentError wraps errors that should not be retried (e.g. 401, 403, 404).
|
||||
type PermanentError struct{ Err error }
|
||||
|
||||
func (e *PermanentError) Error() string { return e.Err.Error() }
|
||||
func (e *PermanentError) Unwrap() error { return e.Err }
|
||||
|
||||
func IsPermanent(err error) bool {
|
||||
var p *PermanentError
|
||||
return errors.As(err, &p)
|
||||
}
|
||||
|
||||
func isPermanentStatusCode(code int) bool {
|
||||
return code == http.StatusUnauthorized ||
|
||||
code == http.StatusForbidden ||
|
||||
code == http.StatusNotFound ||
|
||||
code == http.StatusMethodNotAllowed ||
|
||||
code == http.StatusGone
|
||||
}
|
||||
132
backend/internal/notifications/slack.go
Normal file
132
backend/internal/notifications/slack.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SlackNotifier sends alert notifications via a Slack webhook.
|
||||
type SlackNotifier struct {
|
||||
WebhookURL string
|
||||
}
|
||||
|
||||
// Send implements Notifier for Slack.
|
||||
func (s *SlackNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
|
||||
_, err := SendSlackNotification(s.WebhookURL, message, meta)
|
||||
return err
|
||||
}
|
||||
|
||||
const slackHTTPTimeoutSeconds = 10
|
||||
|
||||
var slackHTTPClient = &http.Client{ //nolint:gochecknoglobals
|
||||
Timeout: slackHTTPTimeoutSeconds * time.Second,
|
||||
}
|
||||
|
||||
type slackAttachment struct {
|
||||
Color string `json:"color"`
|
||||
Title string `json:"title"`
|
||||
Text string `json:"text"`
|
||||
Fields []slackField `json:"fields"`
|
||||
Footer string `json:"footer"`
|
||||
Ts int64 `json:"ts"`
|
||||
}
|
||||
|
||||
type slackField struct {
|
||||
Title string `json:"title"`
|
||||
Value string `json:"value"`
|
||||
Short bool `json:"short"`
|
||||
}
|
||||
|
||||
type slackPayload struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
Attachments []slackAttachment `json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
func SendSlackNotification(webhookURL, message string, meta AlertMetadata) (bool, error) {
|
||||
fields := []slackField{
|
||||
{Title: "Address", Value: meta.AddressLabel, Short: true},
|
||||
{Title: "Blockchain Address", Value: fmt.Sprintf("`%s`", meta.Address), Short: false},
|
||||
}
|
||||
|
||||
if meta.TxHash != "" {
|
||||
fields = append(fields, slackField{
|
||||
Title: "Transaction",
|
||||
Value: fmt.Sprintf("<https://etherscan.io/tx/%s|View on Etherscan>", meta.TxHash),
|
||||
Short: false,
|
||||
})
|
||||
}
|
||||
|
||||
payload := slackPayload{
|
||||
Attachments: []slackAttachment{
|
||||
{
|
||||
Color: slackColorForAlertType(meta.AlertType),
|
||||
Title: "Koin Ping Alert",
|
||||
Text: message,
|
||||
Fields: fields,
|
||||
Footer: "Koin Ping",
|
||||
Ts: time.Now().Unix(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("marshal slack payload: %w", err)
|
||||
}
|
||||
|
||||
resp, err := slackHTTPClient.Post(webhookURL, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
log.Printf("Failed to send Slack notification: %v", err)
|
||||
return false, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
err := fmt.Errorf("slack webhook failed: HTTP %d", resp.StatusCode)
|
||||
log.Printf("Slack webhook failed: HTTP %d", resp.StatusCode)
|
||||
if isPermanentStatusCode(resp.StatusCode) {
|
||||
return false, &PermanentError{Err: err}
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func TestSlackWebhook(webhookURL string) (bool, error) {
|
||||
payload := slackPayload{
|
||||
Text: "Koin Ping test notification — Your Slack alerts are configured correctly!",
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
resp, err := slackHTTPClient.Post(webhookURL, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
log.Printf("Slack webhook test failed: %v", err)
|
||||
return false, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return resp.StatusCode >= 200 && resp.StatusCode < 300, nil
|
||||
}
|
||||
|
||||
func slackColorForAlertType(alertType string) string {
|
||||
switch alertType {
|
||||
case "incoming_tx":
|
||||
return "#00ff00"
|
||||
case "outgoing_tx":
|
||||
return "#ff9900"
|
||||
case "large_transfer", "balance_below":
|
||||
return "#ff0000"
|
||||
default:
|
||||
return "#0099ff"
|
||||
}
|
||||
}
|
||||
126
backend/internal/notifications/telegram.go
Normal file
126
backend/internal/notifications/telegram.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TelegramNotifier sends alert notifications via Telegram.
|
||||
type TelegramNotifier struct {
|
||||
BotToken string
|
||||
ChatID string
|
||||
}
|
||||
|
||||
// Send implements Notifier for Telegram.
|
||||
func (t *TelegramNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
|
||||
_, err := SendTelegramNotification(t.BotToken, t.ChatID, message, meta)
|
||||
return err
|
||||
}
|
||||
|
||||
const telegramHTTPTimeoutSeconds = 10
|
||||
|
||||
var telegramHTTPClient = &http.Client{ //nolint:gochecknoglobals
|
||||
Timeout: telegramHTTPTimeoutSeconds * time.Second,
|
||||
}
|
||||
|
||||
type telegramPayload struct {
|
||||
ChatID string `json:"chat_id"`
|
||||
Text string `json:"text"`
|
||||
ParseMode string `json:"parse_mode"`
|
||||
}
|
||||
|
||||
func SendTelegramNotification(botToken, chatID, message string, meta AlertMetadata) (bool, error) {
|
||||
text := fmt.Sprintf("*Koin Ping Alert*\n\n%s\n\n*Address:* %s\n`%s`",
|
||||
escapeMarkdown(message), escapeMarkdown(meta.AddressLabel), meta.Address)
|
||||
|
||||
if meta.TxHash != "" {
|
||||
text += fmt.Sprintf("\n\n[View on Etherscan](https://etherscan.io/tx/%s)", meta.TxHash)
|
||||
}
|
||||
|
||||
payload := telegramPayload{
|
||||
ChatID: chatID,
|
||||
Text: text,
|
||||
ParseMode: "Markdown",
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("marshal telegram payload: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken)
|
||||
|
||||
resp, err := telegramHTTPClient.Post(url, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
log.Printf("Failed to send Telegram notification: %v", err)
|
||||
return false, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
err := fmt.Errorf("telegram API failed: HTTP %d", resp.StatusCode)
|
||||
log.Printf("Telegram API failed: HTTP %d", resp.StatusCode)
|
||||
if isPermanentStatusCode(resp.StatusCode) {
|
||||
return false, &PermanentError{Err: err}
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func TestTelegramWebhook(botToken, chatID string) (bool, error) {
|
||||
payload := telegramPayload{
|
||||
ChatID: chatID,
|
||||
Text: "Koin Ping test notification — Your Telegram alerts are configured correctly!",
|
||||
ParseMode: "Markdown",
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken)
|
||||
|
||||
resp, err := telegramHTTPClient.Post(url, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
log.Printf("Telegram test failed: %v", err)
|
||||
return false, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return resp.StatusCode >= 200 && resp.StatusCode < 300, nil
|
||||
}
|
||||
|
||||
func escapeMarkdown(s string) string {
|
||||
replacer := []struct{ old, new string }{
|
||||
{"_", "\\_"}, {"*", "\\*"}, {"[", "\\["}, {"]", "\\]"},
|
||||
{"(", "\\("}, {")", "\\)"}, {"~", "\\~"}, {"`", "\\`"},
|
||||
{">", "\\>"}, {"#", "\\#"}, {"+", "\\+"}, {"-", "\\-"},
|
||||
{"=", "\\="}, {"|", "\\|"}, {"{", "\\{"}, {"}", "\\}"},
|
||||
{".", "\\."}, {"!", "\\!"},
|
||||
}
|
||||
result := s
|
||||
for _, r := range replacer {
|
||||
result = replaceAll(result, r.old, r.new)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func replaceAll(s, old, new string) string {
|
||||
out := ""
|
||||
for i := 0; i < len(s); i++ {
|
||||
if string(s[i]) == old {
|
||||
out += new
|
||||
} else {
|
||||
out += string(s[i])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
421
backend/internal/protocols/ethereum/jsonrpc.go
Normal file
421
backend/internal/protocols/ethereum/jsonrpc.go
Normal file
@@ -0,0 +1,421 @@
|
||||
package ethereum
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
rpcTimeoutMS = 30000
|
||||
rpcMaxRetries = 3
|
||||
rpcRetryBaseMS = 2000
|
||||
rpcMinIntervalMS = 1000
|
||||
)
|
||||
|
||||
type JsonRpcEthereum struct {
|
||||
rpcURL string
|
||||
client *http.Client
|
||||
lastCallAt time.Time
|
||||
}
|
||||
|
||||
func NewJsonRpcEthereum(rpcURL string) (*JsonRpcEthereum, error) {
|
||||
if rpcURL == "" {
|
||||
return nil, fmt.Errorf("JsonRpcEthereum requires a valid RPC URL")
|
||||
}
|
||||
return &JsonRpcEthereum{
|
||||
rpcURL: rpcURL,
|
||||
client: &http.Client{
|
||||
Timeout: time.Duration(rpcTimeoutMS) * time.Millisecond,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type rpcRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int64 `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params []interface{} `json:"params"`
|
||||
}
|
||||
|
||||
type rpcResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int64 `json:"id"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
|
||||
type rpcError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (j *JsonRpcEthereum) callRPC(ctx context.Context, method string, params ...interface{}) (json.RawMessage, error) {
|
||||
j.throttle(ctx)
|
||||
|
||||
if params == nil {
|
||||
params = []interface{}{}
|
||||
}
|
||||
|
||||
body, err := json.Marshal(rpcRequest{
|
||||
JSONRPC: "2.0",
|
||||
ID: time.Now().UnixMilli(),
|
||||
Method: method,
|
||||
Params: params,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal RPC request: %w", err)
|
||||
}
|
||||
|
||||
result, callErr := j.callWithRetry(ctx, method, body)
|
||||
j.lastCallAt = time.Now()
|
||||
|
||||
return result, callErr
|
||||
}
|
||||
|
||||
func (j *JsonRpcEthereum) throttle(ctx context.Context) {
|
||||
if j.lastCallAt.IsZero() {
|
||||
return
|
||||
}
|
||||
minInterval := time.Duration(rpcMinIntervalMS) * time.Millisecond
|
||||
elapsed := time.Since(j.lastCallAt)
|
||||
if elapsed >= minInterval {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-time.After(minInterval - elapsed):
|
||||
}
|
||||
}
|
||||
|
||||
// callWithRetry executes a JSON-RPC POST with exponential backoff on transient errors.
|
||||
// It retries on network errors, HTTP 429, and HTTP 5xx. It does NOT retry on RPC-level
|
||||
// errors or other 4xx responses (those are permanent failures).
|
||||
func (j *JsonRpcEthereum) callWithRetry(ctx context.Context, method string, body []byte) (json.RawMessage, error) {
|
||||
var lastErr error
|
||||
for attempt := range rpcMaxRetries {
|
||||
if attempt > 0 {
|
||||
wait := time.Duration(rpcRetryBaseMS*(1<<(attempt-1))) * time.Millisecond
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(wait):
|
||||
}
|
||||
log.Printf("Retrying RPC call [%s] (attempt %d/%d)", method, attempt+1, rpcMaxRetries)
|
||||
}
|
||||
|
||||
result, err := j.doRPCCall(ctx, method, body)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
|
||||
// Permanent errors: do not retry
|
||||
if isPermanentRPCError(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Printf("Transient RPC error [%s] (attempt %d/%d): %v", method, attempt+1, rpcMaxRetries, err)
|
||||
}
|
||||
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (j *JsonRpcEthereum) doRPCCall(ctx context.Context, method string, body []byte) (json.RawMessage, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, j.rpcURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create RPC request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := j.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("RPC call failed [%s]: %w", method, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 429 and 5xx are transient; other non-200 are permanent.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
err := fmt.Errorf("HTTP %d: %s for %s", resp.StatusCode, resp.Status, method)
|
||||
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 { //nolint:mnd
|
||||
return nil, err // transient — will be retried
|
||||
}
|
||||
return nil, &permanentRPCError{err}
|
||||
}
|
||||
|
||||
var rpcResp rpcResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rpcResp); err != nil {
|
||||
return nil, fmt.Errorf("decode RPC response: %w", err)
|
||||
}
|
||||
|
||||
if rpcResp.Error != nil {
|
||||
// RPC-level errors are permanent (bad params, unsupported method, etc.)
|
||||
return nil, &permanentRPCError{
|
||||
fmt.Errorf("RPC Error [%s]: %s (code: %d)", method, rpcResp.Error.Message, rpcResp.Error.Code),
|
||||
}
|
||||
}
|
||||
|
||||
return rpcResp.Result, nil
|
||||
}
|
||||
|
||||
type permanentRPCError struct{ cause error }
|
||||
|
||||
func (e *permanentRPCError) Error() string { return e.cause.Error() }
|
||||
func (e *permanentRPCError) Unwrap() error { return e.cause }
|
||||
|
||||
func isPermanentRPCError(err error) bool {
|
||||
var p *permanentRPCError
|
||||
return errors.As(err, &p)
|
||||
}
|
||||
|
||||
func (j *JsonRpcEthereum) GetLatestBlockNumber(ctx context.Context) (int, error) {
|
||||
result, err := j.callRPC(ctx, "eth_blockNumber")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var hexBlock string
|
||||
if err := json.Unmarshal(result, &hexBlock); err != nil {
|
||||
return 0, fmt.Errorf("unmarshal block number: %w", err)
|
||||
}
|
||||
|
||||
return hexToInt(hexBlock)
|
||||
}
|
||||
|
||||
type rpcBlock struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
Transactions []rpcTx `json:"transactions"`
|
||||
}
|
||||
|
||||
type rpcTx struct {
|
||||
Hash string `json:"hash"`
|
||||
From string `json:"from"`
|
||||
To *string `json:"to"`
|
||||
Value string `json:"value"`
|
||||
BlockNumber string `json:"blockNumber"`
|
||||
}
|
||||
|
||||
func (j *JsonRpcEthereum) GetBlockTransactions(ctx context.Context, blockNumber int) ([]domain.NormalizedTx, error) {
|
||||
hexBlock := fmt.Sprintf("0x%x", blockNumber)
|
||||
|
||||
result, err := j.callRPC(ctx, "eth_getBlockByNumber", hexBlock, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if string(result) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var block rpcBlock
|
||||
if err := json.Unmarshal(result, &block); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal block: %w", err)
|
||||
}
|
||||
|
||||
blockTimestamp, err := hexToInt64(block.Timestamp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse block timestamp: %w", err)
|
||||
}
|
||||
|
||||
txs := make([]domain.NormalizedTx, 0, len(block.Transactions))
|
||||
for _, tx := range block.Transactions {
|
||||
bn, _ := hexToInt(tx.BlockNumber)
|
||||
|
||||
var toLower *string
|
||||
if tx.To != nil {
|
||||
s := strings.ToLower(*tx.To)
|
||||
toLower = &s
|
||||
}
|
||||
|
||||
txs = append(txs, domain.NormalizedTx{
|
||||
Hash: tx.Hash,
|
||||
From: strings.ToLower(tx.From),
|
||||
To: toLower,
|
||||
Value: hexToDecimalString(tx.Value),
|
||||
BlockNumber: bn,
|
||||
BlockTimestamp: blockTimestamp,
|
||||
})
|
||||
}
|
||||
|
||||
return txs, nil
|
||||
}
|
||||
|
||||
func (j *JsonRpcEthereum) GetBalance(ctx context.Context, address string) (string, error) {
|
||||
result, err := j.callRPC(ctx, "eth_getBalance", address, "latest")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var hexBalance string
|
||||
if err := json.Unmarshal(result, &hexBalance); err != nil {
|
||||
return "", fmt.Errorf("unmarshal balance: %w", err)
|
||||
}
|
||||
|
||||
return hexToDecimalString(hexBalance), nil
|
||||
}
|
||||
|
||||
const erc20TransferTopic = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
|
||||
|
||||
const minTransferTopics = 3
|
||||
|
||||
type rpcLog struct {
|
||||
Address string `json:"address"`
|
||||
Topics []string `json:"topics"`
|
||||
Data string `json:"data"`
|
||||
BlockNumber string `json:"blockNumber"`
|
||||
TxHash string `json:"transactionHash"`
|
||||
}
|
||||
|
||||
// GetTokenTransfers fetches ERC-20 Transfer events for a given address
|
||||
// across a block range, covering both incoming and outgoing transfers.
|
||||
func (j *JsonRpcEthereum) GetTokenTransfers(ctx context.Context, fromBlock, toBlock int, address string) ([]domain.NormalizedTx, error) {
|
||||
paddedAddr := padAddress(address)
|
||||
hexFrom := fmt.Sprintf("0x%x", fromBlock)
|
||||
hexTo := fmt.Sprintf("0x%x", toBlock)
|
||||
|
||||
incomingLogs, err := j.getTransferLogs(ctx, hexFrom, hexTo, "", paddedAddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch incoming token transfers: %w", err)
|
||||
}
|
||||
|
||||
outgoingLogs, err := j.getTransferLogs(ctx, hexFrom, hexTo, paddedAddr, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch outgoing token transfers: %w", err)
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var txs []domain.NormalizedTx
|
||||
|
||||
for _, entry := range append(incomingLogs, outgoingLogs...) {
|
||||
key := entry.TxHash + "|" + entry.Address + "|" + entry.Data
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
|
||||
tx, parseErr := parseTransferLog(entry)
|
||||
if parseErr != nil {
|
||||
log.Printf("Skipping unparseable transfer log in tx %s: %v", entry.TxHash, parseErr)
|
||||
continue
|
||||
}
|
||||
txs = append(txs, tx)
|
||||
}
|
||||
|
||||
return txs, nil
|
||||
}
|
||||
|
||||
func (j *JsonRpcEthereum) getTransferLogs(ctx context.Context, fromBlock, toBlock, fromAddr, toAddr string) ([]rpcLog, error) {
|
||||
topics := make([]interface{}, minTransferTopics)
|
||||
topics[0] = erc20TransferTopic
|
||||
|
||||
if fromAddr != "" {
|
||||
topics[1] = fromAddr
|
||||
}
|
||||
if toAddr != "" {
|
||||
topics[2] = toAddr
|
||||
}
|
||||
|
||||
filter := map[string]interface{}{
|
||||
"fromBlock": fromBlock,
|
||||
"toBlock": toBlock,
|
||||
"topics": topics,
|
||||
}
|
||||
|
||||
result, err := j.callRPC(ctx, "eth_getLogs", filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var logs []rpcLog
|
||||
if err := json.Unmarshal(result, &logs); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal logs: %w", err)
|
||||
}
|
||||
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
func parseTransferLog(entry rpcLog) (domain.NormalizedTx, error) {
|
||||
if len(entry.Topics) < minTransferTopics {
|
||||
return domain.NormalizedTx{}, fmt.Errorf("transfer log has %d topics, expected >= 3", len(entry.Topics))
|
||||
}
|
||||
|
||||
from := topicToAddress(entry.Topics[1])
|
||||
to := topicToAddress(entry.Topics[2])
|
||||
tokenValue := hexToDecimalString(entry.Data)
|
||||
blockNumber, _ := hexToInt(entry.BlockNumber)
|
||||
|
||||
contractAddr := strings.ToLower(entry.Address)
|
||||
tokenInfo, known := LookupToken(contractAddr)
|
||||
|
||||
tx := domain.NormalizedTx{
|
||||
Hash: entry.TxHash,
|
||||
From: from,
|
||||
To: &to,
|
||||
Value: "0",
|
||||
BlockNumber: blockNumber,
|
||||
TokenContract: &contractAddr,
|
||||
TokenValue: &tokenValue,
|
||||
}
|
||||
|
||||
if known {
|
||||
tx.TokenSymbol = &tokenInfo.Symbol
|
||||
tx.TokenDecimals = &tokenInfo.Decimals
|
||||
}
|
||||
|
||||
return tx, nil
|
||||
}
|
||||
|
||||
func padAddress(addr string) string {
|
||||
clean := strings.TrimPrefix(strings.ToLower(addr), "0x")
|
||||
const addressHexLen = 64
|
||||
return "0x" + strings.Repeat("0", addressHexLen-len(clean)) + clean
|
||||
}
|
||||
|
||||
func topicToAddress(topic string) string {
|
||||
clean := strings.TrimPrefix(topic, "0x")
|
||||
const ethAddrLen = 40
|
||||
if len(clean) > ethAddrLen {
|
||||
clean = clean[len(clean)-ethAddrLen:]
|
||||
}
|
||||
return "0x" + strings.ToLower(clean)
|
||||
}
|
||||
|
||||
func hexToInt(hex string) (int, error) {
|
||||
hex = strings.TrimPrefix(hex, "0x")
|
||||
n, ok := new(big.Int).SetString(hex, 16)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("invalid hex: %s", hex)
|
||||
}
|
||||
return int(n.Int64()), nil
|
||||
}
|
||||
|
||||
func hexToInt64(hex string) (int64, error) {
|
||||
hex = strings.TrimPrefix(hex, "0x")
|
||||
n, ok := new(big.Int).SetString(hex, 16)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("invalid hex: %s", hex)
|
||||
}
|
||||
return n.Int64(), nil
|
||||
}
|
||||
|
||||
func hexToDecimalString(hex string) string {
|
||||
if hex == "" || hex == "0x" || hex == "0x0" {
|
||||
return "0"
|
||||
}
|
||||
clean := strings.TrimPrefix(hex, "0x")
|
||||
n, ok := new(big.Int).SetString(clean, 16)
|
||||
if !ok {
|
||||
return "0"
|
||||
}
|
||||
return n.String()
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package ethereum
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
)
|
||||
|
||||
// EthereumObserver defines the interface for blockchain interaction.
|
||||
@@ -12,4 +12,5 @@ type EthereumObserver interface {
|
||||
GetLatestBlockNumber(ctx context.Context) (int, error)
|
||||
GetBlockTransactions(ctx context.Context, blockNumber int) ([]domain.NormalizedTx, error)
|
||||
GetBalance(ctx context.Context, address string) (string, error)
|
||||
GetTokenTransfers(ctx context.Context, fromBlock, toBlock int, address string) ([]domain.NormalizedTx, error)
|
||||
}
|
||||
34
backend/internal/protocols/ethereum/tokens.go
Normal file
34
backend/internal/protocols/ethereum/tokens.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package ethereum
|
||||
|
||||
import "strings"
|
||||
|
||||
// TokenInfo holds metadata for a known ERC-20 token contract.
|
||||
type TokenInfo struct {
|
||||
Symbol string
|
||||
Decimals int
|
||||
}
|
||||
|
||||
//nolint:gochecknoglobals
|
||||
var wellKnownTokens = map[string]TokenInfo{
|
||||
"0xdac17f958d2ee523a2206206994597c13d831ec7": {Symbol: "USDT", Decimals: 6},
|
||||
"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48": {Symbol: "USDC", Decimals: 6},
|
||||
"0x6b175474e89094c44da98b954eedeac495271d0f": {Symbol: "DAI", Decimals: 18},
|
||||
"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2": {Symbol: "WETH", Decimals: 18},
|
||||
"0x2260fac5e5542a773aa44fbcfedf7c193bc2c599": {Symbol: "WBTC", Decimals: 8},
|
||||
"0x514910771af9ca656af840dff83e8264ecf986ca": {Symbol: "LINK", Decimals: 18},
|
||||
"0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": {Symbol: "UNI", Decimals: 18},
|
||||
"0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9": {Symbol: "AAVE", Decimals: 18},
|
||||
"0x95ad61b0a150d79219dcf64e1e6cc01f0b64c4ce": {Symbol: "SHIB", Decimals: 18},
|
||||
"0x6982508145454ce325ddbe47a25d4ec3d2311933": {Symbol: "PEPE", Decimals: 18},
|
||||
"0xb8c77482e45f1f44de1745f52c74426c631bdd52": {Symbol: "BNB", Decimals: 18},
|
||||
"0x4fabb145d64652a948d72533023f6e7a623c7c53": {Symbol: "BUSD", Decimals: 18},
|
||||
"0x75231f58b43240c9718dd58b4967c5114342a86c": {Symbol: "OKB", Decimals: 18},
|
||||
"0x582d872a1b094fc48f5de31d3b73f2d9be47def1": {Symbol: "TON", Decimals: 9},
|
||||
"0x4d224452801aced8b2f0aebe155379bb5d594381": {Symbol: "APE", Decimals: 18},
|
||||
}
|
||||
|
||||
// LookupToken returns metadata for a known token contract, if found.
|
||||
func LookupToken(contractAddress string) (TokenInfo, bool) {
|
||||
info, ok := wellKnownTokens[strings.ToLower(contractAddress)]
|
||||
return info, ok
|
||||
}
|
||||
196
backend/internal/services/email_digest.go
Normal file
196
backend/internal/services/email_digest.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
resendAPIURL = "https://api.resend.com/emails"
|
||||
emailHTTPTimeout = 10 * time.Second
|
||||
defaultDigestMaxItems = 50
|
||||
)
|
||||
|
||||
var digestHTTPClient = &http.Client{Timeout: emailHTTPTimeout} //nolint:gochecknoglobals
|
||||
|
||||
// handles email setup and digest sending via Resend.
|
||||
type EmailDigestService struct {
|
||||
apiKey string
|
||||
fromAddress string
|
||||
alertEvents *models.AlertEventModel
|
||||
notifCfgs *models.NotificationConfigModel
|
||||
}
|
||||
|
||||
func NewEmailDigestService(
|
||||
apiKey, fromAddress string,
|
||||
alertEvents *models.AlertEventModel,
|
||||
notifCfgs *models.NotificationConfigModel,
|
||||
) *EmailDigestService {
|
||||
return &EmailDigestService{
|
||||
apiKey: apiKey,
|
||||
fromAddress: fromAddress,
|
||||
alertEvents: alertEvents,
|
||||
notifCfgs: notifCfgs,
|
||||
}
|
||||
}
|
||||
|
||||
// Configured returns true when the Resend API key is present.
|
||||
func (s *EmailDigestService) Configured() bool {
|
||||
return s.apiKey != ""
|
||||
}
|
||||
|
||||
// SetupEmail validates the email works by sending a welcome/confirmation
|
||||
// message via Resend. Called when a user saves their email in notification settings.
|
||||
func (s *EmailDigestService) SetupEmail(toAddress string) error {
|
||||
if !s.Configured() {
|
||||
return fmt.Errorf("email service not configured: RESEND_API_KEY not set") //nolint:err113
|
||||
}
|
||||
|
||||
html := `
|
||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="color: #333;">Welcome to Koin Ping Email Alerts</h2>
|
||||
<p>Your email has been successfully configured for alert notifications.</p>
|
||||
<p>You will receive alert digests at this address when events are triggered
|
||||
on your watched addresses.</p>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 24px 0;" />
|
||||
<p style="font-size: 12px; color: #999;">Sent by Koin Ping</p>
|
||||
</div>`
|
||||
|
||||
return s.send(toAddress, "Koin Ping — Email Alerts Configured", html)
|
||||
}
|
||||
|
||||
func (s *EmailDigestService) SendDigest(ctx context.Context, userID, toAddress string) error {
|
||||
if !s.Configured() {
|
||||
return fmt.Errorf("email service not configured: RESEND_API_KEY not set") //nolint:err113
|
||||
}
|
||||
|
||||
events, err := s.alertEvents.ListRecentByUser(ctx, userID, defaultDigestMaxItems)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch alert events: %w", err)
|
||||
}
|
||||
|
||||
if len(events) == 0 {
|
||||
log.Printf("No recent alerts for user %s — skipping digest", userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
var rows string
|
||||
for _, e := range events {
|
||||
label := "—"
|
||||
if e.AddressLabel != nil {
|
||||
label = *e.AddressLabel
|
||||
}
|
||||
txLink := "—"
|
||||
if e.TxHash != nil {
|
||||
txLink = fmt.Sprintf(
|
||||
`<a href="https://etherscan.io/tx/%s" style="color:#0066cc;">%s…</a>`,
|
||||
*e.TxHash, (*e.TxHash)[:10],
|
||||
)
|
||||
}
|
||||
rows += fmt.Sprintf(`
|
||||
<tr>
|
||||
<td style="padding:6px 8px; border-bottom:1px solid #eee;">%s</td>
|
||||
<td style="padding:6px 8px; border-bottom:1px solid #eee;">%s</td>
|
||||
<td style="padding:6px 8px; border-bottom:1px solid #eee;">%s</td>
|
||||
<td style="padding:6px 8px; border-bottom:1px solid #eee; font-size:12px; color:#666;">%s</td>
|
||||
</tr>`,
|
||||
label, e.Message, txLink,
|
||||
e.Timestamp.Format("Jan 2 15:04 UTC"),
|
||||
)
|
||||
}
|
||||
|
||||
html := fmt.Sprintf(`
|
||||
<div style="font-family: sans-serif; max-width: 700px; margin: 0 auto;">
|
||||
<h2 style="color: #333;">Koin Ping — Alert Digest</h2>
|
||||
<p>Here are your recent alerts (%d total):</p>
|
||||
<table style="width:100%%; border-collapse:collapse; font-size:14px;">
|
||||
<thead>
|
||||
<tr style="background:#f5f5f5;">
|
||||
<th style="padding:8px; text-align:left;">Address</th>
|
||||
<th style="padding:8px; text-align:left;">Alert</th>
|
||||
<th style="padding:8px; text-align:left;">Tx</th>
|
||||
<th style="padding:8px; text-align:left;">Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>%s</tbody>
|
||||
</table>
|
||||
<hr style="border:none; border-top:1px solid #eee; margin:24px 0;" />
|
||||
<p style="font-size:12px; color:#999;">Sent by Koin Ping</p>
|
||||
</div>`, len(events), rows)
|
||||
|
||||
subject := fmt.Sprintf("Koin Ping Digest — %d alerts", len(events))
|
||||
|
||||
return s.send(toAddress, subject, html)
|
||||
}
|
||||
|
||||
func (s *EmailDigestService) SendDigestsForAllUsers(ctx context.Context) (int, error) {
|
||||
if !s.Configured() {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
configs, err := s.notifCfgs.ListEnabled(ctx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("list enabled configs: %w", err)
|
||||
}
|
||||
|
||||
sent := 0
|
||||
for _, cfg := range configs {
|
||||
if cfg.Email == nil || *cfg.Email == "" {
|
||||
continue
|
||||
}
|
||||
if err := s.SendDigest(ctx, cfg.UserID, *cfg.Email); err != nil {
|
||||
log.Printf("Failed to send digest to user %s: %v", cfg.UserID, err)
|
||||
continue
|
||||
}
|
||||
sent++
|
||||
}
|
||||
|
||||
return sent, nil
|
||||
}
|
||||
|
||||
type resendEmailPayload struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
HTML string `json:"html"`
|
||||
}
|
||||
|
||||
func (s *EmailDigestService) send(to, subject, html string) error {
|
||||
payload := resendEmailPayload{
|
||||
From: s.fromAddress,
|
||||
To: to,
|
||||
Subject: subject,
|
||||
HTML: html,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal email payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, resendAPIURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+s.apiKey)
|
||||
|
||||
resp, err := digestHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send email via Resend: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("Resend API returned HTTP %d", resp.StatusCode) //nolint:err113
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
406
backend/internal/services/evaluator.go
Normal file
406
backend/internal/services/evaluator.go
Normal file
@@ -0,0 +1,406 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/semaphore"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend/internal/notifications"
|
||||
"github.com/kjannette/koin-ping/backend/internal/protocols/ethereum"
|
||||
"github.com/kjannette/koin-ping/backend/internal/wei"
|
||||
)
|
||||
|
||||
const (
|
||||
notificationTimeout = 30 * time.Second
|
||||
notificationMaxRetries = 3
|
||||
notificationRetryBase = time.Second
|
||||
maxConcurrentNotifications = 5
|
||||
)
|
||||
|
||||
type EvaluatorService struct {
|
||||
eth ethereum.EthereumObserver
|
||||
alertRules *models.AlertRuleModel
|
||||
alertEvents *models.AlertEventModel
|
||||
addresses *models.AddressModel
|
||||
users *models.UserModel
|
||||
notifConfigs *models.NotificationConfigModel
|
||||
notifSem *semaphore.Weighted
|
||||
notifWg sync.WaitGroup
|
||||
}
|
||||
|
||||
func NewEvaluatorService(
|
||||
eth ethereum.EthereumObserver,
|
||||
alertRules *models.AlertRuleModel,
|
||||
alertEvents *models.AlertEventModel,
|
||||
addresses *models.AddressModel,
|
||||
users *models.UserModel,
|
||||
notifConfigs *models.NotificationConfigModel,
|
||||
) *EvaluatorService {
|
||||
return &EvaluatorService{
|
||||
eth: eth,
|
||||
alertRules: alertRules,
|
||||
alertEvents: alertEvents,
|
||||
addresses: addresses,
|
||||
users: users,
|
||||
notifConfigs: notifConfigs,
|
||||
notifSem: semaphore.NewWeighted(maxConcurrentNotifications),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *EvaluatorService) Evaluate(ctx context.Context, observations []domain.ObservedTx) (int, error) {
|
||||
alertsFired := 0
|
||||
|
||||
for _, obs := range observations {
|
||||
fired, err := s.evaluateObservation(ctx, obs)
|
||||
if err != nil {
|
||||
log.Printf("Error evaluating observation for address ID %d: %v", obs.AddressID, err)
|
||||
continue
|
||||
}
|
||||
alertsFired += fired
|
||||
}
|
||||
|
||||
return alertsFired, nil
|
||||
}
|
||||
|
||||
func (s *EvaluatorService) evaluateObservation(ctx context.Context, obs domain.ObservedTx) (int, error) {
|
||||
rules, err := s.alertRules.ListByAddress(ctx, obs.AddressID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
alertsFired := 0
|
||||
for _, rule := range rules {
|
||||
if !rule.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
matches, err := s.ruleMatches(ctx, rule, obs)
|
||||
if err != nil {
|
||||
log.Printf("Error matching rule %d: %v", rule.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if matches {
|
||||
if err := s.fireAlert(ctx, rule, obs); err != nil {
|
||||
log.Printf("Error firing alert for rule %d: %v", rule.ID, err)
|
||||
continue
|
||||
}
|
||||
alertsFired++
|
||||
}
|
||||
}
|
||||
|
||||
return alertsFired, nil
|
||||
}
|
||||
|
||||
func (s *EvaluatorService) ruleMatches(ctx context.Context, rule domain.AlertRule, obs domain.ObservedTx) (bool, error) {
|
||||
switch rule.Type {
|
||||
case domain.AlertIncomingTx:
|
||||
return s.matchesDirectionalTx(rule, obs, domain.DirectionIncoming)
|
||||
case domain.AlertOutgoingTx:
|
||||
return s.matchesDirectionalTx(rule, obs, domain.DirectionOutgoing)
|
||||
case domain.AlertLargeTransfer:
|
||||
return s.matchesLargeTransfer(rule, obs)
|
||||
case domain.AlertBalanceBelow:
|
||||
return s.matchesBalanceBelow(ctx, rule, obs)
|
||||
default:
|
||||
log.Printf("Unknown rule type: %s", rule.Type)
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *EvaluatorService) matchesDirectionalTx(rule domain.AlertRule, obs domain.ObservedTx, expected domain.Direction) (bool, error) {
|
||||
if obs.Direction != expected {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if rule.Minimum != nil {
|
||||
minWei, err := wei.FromEth(*rule.Minimum)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
aboveMin, err := wei.GreaterThanOrEqual(obs.Value, minWei)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !aboveMin {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
if rule.Maximum != nil {
|
||||
maxWei, err := wei.FromEth(*rule.Maximum)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
belowMax, err := wei.LessThanOrEqual(obs.Value, maxWei)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !belowMax {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *EvaluatorService) matchesLargeTransfer(rule domain.AlertRule, obs domain.ObservedTx) (bool, error) {
|
||||
if rule.Threshold == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
thresholdWei, err := wei.FromEth(*rule.Threshold)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return wei.GreaterThanOrEqual(obs.Value, thresholdWei)
|
||||
}
|
||||
|
||||
// matchesBalanceBelow only triggers after outgoing transactions, since incoming
|
||||
// transactions increase balance and can't cause it to drop below threshold.
|
||||
func (s *EvaluatorService) matchesBalanceBelow(ctx context.Context, rule domain.AlertRule, obs domain.ObservedTx) (bool, error) {
|
||||
if rule.Threshold == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if obs.Direction != domain.DirectionOutgoing {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
addr, err := s.addresses.FindByID(ctx, obs.AddressID, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if addr == nil {
|
||||
log.Printf("Address ID %d not found", obs.AddressID)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
balanceWei, err := s.eth.GetBalance(ctx, addr.Address)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
thresholdWei, err := wei.FromEth(*rule.Threshold)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return wei.LessThan(balanceWei, thresholdWei)
|
||||
}
|
||||
|
||||
func (s *EvaluatorService) fireAlert(ctx context.Context, rule domain.AlertRule, obs domain.ObservedTx) error {
|
||||
addr, err := s.addresses.FindByID(ctx, obs.AddressID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
addressLabel := "Unknown"
|
||||
if addr != nil {
|
||||
if addr.Label != nil {
|
||||
addressLabel = *addr.Label
|
||||
} else {
|
||||
addressLabel = addr.Address
|
||||
}
|
||||
}
|
||||
|
||||
message := s.buildMessage(rule, obs)
|
||||
txHash := &obs.Hash
|
||||
|
||||
event, err := s.alertEvents.Create(ctx, rule.ID, message, &addressLabel, txHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if event == nil {
|
||||
log.Printf("[ALERT DEDUP] Rule %d (%s) - duplicate event skipped for TX: %s", rule.ID, rule.Type, obs.Hash)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("[ALERT FIRED] Rule %d (%s) - %s - TX: %s", rule.ID, rule.Type, message, obs.Hash)
|
||||
|
||||
if addr != nil {
|
||||
userID := addr.UserID
|
||||
address := addr.Address
|
||||
if err := s.notifSem.Acquire(ctx, 1); err != nil {
|
||||
log.Printf("Failed to acquire notification semaphore for rule %d: %v", rule.ID, err)
|
||||
return nil
|
||||
}
|
||||
s.notifWg.Add(1)
|
||||
go func() {
|
||||
defer s.notifSem.Release(1)
|
||||
defer s.notifWg.Done()
|
||||
notifCtx, cancel := context.WithTimeout(context.Background(), notificationTimeout)
|
||||
defer cancel()
|
||||
s.sendNotification(notifCtx, userID, message, obs, addressLabel, rule, address)
|
||||
}()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WaitForNotifications blocks until all in-flight notification goroutines finish.
|
||||
func (s *EvaluatorService) WaitForNotifications() {
|
||||
s.notifWg.Wait()
|
||||
}
|
||||
|
||||
func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig, limits domain.TierLimits) []notifications.Notifier {
|
||||
var notifiers []notifications.Notifier
|
||||
|
||||
if limits.ChannelAllowed("discord") &&
|
||||
cfg.DiscordWebhookURL != nil && *cfg.DiscordWebhookURL != "" {
|
||||
notifiers = append(notifiers, ¬ifications.DiscordNotifier{WebhookURL: *cfg.DiscordWebhookURL})
|
||||
}
|
||||
|
||||
if limits.ChannelAllowed("telegram") &&
|
||||
cfg.TelegramBotToken != nil && *cfg.TelegramBotToken != "" &&
|
||||
cfg.TelegramChatID != nil && *cfg.TelegramChatID != "" {
|
||||
notifiers = append(notifiers, ¬ifications.TelegramNotifier{
|
||||
BotToken: *cfg.TelegramBotToken,
|
||||
ChatID: *cfg.TelegramChatID,
|
||||
})
|
||||
}
|
||||
|
||||
if limits.ChannelAllowed("slack") &&
|
||||
cfg.SlackWebhookURL != nil && *cfg.SlackWebhookURL != "" {
|
||||
notifiers = append(notifiers, ¬ifications.SlackNotifier{WebhookURL: *cfg.SlackWebhookURL})
|
||||
}
|
||||
|
||||
// Email is delivered exclusively via the daily digest service, never real-time.
|
||||
|
||||
return notifiers
|
||||
}
|
||||
|
||||
func sendWithRetry(ctx context.Context, n notifications.Notifier, message string, meta notifications.AlertMetadata) error {
|
||||
var lastErr error
|
||||
for attempt := range notificationMaxRetries {
|
||||
if attempt > 0 {
|
||||
wait := notificationRetryBase * time.Duration(1<<(attempt-1))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(wait):
|
||||
}
|
||||
}
|
||||
|
||||
if err := n.Send(ctx, message, meta); err != nil {
|
||||
lastErr = err
|
||||
|
||||
if notifications.IsPermanent(err) {
|
||||
log.Printf("Permanent notification failure, skipping retries: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("Notification attempt %d/%d failed: %v", attempt+1, notificationMaxRetries, err)
|
||||
continue
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return lastErr
|
||||
}
|
||||
|
||||
func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message string, obs domain.ObservedTx, addressLabel string, rule domain.AlertRule, address string) {
|
||||
notifConfig, err := s.notifConfigs.GetConfig(ctx, userID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get notification config: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if notifConfig == nil || !notifConfig.NotificationEnabled {
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.users.GetByID(ctx, userID)
|
||||
if err != nil || user == nil {
|
||||
log.Printf("Failed to load user %s for tier check in notification: %v", userID, err)
|
||||
return
|
||||
}
|
||||
limits := domain.GetTierLimits(user.SubscriptionTier)
|
||||
|
||||
meta := notifications.AlertMetadata{
|
||||
TxHash: obs.Hash,
|
||||
AddressLabel: addressLabel,
|
||||
AlertType: string(rule.Type),
|
||||
Address: address,
|
||||
}
|
||||
|
||||
for _, n := range s.buildNotifiers(notifConfig, limits) {
|
||||
if err := sendWithRetry(ctx, n, message, meta); err != nil {
|
||||
log.Printf("Notification channel failed for user %s after retries: %v", userID, err)
|
||||
} else {
|
||||
log.Printf("Notification sent to user %s via %T", userID, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.ObservedTx) string {
|
||||
if obs.IsTokenTransfer() {
|
||||
return s.buildTokenMessage(rule, obs)
|
||||
}
|
||||
|
||||
switch rule.Type {
|
||||
case domain.AlertIncomingTx:
|
||||
ethStr, _ := wei.FormatAsEth(obs.Value, 4)
|
||||
return fmt.Sprintf("Incoming transaction: %s received", ethStr)
|
||||
case domain.AlertOutgoingTx:
|
||||
ethStr, _ := wei.FormatAsEth(obs.Value, 4)
|
||||
return fmt.Sprintf("Outgoing transaction: %s sent", ethStr)
|
||||
case domain.AlertLargeTransfer:
|
||||
ethStr, _ := wei.FormatAsEth(obs.Value, 4)
|
||||
threshold := float64(0)
|
||||
if rule.Threshold != nil {
|
||||
threshold = *rule.Threshold
|
||||
}
|
||||
return fmt.Sprintf("Large transfer detected: %s (threshold: %g ETH)", ethStr, threshold)
|
||||
case domain.AlertBalanceBelow:
|
||||
threshold := float64(0)
|
||||
if rule.Threshold != nil {
|
||||
threshold = *rule.Threshold
|
||||
}
|
||||
return fmt.Sprintf("Balance dropped below threshold of %g ETH", threshold)
|
||||
default:
|
||||
return "Alert triggered"
|
||||
}
|
||||
}
|
||||
|
||||
const defaultTokenDecimals = 18
|
||||
|
||||
func (s *EvaluatorService) buildTokenMessage(rule domain.AlertRule, obs domain.ObservedTx) string {
|
||||
symbol := "tokens"
|
||||
if obs.TokenSymbol != nil {
|
||||
symbol = *obs.TokenSymbol
|
||||
}
|
||||
|
||||
amount := "unknown"
|
||||
if obs.TokenValue != nil {
|
||||
decimals := defaultTokenDecimals
|
||||
if obs.TokenDecimals != nil {
|
||||
decimals = *obs.TokenDecimals
|
||||
}
|
||||
amount = wei.FormatTokenAmount(*obs.TokenValue, decimals)
|
||||
}
|
||||
|
||||
switch rule.Type {
|
||||
case domain.AlertIncomingTx:
|
||||
return fmt.Sprintf("Incoming transfer: %s %s received", amount, symbol)
|
||||
case domain.AlertOutgoingTx:
|
||||
return fmt.Sprintf("Outgoing transfer: %s %s sent", amount, symbol)
|
||||
case domain.AlertLargeTransfer:
|
||||
threshold := float64(0)
|
||||
if rule.Threshold != nil {
|
||||
threshold = *rule.Threshold
|
||||
}
|
||||
return fmt.Sprintf("Large token transfer: %s %s (threshold: %g)", amount, symbol, threshold)
|
||||
default:
|
||||
return fmt.Sprintf("Token transfer: %s %s", amount, symbol)
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend/internal/protocols/ethereum"
|
||||
)
|
||||
|
||||
const maxBlocksPerRun = 100
|
||||
@@ -80,6 +80,15 @@ func (s *ObserverService) observeAddress(ctx context.Context, addr domain.Addres
|
||||
}
|
||||
}
|
||||
|
||||
tokenTxs, err := s.eth.GetTokenTransfers(ctx, startBlock, endBlock, addr.Address)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching token transfers for %s: %v", addr.Address, err)
|
||||
} else {
|
||||
for _, tx := range tokenTxs {
|
||||
observations = append(observations, createObservedTx(tx, addr))
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := s.checkpoint.UpdateLastCheckedBlock(ctx, addr.ID, endBlock); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -90,6 +90,14 @@ func LessThan(weiA, weiB string) (bool, error) {
|
||||
return cmp < 0, nil
|
||||
}
|
||||
|
||||
func LessThanOrEqual(weiA, weiB string) (bool, error) {
|
||||
cmp, err := Compare(weiA, weiB)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return cmp <= 0, nil
|
||||
}
|
||||
|
||||
// FormatAsEth formats a Wei string as "X.XXXX ETH".
|
||||
func FormatAsEth(weiString string, decimals int) (string, error) {
|
||||
eth, err := ToEth(weiString)
|
||||
@@ -98,3 +106,54 @@ func FormatAsEth(weiString string, decimals int) (string, error) {
|
||||
}
|
||||
return fmt.Sprintf("%.*f ETH", decimals, eth), nil
|
||||
}
|
||||
|
||||
// FormatTokenAmount formats a raw token amount using the token's decimal places.
|
||||
// For example, 1000000 USDT (6 decimals) becomes "1".
|
||||
func FormatTokenAmount(rawValue string, tokenDecimals int) string {
|
||||
if rawValue == "" || rawValue == "0" {
|
||||
return "0"
|
||||
}
|
||||
|
||||
n, ok := new(big.Int).SetString(rawValue, 10)
|
||||
if !ok {
|
||||
return "0"
|
||||
}
|
||||
|
||||
divisor := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(tokenDecimals)), nil) //nolint:mnd
|
||||
whole := new(big.Int).Div(n, divisor)
|
||||
remainder := new(big.Int).Mod(n, divisor)
|
||||
|
||||
if remainder.Sign() == 0 {
|
||||
return addThousandsSeparators(whole.String())
|
||||
}
|
||||
|
||||
fracStr := fmt.Sprintf("%0*s", tokenDecimals, remainder.String())
|
||||
fracStr = strings.TrimRight(fracStr, "0")
|
||||
const maxDisplayDecimals = 4
|
||||
if len(fracStr) > maxDisplayDecimals {
|
||||
fracStr = fracStr[:maxDisplayDecimals]
|
||||
}
|
||||
|
||||
return addThousandsSeparators(whole.String()) + "." + fracStr
|
||||
}
|
||||
|
||||
func addThousandsSeparators(s string) string {
|
||||
if len(s) <= 3 { //nolint:mnd
|
||||
return s
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
offset := len(s) % 3 //nolint:mnd
|
||||
if offset > 0 {
|
||||
result.WriteString(s[:offset])
|
||||
}
|
||||
|
||||
for i := offset; i < len(s); i += 3 { //nolint:mnd
|
||||
if result.Len() > 0 {
|
||||
result.WriteByte(',')
|
||||
}
|
||||
result.WriteString(s[i : i+3]) //nolint:mnd
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
@@ -5,24 +5,27 @@ React + Vite frontend for the on-chain monitoring and alerting system.
|
||||
## Getting Started
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Run the development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. Build for production:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
4. Preview production build:
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
@@ -34,5 +37,5 @@ React + Vite frontend for the on-chain monitoring and alerting system.
|
||||
|
||||
## Development
|
||||
|
||||
The frontend runs on port 3000 and proxies API requests to the backend running on port 5000.
|
||||
|
||||
The frontend runs on port 3000 and proxies API requests to the backend running
|
||||
on port 5000.
|
||||
|
||||
5767
frontend/package-lock.json
generated
5767
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,32 +1,36 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "1.0.0",
|
||||
"description": "koin_ping blockchain monoitor UI",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"keywords": [
|
||||
"cryptocurrency",
|
||||
"blockchain",
|
||||
"blockchain event moitoring"
|
||||
],
|
||||
"author": "Steven Jannette",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"firebase": "^12.7.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-router-dom": "^7.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.0"
|
||||
}
|
||||
"name": "frontend",
|
||||
"version": "1.0.0",
|
||||
"description": "koin_ping blockchain monitor UI",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vite build",
|
||||
"lint": "prettier --check 'src/**/*.{js,jsx,ts,tsx,css}'",
|
||||
"fmt": "prettier --write 'src/**/*.{js,jsx,ts,tsx,css}'"
|
||||
},
|
||||
"keywords": [
|
||||
"cryptocurrency",
|
||||
"blockchain",
|
||||
"blockchain event monitoring"
|
||||
],
|
||||
"author": "Steven Jannette",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"firebase": "^12.7.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-router-dom": "^7.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.0"
|
||||
}
|
||||
}
|
||||
4
frontend/prettierrc
Normal file
4
frontend/prettierrc
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"proseWrap": "always"
|
||||
}
|
||||
BIN
frontend/public/koin_exp_2.mp4
Normal file
BIN
frontend/public/koin_exp_2.mp4
Normal file
Binary file not shown.
BIN
frontend/public/koin_spin.mp4
Normal file
BIN
frontend/public/koin_spin.mp4
Normal file
Binary file not shown.
BIN
frontend/public/ping.png
Normal file
BIN
frontend/public/ping.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@@ -1,60 +1,48 @@
|
||||
import { Routes, Route, Link, Navigate } from "react-router-dom";
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import { useAuth } from "./contexts/AuthContext";
|
||||
import Login from "./pages/Login";
|
||||
import Navbar from "./components/Navbar";
|
||||
import Login from "./pages/login/Login";
|
||||
import Signup from "./pages/Signup";
|
||||
import Addresses from "./pages/Addresses";
|
||||
import Alerts from "./pages/Alerts";
|
||||
import AlertHistory from "./pages/AlertHistory";
|
||||
import Subscribe from "./pages/subscribe/Subscribe";
|
||||
import Addresses from "./pages/addresses/Addresses";
|
||||
import Alerts from "./pages/alerts/Alerts";
|
||||
import AlertHistory from "./pages/alertHistory/AlertHistory";
|
||||
import Account from "./pages/user_account/Account";
|
||||
|
||||
export default function App() {
|
||||
const { currentUser, logout } = useAuth();
|
||||
const { currentUser, isSubscribed } = useAuth();
|
||||
|
||||
// Show login/signup routes if not authenticated
|
||||
if (!currentUser) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
<Route path="/subscribe" element={<Subscribe />} />
|
||||
<Route path="*" element={<Navigate to="/login" />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
// Show main app if authenticated
|
||||
return (
|
||||
<div style={{ padding: "1rem" }}>
|
||||
<nav style={{ marginBottom: "1rem", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<div>
|
||||
<Link to="/addresses">Addresses</Link>{" | "}
|
||||
<Link to="/alerts">Alerts</Link>{" | "}
|
||||
<Link to="/history">History</Link>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||
<span style={{ fontSize: "0.9rem", color: "#999" }}>
|
||||
{currentUser.email}
|
||||
</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
style={{
|
||||
padding: "0.5rem 1rem",
|
||||
fontSize: "0.9rem",
|
||||
backgroundColor: "#333",
|
||||
color: "white",
|
||||
border: "1px solid #555",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer"
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
if (!isSubscribed) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/subscribe" element={<Subscribe />} />
|
||||
<Route path="/account" element={<><Navbar /><Account /></>} />
|
||||
<Route path="*" element={<Navigate to="/subscribe" />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<Routes>
|
||||
<Route path="/" element={<Addresses />} />
|
||||
<Route path="/addresses" element={<Addresses />} />
|
||||
<Route path="/alerts" element={<Alerts />} />
|
||||
<Route path="/history" element={<AlertHistory />} />
|
||||
<Route path="/alertevents" element={<AlertHistory />} />
|
||||
<Route path="/account" element={<Account />} />
|
||||
<Route path="/subscribe" element={<Subscribe />} />
|
||||
<Route path="*" element={<Navigate to="/addresses" />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
25
frontend/src/api/account.js
Normal file
25
frontend/src/api/account.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { getAuthHeaders } from "./authHeaders";
|
||||
import { API_BASE } from "./config";
|
||||
|
||||
export async function getAccount() {
|
||||
const headers = await getAuthHeaders();
|
||||
const res = await fetch(`${API_BASE}/user/account`, { headers });
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.message || "Failed to load account");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createPortalSession() {
|
||||
const headers = await getAuthHeaders();
|
||||
const res = await fetch(`${API_BASE}/stripe/create-portal-session`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.message || "Failed to create portal session");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// API client for address management
|
||||
|
||||
import { getAuthHeaders, getAuthHeadersSimple } from './authHeaders';
|
||||
import { API_BASE } from './config';
|
||||
import { getAuthHeaders, getAuthHeadersSimple } from "./authHeaders";
|
||||
import { API_BASE } from "./config";
|
||||
|
||||
/**
|
||||
* Create a new blockchain address to monitor
|
||||
@@ -11,35 +11,37 @@ import { API_BASE } from './config';
|
||||
* @returns {Promise<Object>} Created address with id and metadata
|
||||
*/
|
||||
export async function createAddress(data) {
|
||||
try {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await fetch(`${API_BASE}/addresses`, {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
try {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await fetch(`${API_BASE}/addresses`, {
|
||||
method: "POST",
|
||||
headers: headers,
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Try to parse error response, but handle if it's not JSON
|
||||
let errorMessage = 'Failed to create address';
|
||||
try {
|
||||
const error = await response.json();
|
||||
errorMessage = error.message || errorMessage;
|
||||
} catch {
|
||||
// If JSON parse fails, use status text
|
||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
if (!response.ok) {
|
||||
// Try to parse error response, but handle if it's not JSON
|
||||
let errorMessage = "Failed to create address";
|
||||
try {
|
||||
const error = await response.json();
|
||||
errorMessage = error.message || errorMessage;
|
||||
} catch {
|
||||
// If JSON parse fails, use status text
|
||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
// Handle network errors
|
||||
if (error.message.includes('fetch')) {
|
||||
throw new Error('Cannot connect to server. Is the backend running?');
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
// Handle network errors
|
||||
if (error.message.includes("fetch")) {
|
||||
throw new Error(
|
||||
"Cannot connect to server. Is the backend running?",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,33 +49,70 @@ export async function createAddress(data) {
|
||||
* @returns {Promise<Array>} List of all addresses
|
||||
*/
|
||||
export async function getAddresses() {
|
||||
try {
|
||||
const headers = await getAuthHeadersSimple();
|
||||
const response = await fetch(`${API_BASE}/addresses`, {
|
||||
headers: headers
|
||||
});
|
||||
try {
|
||||
const headers = await getAuthHeadersSimple();
|
||||
const response = await fetch(`${API_BASE}/addresses`, {
|
||||
headers: headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Try to parse error response, but handle if it's not JSON
|
||||
let errorMessage = 'Failed to fetch addresses';
|
||||
try {
|
||||
const error = await response.json();
|
||||
errorMessage = error.message || errorMessage;
|
||||
} catch {
|
||||
// If JSON parse fails, use status text
|
||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
if (!response.ok) {
|
||||
// Try to parse error response, but handle if it's not JSON
|
||||
let errorMessage = "Failed to fetch addresses";
|
||||
try {
|
||||
const error = await response.json();
|
||||
errorMessage = error.message || errorMessage;
|
||||
} catch {
|
||||
// If JSON parse fails, use status text
|
||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
// Handle network errors or JSON parse errors
|
||||
if (error.message.includes('fetch')) {
|
||||
throw new Error('Cannot connect to server. Is the backend running?');
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
// Handle network errors or JSON parse errors
|
||||
if (error.message.includes("fetch")) {
|
||||
throw new Error(
|
||||
"Cannot connect to server. Is the backend running?",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an address (e.g. change its label)
|
||||
* @param {number} addressId - Address ID to update
|
||||
* @param {Object} data - Fields to update (e.g. { label: "New Label" })
|
||||
* @returns {Promise<Object>} Updated address
|
||||
*/
|
||||
export async function updateAddress(addressId, data) {
|
||||
try {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await fetch(`${API_BASE}/addresses/${addressId}`, {
|
||||
method: "PATCH",
|
||||
headers: headers,
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = "Failed to update address";
|
||||
try {
|
||||
const error = await response.json();
|
||||
errorMessage = error.message || errorMessage;
|
||||
} catch {
|
||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
if (error.message.includes("fetch")) {
|
||||
throw new Error("Cannot connect to server. Is the backend running?");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,31 +121,32 @@ export async function getAddresses() {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function deleteAddress(addressId) {
|
||||
try {
|
||||
const headers = await getAuthHeadersSimple();
|
||||
const response = await fetch(`${API_BASE}/addresses/${addressId}`, {
|
||||
method: 'DELETE',
|
||||
headers: headers
|
||||
});
|
||||
try {
|
||||
const headers = await getAuthHeadersSimple();
|
||||
const response = await fetch(`${API_BASE}/addresses/${addressId}`, {
|
||||
method: "DELETE",
|
||||
headers: headers,
|
||||
});
|
||||
|
||||
if (!response.ok && response.status !== 204) {
|
||||
// Try to parse error response, but handle if it's not JSON
|
||||
let errorMessage = 'Failed to delete address';
|
||||
try {
|
||||
const error = await response.json();
|
||||
errorMessage = error.message || errorMessage;
|
||||
} catch {
|
||||
// If JSON parse fails, use status text
|
||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
if (!response.ok && response.status !== 204) {
|
||||
// Try to parse error response, but handle if it's not JSON
|
||||
let errorMessage = "Failed to delete address";
|
||||
try {
|
||||
const error = await response.json();
|
||||
errorMessage = error.message || errorMessage;
|
||||
} catch {
|
||||
// If JSON parse fails, use status text
|
||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle network errors
|
||||
if (error.message.includes("fetch")) {
|
||||
throw new Error(
|
||||
"Cannot connect to server. Is the backend running?",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle network errors
|
||||
if (error.message.includes('fetch')) {
|
||||
throw new Error('Cannot connect to server. Is the backend running?');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,35 +1,37 @@
|
||||
// API client for alert event history
|
||||
|
||||
import { getAuthHeadersSimple } from './authHeaders';
|
||||
import { API_BASE } from './config';
|
||||
import { getAuthHeadersSimple } from "./authHeaders";
|
||||
import { API_BASE } from "./config";
|
||||
|
||||
/**
|
||||
* Get all alert events (history)
|
||||
* @returns {Promise<Array>} List of alert events, sorted by most recent first
|
||||
*/
|
||||
export async function getAlertEvents() {
|
||||
try {
|
||||
const headers = await getAuthHeadersSimple();
|
||||
const response = await fetch(`${API_BASE}/alert-events`, {
|
||||
headers: headers
|
||||
});
|
||||
try {
|
||||
const headers = await getAuthHeadersSimple();
|
||||
const response = await fetch(`${API_BASE}/alert-events`, {
|
||||
headers: headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'Failed to fetch alert events';
|
||||
try {
|
||||
const error = await response.json();
|
||||
errorMessage = error.message || errorMessage;
|
||||
} catch {
|
||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
if (!response.ok) {
|
||||
let errorMessage = "Failed to fetch alert events";
|
||||
try {
|
||||
const error = await response.json();
|
||||
errorMessage = error.message || errorMessage;
|
||||
} catch {
|
||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
if (error.message.includes('fetch')) {
|
||||
throw new Error('Cannot connect to server. Is the backend running?');
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
if (error.message.includes("fetch")) {
|
||||
throw new Error(
|
||||
"Cannot connect to server. Is the backend running?",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
// API client for alert rule management
|
||||
|
||||
import { getAuthHeaders, getAuthHeadersSimple } from './authHeaders';
|
||||
import { API_BASE } from './config';
|
||||
import { getAuthHeaders, getAuthHeadersSimple } from "./authHeaders";
|
||||
import { API_BASE } from "./config";
|
||||
|
||||
/**
|
||||
* Alert types supported by the system
|
||||
*/
|
||||
export const ALERT_TYPES = {
|
||||
INCOMING_TX: 'incoming_tx',
|
||||
OUTGOING_TX: 'outgoing_tx',
|
||||
LARGE_TRANSFER: 'large_transfer',
|
||||
BALANCE_BELOW: 'balance_below'
|
||||
INCOMING_TX: "incoming_tx",
|
||||
OUTGOING_TX: "outgoing_tx",
|
||||
LARGE_TRANSFER: "large_transfer",
|
||||
BALANCE_BELOW: "balance_below",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -22,32 +22,37 @@ export const ALERT_TYPES = {
|
||||
* @returns {Promise<Object>} Created alert rule
|
||||
*/
|
||||
export async function createAlert(addressId, data) {
|
||||
try {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await fetch(`${API_BASE}/addresses/${addressId}/alerts`, {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
try {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await fetch(
|
||||
`${API_BASE}/addresses/${addressId}/alerts`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: headers,
|
||||
body: JSON.stringify(data),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'Failed to create alert rule';
|
||||
try {
|
||||
const error = await response.json();
|
||||
errorMessage = error.message || errorMessage;
|
||||
} catch {
|
||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
if (!response.ok) {
|
||||
let errorMessage = "Failed to create alert rule";
|
||||
try {
|
||||
const error = await response.json();
|
||||
errorMessage = error.message || errorMessage;
|
||||
} catch {
|
||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
if (error.message.includes('fetch')) {
|
||||
throw new Error('Cannot connect to server. Is the backend running?');
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
if (error.message.includes("fetch")) {
|
||||
throw new Error(
|
||||
"Cannot connect to server. Is the backend running?",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,30 +61,35 @@ export async function createAlert(addressId, data) {
|
||||
* @returns {Promise<Array>} List of alert rules
|
||||
*/
|
||||
export async function getAlerts(addressId) {
|
||||
try {
|
||||
const headers = await getAuthHeadersSimple();
|
||||
const response = await fetch(`${API_BASE}/addresses/${addressId}/alerts`, {
|
||||
headers: headers
|
||||
});
|
||||
try {
|
||||
const headers = await getAuthHeadersSimple();
|
||||
const response = await fetch(
|
||||
`${API_BASE}/addresses/${addressId}/alerts`,
|
||||
{
|
||||
headers: headers,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'Failed to fetch alert rules';
|
||||
try {
|
||||
const error = await response.json();
|
||||
errorMessage = error.message || errorMessage;
|
||||
} catch {
|
||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
if (!response.ok) {
|
||||
let errorMessage = "Failed to fetch alert rules";
|
||||
try {
|
||||
const error = await response.json();
|
||||
errorMessage = error.message || errorMessage;
|
||||
} catch {
|
||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
if (error.message.includes('fetch')) {
|
||||
throw new Error('Cannot connect to server. Is the backend running?');
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
if (error.message.includes("fetch")) {
|
||||
throw new Error(
|
||||
"Cannot connect to server. Is the backend running?",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,32 +99,76 @@ export async function getAlerts(addressId) {
|
||||
* @returns {Promise<Object>} Updated alert rule
|
||||
*/
|
||||
export async function updateAlertStatus(alertId, enabled) {
|
||||
try {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await fetch(`${API_BASE}/alerts/${alertId}`, {
|
||||
method: 'PATCH',
|
||||
headers: headers,
|
||||
body: JSON.stringify({ enabled })
|
||||
});
|
||||
try {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await fetch(`${API_BASE}/alerts/${alertId}`, {
|
||||
method: "PATCH",
|
||||
headers: headers,
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'Failed to update alert rule';
|
||||
try {
|
||||
const error = await response.json();
|
||||
errorMessage = error.message || errorMessage;
|
||||
} catch {
|
||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
if (!response.ok) {
|
||||
let errorMessage = "Failed to update alert rule";
|
||||
try {
|
||||
const error = await response.json();
|
||||
errorMessage = error.message || errorMessage;
|
||||
} catch {
|
||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
if (error.message.includes('fetch')) {
|
||||
throw new Error('Cannot connect to server. Is the backend running?');
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
if (error.message.includes("fetch")) {
|
||||
throw new Error(
|
||||
"Cannot connect to server. Is the backend running?",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the min/max thresholds on an alert rule
|
||||
* @param {number} alertId - Alert rule ID
|
||||
* @param {number|null} minimum - Minimum amount (null to clear)
|
||||
* @param {number|null} maximum - Maximum amount (null to clear)
|
||||
* @returns {Promise<Object>} Updated alert rule
|
||||
*/
|
||||
export async function updateAlertThresholds(alertId, minimum, maximum) {
|
||||
try {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await fetch(`${API_BASE}/alerts/${alertId}`, {
|
||||
method: "PATCH",
|
||||
headers: headers,
|
||||
body: JSON.stringify({
|
||||
update_min_max: true,
|
||||
minimum: minimum ?? null,
|
||||
maximum: maximum ?? null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = "Failed to update alert thresholds";
|
||||
try {
|
||||
const error = await response.json();
|
||||
errorMessage = error.message || errorMessage;
|
||||
} catch {
|
||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
if (error.message.includes("fetch")) {
|
||||
throw new Error(
|
||||
"Cannot connect to server. Is the backend running?",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,27 +177,29 @@ export async function updateAlertStatus(alertId, enabled) {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function deleteAlert(alertId) {
|
||||
try {
|
||||
const headers = await getAuthHeadersSimple();
|
||||
const response = await fetch(`${API_BASE}/alerts/${alertId}`, {
|
||||
method: 'DELETE',
|
||||
headers: headers
|
||||
});
|
||||
try {
|
||||
const headers = await getAuthHeadersSimple();
|
||||
const response = await fetch(`${API_BASE}/alerts/${alertId}`, {
|
||||
method: "DELETE",
|
||||
headers: headers,
|
||||
});
|
||||
|
||||
if (!response.ok && response.status !== 204) {
|
||||
let errorMessage = 'Failed to delete alert rule';
|
||||
try {
|
||||
const error = await response.json();
|
||||
errorMessage = error.message || errorMessage;
|
||||
} catch {
|
||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
if (!response.ok && response.status !== 204) {
|
||||
let errorMessage = "Failed to delete alert rule";
|
||||
try {
|
||||
const error = await response.json();
|
||||
errorMessage = error.message || errorMessage;
|
||||
} catch {
|
||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.message.includes("fetch")) {
|
||||
throw new Error(
|
||||
"Cannot connect to server. Is the backend running?",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.message.includes('fetch')) {
|
||||
throw new Error('Cannot connect to server. Is the backend running?');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,26 +5,26 @@
|
||||
* Includes Firebase ID token in Authorization header
|
||||
*/
|
||||
|
||||
import { auth } from '../firebase/config';
|
||||
import { auth } from "../firebase/config";
|
||||
|
||||
/**
|
||||
* Get headers with authentication token
|
||||
* @returns {Promise<Object>} Headers object with Authorization
|
||||
*/
|
||||
export async function getAuthHeaders() {
|
||||
const currentUser = auth.currentUser;
|
||||
const currentUser = auth.currentUser;
|
||||
|
||||
if (!currentUser) {
|
||||
throw new Error('No authenticated user');
|
||||
}
|
||||
if (!currentUser) {
|
||||
throw new Error("No authenticated user");
|
||||
}
|
||||
|
||||
// Get Firebase ID token
|
||||
const token = await currentUser.getIdToken();
|
||||
// Get Firebase ID token
|
||||
const token = await currentUser.getIdToken();
|
||||
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
};
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,16 +32,15 @@ export async function getAuthHeaders() {
|
||||
* @returns {Promise<Object>} Headers object with Authorization
|
||||
*/
|
||||
export async function getAuthHeadersSimple() {
|
||||
const currentUser = auth.currentUser;
|
||||
const currentUser = auth.currentUser;
|
||||
|
||||
if (!currentUser) {
|
||||
throw new Error('No authenticated user');
|
||||
}
|
||||
if (!currentUser) {
|
||||
throw new Error("No authenticated user");
|
||||
}
|
||||
|
||||
const token = await currentUser.getIdToken();
|
||||
const token = await currentUser.getIdToken();
|
||||
|
||||
return {
|
||||
'Authorization': `Bearer ${token}`
|
||||
};
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const API_BASE = import.meta.env.VITE_API_BASE || '/v1';
|
||||
export const API_BASE = import.meta.env.VITE_API_BASE || "/v1";
|
||||
|
||||
@@ -1,116 +1,183 @@
|
||||
import { getAuthHeaders } from './authHeaders';
|
||||
import { API_BASE } from './config';
|
||||
import { getAuthHeaders } from "./authHeaders";
|
||||
import { API_BASE } from "./config";
|
||||
|
||||
/**
|
||||
* Get notification configuration for current user
|
||||
* @returns {Promise<Object>} Notification config
|
||||
*/
|
||||
export async function getNotificationConfig() {
|
||||
try {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await fetch(`${API_BASE}/notification-config`, {
|
||||
headers: headers
|
||||
});
|
||||
try {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await fetch(`${API_BASE}/notification-config`, {
|
||||
headers: headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'Failed to fetch notification config';
|
||||
try {
|
||||
const error = await response.json();
|
||||
errorMessage = error.message || errorMessage;
|
||||
} catch {
|
||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
if (!response.ok) {
|
||||
let errorMessage = "Failed to fetch notification config";
|
||||
try {
|
||||
const error = await response.json();
|
||||
errorMessage = error.message || errorMessage;
|
||||
} catch {
|
||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
if (error.message.includes('fetch')) {
|
||||
throw new Error('Cannot connect to server. Is the backend running?');
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
if (error.message.includes("fetch")) {
|
||||
throw new Error(
|
||||
"Cannot connect to server. Is the backend running?",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update notification configuration
|
||||
* @param {Object} config - Configuration to update
|
||||
* @param {string} [config.discord_webhook_url] - Discord webhook URL
|
||||
* @param {string} [config.slack_webhook_url] - Slack webhook URL
|
||||
* @param {string} [config.email] - Email address
|
||||
* @param {boolean} [config.notification_enabled] - Enable/disable notifications
|
||||
* @param {Object} config
|
||||
* @param {string} [config.discord_webhook_url]
|
||||
* @param {string} [config.telegram_chat_id]
|
||||
* @param {string} [config.telegram_bot_token]
|
||||
* @param {string} [config.email]
|
||||
* @param {string} [config.slack_webhook_url]
|
||||
* @param {boolean} [config.notification_enabled]
|
||||
* @returns {Promise<Object>} Updated config
|
||||
*/
|
||||
export async function updateNotificationConfig(config) {
|
||||
try {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await fetch(`${API_BASE}/notification-config`, {
|
||||
method: 'PUT',
|
||||
headers: headers,
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
try {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await fetch(`${API_BASE}/notification-config`, {
|
||||
method: "PUT",
|
||||
headers: headers,
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'Failed to update notification config';
|
||||
try {
|
||||
const error = await response.json();
|
||||
errorMessage = error.message || errorMessage;
|
||||
} catch {
|
||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
if (!response.ok) {
|
||||
let errorMessage = "Failed to update notification config";
|
||||
try {
|
||||
const error = await response.json();
|
||||
errorMessage = error.message || errorMessage;
|
||||
} catch {
|
||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
if (error.message.includes('fetch')) {
|
||||
throw new Error('Cannot connect to server. Is the backend running?');
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
if (error.message.includes("fetch")) {
|
||||
throw new Error(
|
||||
"Cannot connect to server. Is the backend running?",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test a webhook URL by sending a test message via the backend
|
||||
* @param {string} type - 'discord' or 'slack'
|
||||
* @param {string} webhookUrl - Webhook URL to test
|
||||
* @returns {Promise<boolean>} True if test successful
|
||||
* Set up email notifications via Resend.
|
||||
* Reads the user's saved email from their config and sends a confirmation.
|
||||
* @returns {Promise<Object>} Setup result
|
||||
*/
|
||||
async function testWebhook(type, webhookUrl) {
|
||||
try {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await fetch(`${API_BASE}/notification-config/test`, {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: JSON.stringify({ type, url: webhookUrl })
|
||||
});
|
||||
export async function setupEmail() {
|
||||
try {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await fetch(`${API_BASE}/email/setup`, {
|
||||
method: "POST",
|
||||
headers: headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
if (!response.ok) {
|
||||
let errorMessage = "Failed to set up email";
|
||||
try {
|
||||
const error = await response.json();
|
||||
errorMessage = error.message || errorMessage;
|
||||
} catch {
|
||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
if (error.message.includes("fetch")) {
|
||||
throw new Error(
|
||||
"Cannot connect to server. Is the backend running?",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result.success === true;
|
||||
} catch (error) {
|
||||
console.error(`${type} webhook test failed:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test a Discord webhook URL
|
||||
* @param {string} webhookUrl - Discord webhook URL to test
|
||||
* @returns {Promise<boolean>} True if test successful
|
||||
* Send an alert digest email to the user's configured email address.
|
||||
* @returns {Promise<Object>} Digest send result
|
||||
*/
|
||||
export async function testDiscordWebhook(webhookUrl) {
|
||||
return testWebhook('discord', webhookUrl);
|
||||
export async function sendEmailDigest() {
|
||||
try {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await fetch(`${API_BASE}/email/digest`, {
|
||||
method: "POST",
|
||||
headers: headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = "Failed to send digest";
|
||||
try {
|
||||
const error = await response.json();
|
||||
errorMessage = error.message || errorMessage;
|
||||
} catch {
|
||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
if (error.message.includes("fetch")) {
|
||||
throw new Error(
|
||||
"Cannot connect to server. Is the backend running?",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test a Slack webhook URL
|
||||
* @param {string} webhookUrl - Slack webhook URL to test
|
||||
* @returns {Promise<boolean>} True if test successful
|
||||
* Test all configured notification channels via the backend
|
||||
* @returns {Promise<Object>} Results per channel
|
||||
*/
|
||||
export async function testSlackWebhook(webhookUrl) {
|
||||
return testWebhook('slack', webhookUrl);
|
||||
export async function testNotificationChannels() {
|
||||
try {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await fetch(
|
||||
`${API_BASE}/notification-config/test`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: headers,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = "Failed to test notification channels";
|
||||
try {
|
||||
const error = await response.json();
|
||||
errorMessage = error.message || errorMessage;
|
||||
} catch {
|
||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
if (error.message.includes("fetch")) {
|
||||
throw new Error(
|
||||
"Cannot connect to server. Is the backend running?",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user