Compare commits

..

1 Commits

Author SHA1 Message Date
KS Jannette
c99d31feff going full courtside 2026-02-27 17:33:40 -05:00
130 changed files with 6620 additions and 10514 deletions

View File

@@ -1,3 +0,0 @@
.git
node_modules
.DS_Store

View File

@@ -1,15 +0,0 @@
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

View File

@@ -1,9 +0,0 @@
name: check
on: [push]
jobs:
check:
runs-on: ubuntu-latest
steps:
# actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: docker build .

45
.gitignore vendored
View File

@@ -1,45 +0,0 @@
# 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/
*.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

View File

@@ -1,7 +0,0 @@
node_modules/
frontend/dist/
frontend/build/
backend/bin/
*.lock
Prompts/
.claude/

View File

@@ -1,4 +0,0 @@
{
"tabWidth": 4,
"proseWrap": "always"
}

View File

@@ -1,22 +0,0 @@
# 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

21
LICENSE
View File

@@ -1,21 +0,0 @@
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.

View File

@@ -1,90 +0,0 @@
.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
View File

@@ -1,156 +1,95 @@
# Koin Ping # Koin Ping
A lightweight on-chain monitoring and alerting system designed to give users situational awareness over blockchain addresses they care about. A lightweight on-chain monitoring and alerting system designed to give users situational awareness over blockchain addresses they care about.
# Overview ## Overview
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. 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
## Getting Started ## Getting Started
### Prerequisites ### Prerequisites
- Node.js (v18 or higher recommended)
- PostgreSQL database
- npm or yarn
- Go 1.24+ ### Backend Setup
- Node.js 18+ and npm (or yarn — preferred per repo policy)
- PostgreSQL 15+
- `golangci-lint` v2 (for `make lint`)
### Setup 1. Navigate to the backend directory:
```bash
```bash cd backend
# 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
``` ```
4. In the JSON response, find the `"chat"` object — the `"id"` field is your **Chat ID** (a numeric value). 2. Install dependencies (already done):
```bash
npm install
```
> **Tip:** If the `'result"` array is empty, make sure you sent a message to your bot first, then refresh the page. 3. Create a `.env` file (see backend/README.md for required variables)
#### 3. Save in Koin Ping 4. Start the development server:
```bash
npm run dev
```
1. Go to the **Alerts** page in Koin Ping. The backend will run on `http://localhost:3001`
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
*Guides for Discord, Slack, and Email coming soon.* 1. Navigate to the frontend directory:
```bash
cd frontend
```
## License 2. Install dependencies (already done):
```bash
MIT. See [LICENSE](LICENSE). npm install
```
## Author
Steven Jannette
3. Start the development server:
```bash
npm run dev
```
The frontend will run on `http://localhost:3000`

View File

@@ -1,187 +0,0 @@
---
title: Repository Policies
last_modified: 2026-02-22
---
This document covers repository structure, tooling, and workflow standards. Code
style conventions are in separate documents:
- [Code Styleguide](<link>)
(general, bash, Docker)
- [Go](<link>)
- [JavaScript](<link>)
- [Python](<link>)
- [Go HTTP Server Conventions](<link>)
---
- Cross-project documentation (such as this file) must include
`last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync
with the authoritative source as policies evolve.
- **ALL external references must be pinned by cryptographic hash.** This
includes Docker base images, Go modules, npm packages, GitHub Actions, and
anything else fetched from a remote source. Version tags (`@v4`, `@latest`,
`:3.21`, etc.) are server-mutable and therefore remote code execution
vulnerabilities. The ONLY acceptable way to reference an external dependency
is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm
integrity hash in lockfile, GitHub Actions `@<commit-sha>`). No exceptions.
This also means never `curl | bash` to install tools like pyenv, nvm, rustup,
etc. Instead, download a specific release archive from GitHub, verify its hash
(hardcoded in the Dockerfile or script), and only then install. Unverified
install scripts are arbitrary remote code execution. This is the single most
important rule in this document. Double-check every external reference in
every file before committing. There are zero exceptions to this rule.
- Every repo with software must have a root `Makefile` with these targets:
`make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only),
`make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and
`make hooks` (installs pre-commit hook). A model Makefile is at
`(<link>)`.
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
instead of invoking the underlying tools directly. The Makefile is the single
source of truth for how these operations are run.
- The Makefile is authoritative documentation for how the repo is used. Beyond
the required targets above, it should have targets for every common operation:
running a local development server (`make run`, `make dev`), re-initializing
or migrating the database (`make db-reset`, `make migrate`), building
artifacts (`make build`), generating code, seeding data, or anything else a
developer would do regularly. If someone checks out the repo and types
`make<tab>`, they should see every meaningful operation available. A new
contributor should be able to understand the entire development workflow by
reading the Makefile.
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
as a build step so the build fails if the branch is not green. For non-server
repos, the Dockerfile should bring up a development environment and run
`make check`. For server repos, `make check` should run as an early build
stage before the final image is assembled.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `docker build .` on push. Since the Dockerfile already runs `make check`,
a successful build implies all checks pass.
- Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
two exceptions: four-space indents (except Go), and `proseWrap: always` for
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
- Pre-commit hook: `make check` if local testing is possible, otherwise
`make lint && make fmt-check`. The Makefile should provide a `make hooks`
target to install the pre-commit hook.
- All repos with software must have tests that run via the platform-standard
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
tests exist yet, add the most minimal test possible — e.g. importing the
module under test to verify it compiles/parses. There is no excuse for
`make test` to be a no-op.
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
Makefile.
- Docker builds must complete in under 5 minutes.
- `make check` must not modify any files in the repo. Tests may use temporary
directories.
- `main` must always pass `make check`, no exceptions.
- Never commit secrets. `.env` files, credentials, API keys, and private keys
must be in `.gitignore`. No exceptions.
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
Fetch the standard `.gitignore` from
`(<link>) when setting up
a new repo.
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
- Never force-push to `main`.
- Make all changes on a feature branch. You can do whatever you want on a
feature branch.
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
manually by the user. Fetch from
`(<link>)`.
- When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD).
- Use `yarn`, not `npm`.
- Write all dates as YYYY-MM-DD (ISO 8601).
- Simple projects should be configured with environment variables.
- Dockerized web services listen on port 8080 by default, overridable with
`PORT`.
- `README.md` is the primary documentation. Required sections:
- **Description**: First line must include the project name, purpose,
category (web server, SPA, CLI tool, etc.), license, and author. Example:
"µPaaS is an MIT-licensed Go web application by @sjdev that receives
git-frontend webhooks and deploys applications via Docker in realtime."
- **Getting Started**: Copy-pasteable install/usage code block.
- **Rationale**: Why does this exist?
- **Design**: How is the program structured?
- **TODO**: Update meticulously, even between commits. When planning, put
the todo list in the README so a new agent can pick up where the last one
left off.
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a
`LICENSE` file in the repo root and a License section in the README.
- **Author**: [@sjdev](https://sjdev.co).
- First commit of a new repo should contain only `README.md`.
- Always run `go mod tidy` before committing.
- Use SemVer.
- Database migrations live in `internal/db/migrations/` and must be embedded in
the binary.
- `000_migration.sql` — contains ONLY the creation of the migrations
tracking table itself. Nothing else.
- `001_schema.sql` — the full application schema.
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
There is no installed base to migrate. Edit `001_schema.sql` directly.
- **Post-1.0.0:** add new numbered migration files for each schema change.
Never edit existing migrations after release.
- All repos should have an `.editorconfig` enforcing the project's indentation
settings.
- Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
language-specific config). Everything else goes in a subdirectory. Canonical
subdirectory names:
- `bin/` — executable scripts and tools
- `cmd/` — Go command entrypoints
- `configs/` — configuration templates and examples
- `deploy/` — deployment manifests (k8s, compose, terraform)
- `docs/` — documentation and markdown (README.md stays in root)
- `internal/` — Go internal packages
- `internal/db/migrations/` — database migrations
- `pkg/` — Go library packages
- `share/` — systemd units, data files
- `static/` — static assets (images, fonts, etc.)
- `web/` — web frontend source
- When setting up a new repo, files from the `prompts` repo may be used as
templates. Fetch them from
`(<link>)<path>`.
- New repos must contain at minimum:
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
- `Makefile`
- `Dockerfile`, `.dockerignore`
- `.gitea/workflows/check.yml`
- Go: `go.mod`, `go.sum`, `.golangci.yml`
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
- Python: `pyproject.toml`

16
backend-go/.env.example Normal file
View File

@@ -0,0 +1,16 @@
# 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

View File

@@ -37,20 +37,16 @@ cp ENV_TEMPLATE.md .env
## Firebase Setup ## Firebase Setup
For Firebase Admin SDK to work, you need to set up Application Default For Firebase Admin SDK to work, you need to set up Application Default Credentials:
Credentials:
**Option 1: Use Firebase Project ID (easiest for development)** **Option 1: Use Firebase Project ID (easiest for development)**
- Just set `FIREBASE_PROJECT_ID` in .env - Just set `FIREBASE_PROJECT_ID` in .env
- Firebase Admin will use Application Default Credentials - Firebase Admin will use Application Default Credentials
**Option 2: Use Service Account Key (production)** **Option 2: Use Service Account Key (production)**
1. Go to Firebase Console > Project Settings > Service Accounts 1. Go to Firebase Console > Project Settings > Service Accounts
2. Click "Generate new private key" 2. Click "Generate new private key"
3. Download the JSON file 3. Download the JSON file
4. Either: 4. Either:
- Set `GOOGLE_APPLICATION_CREDENTIALS=/path/to/serviceAccountKey.json` in - Set `GOOGLE_APPLICATION_CREDENTIALS=/path/to/serviceAccountKey.json` in .env
.env - Or keep it in backend/ and add to .gitignore
- Or keep it in backend/ and add to .gitignore

4
backend-go/README.md Normal file
View File

@@ -0,0 +1,4 @@
Run Backend:
cd /Users/kjannette/workspace/koin_ping/backend-go
go run ./cmd/api

105
backend-go/cmd/api/main.go Normal file
View File

@@ -0,0 +1,105 @@
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)
})
}

View File

@@ -1,4 +1,3 @@
// Package main is the entry point for the blockchain observer poller.
package main package main
import ( import (
@@ -12,21 +11,14 @@ import (
"time" "time"
"github.com/joho/godotenv" "github.com/joho/godotenv"
"github.com/kjannette/koin-ping/backend/internal/config" "github.com/kjannette/koin-ping/backend-go/internal/config"
"github.com/kjannette/koin-ping/backend/internal/database" "github.com/kjannette/koin-ping/backend-go/internal/database"
"github.com/kjannette/koin-ping/backend/internal/models" "github.com/kjannette/koin-ping/backend-go/internal/models"
"github.com/kjannette/koin-ping/backend/internal/protocols/ethereum" "github.com/kjannette/koin-ping/backend-go/internal/notifications"
"github.com/kjannette/koin-ping/backend/internal/services" "github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
"github.com/kjannette/koin-ping/backend-go/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() { func main() {
_ = godotenv.Load() _ = godotenv.Load()
@@ -43,26 +35,29 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("Failed to connect to database: %v", err) log.Fatalf("Failed to connect to database: %v", err)
} }
defer database.Close()
eth, err := ethereum.NewJsonRpcEthereum(cfg.EthRPCURL) eth, err := ethereum.NewJsonRpcEthereum(cfg.EthRPCURL)
if err != nil { if err != nil {
log.Fatalf("Failed to create Ethereum observer: %v", err) log.Fatalf("Failed to create Ethereum observer: %v", err)
} }
defer database.Close()
addressModel := models.NewAddressModel(pool) addressModel := models.NewAddressModel(pool)
alertRuleModel := models.NewAlertRuleModel(pool) alertRuleModel := models.NewAlertRuleModel(pool)
alertEventModel := models.NewAlertEventModel(pool) alertEventModel := models.NewAlertEventModel(pool)
checkpointModel := models.NewCheckpointModel(pool) checkpointModel := models.NewCheckpointModel(pool)
notifConfigModel := models.NewNotificationConfigModel(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) observer := services.NewObserverService(eth, addressModel, checkpointModel)
evaluator := services.NewEvaluatorService( evaluator := services.NewEvaluatorService(eth, alertRuleModel, alertEventModel, addressModel, notifConfigModel, smtpCfg)
eth, alertRuleModel, alertEventModel, addressModel, notifConfigModel,
cfg.ResendAPIKey, cfg.EmailFrom,
)
digestSvc := services.NewEmailDigestService(cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
@@ -72,74 +67,53 @@ func main() {
go func() { go func() {
<-sigCh <-sigCh
log.Println() log.Println()
log.Println(strings.Repeat("=", separatorWidth)) log.Println(strings.Repeat("=", 60))
log.Println("Shutting down poller gracefully...") log.Println("Shutting down poller gracefully...")
log.Println(strings.Repeat("=", separatorWidth)) log.Println(strings.Repeat("=", 60))
cancel() cancel()
}() }()
interval := time.Duration(cfg.PollIntervalMS) * time.Millisecond interval := time.Duration(cfg.PollIntervalMS) * time.Millisecond
digestInterval := time.Duration(cfg.DigestIntervalHours) * time.Hour
log.Println(strings.Repeat("=", separatorWidth)) log.Println(strings.Repeat("=", 60))
log.Println("Koin Ping Observer Poller Starting") log.Println("Koin Ping Observer Poller Starting")
log.Println(strings.Repeat("=", separatorWidth)) log.Println(strings.Repeat("=", 60))
log.Printf("RPC URL: %s", cfg.EthRPCURL) log.Printf("RPC URL: %s", cfg.EthRPCURL)
log.Printf("Poll Interval: %dms (%ds)", cfg.PollIntervalMS, cfg.PollIntervalMS/msPerSecond) log.Printf("Poll Interval: %dms (%ds)", cfg.PollIntervalMS, cfg.PollIntervalMS/1000)
log.Printf("Digest Interval: %dh", cfg.DigestIntervalHours) log.Println(strings.Repeat("=", 60))
log.Println(strings.Repeat("=", separatorWidth))
runCycle(ctx, observer, evaluator) runCycle(ctx, observer, evaluator)
ticker := time.NewTicker(interval) ticker := time.NewTicker(interval)
defer ticker.Stop() defer ticker.Stop()
digestTicker := time.NewTicker(digestInterval)
defer digestTicker.Stop()
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
log.Println("Poller stopped") log.Println("Poller stopped")
return return
case <-ticker.C: case <-ticker.C:
runCycle(ctx, observer, evaluator) 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( func runCycle(ctx context.Context, observer *services.ObserverService, evaluator *services.EvaluatorService) {
ctx context.Context,
observer *services.ObserverService,
evaluator *services.EvaluatorService,
) {
startTime := time.Now() startTime := time.Now()
log.Printf("[%s] Starting observation cycle...", time.Now().UTC().Format(time.RFC3339)) log.Printf("[%s] Starting observation cycle...", time.Now().UTC().Format(time.RFC3339))
observations, err := observer.RunOnce(ctx) observations, err := observer.RunOnce(ctx)
if err != nil { if err != nil {
log.Printf("[%s] Observation cycle failed: %v", time.Now().UTC().Format(time.RFC3339), err) log.Printf("[%s] Observation cycle failed: %v", time.Now().UTC().Format(time.RFC3339), err)
return return
} }
alertsFired, err := evaluator.Evaluate(ctx, observations) alertsFired, err := evaluator.Evaluate(ctx, observations)
if err != nil { if err != nil {
log.Printf("[%s] Evaluation failed: %v", time.Now().UTC().Format(time.RFC3339), err) log.Printf("[%s] Evaluation failed: %v", time.Now().UTC().Format(time.RFC3339), err)
return return
} }
evaluator.WaitForNotifications()
duration := time.Since(startTime) duration := time.Since(startTime)
log.Printf("[%s] Cycle complete: %d observations, %d alerts fired in %s", log.Printf("[%s] Cycle complete: %d observations, %d alerts fired in %s",
time.Now().UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339),

View File

@@ -1,4 +1,4 @@
module github.com/kjannette/koin-ping/backend module github.com/kjannette/koin-ping/backend-go
go 1.25.0 go 1.25.0
@@ -42,9 +42,7 @@ require (
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/joho/godotenv v1.5.1 // indirect github.com/joho/godotenv v1.5.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // 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/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/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // 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 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect

View File

@@ -92,8 +92,6 @@ 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.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 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= 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= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -101,8 +99,6 @@ 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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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= 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 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=

View File

@@ -1,2 +1,4 @@
ALTER TABLE user_notification_configs 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; ADD COLUMN IF NOT EXISTS slack_webhook_url TEXT;

View File

@@ -4,28 +4,15 @@ DROP TABLE IF EXISTS alert_rules CASCADE;
DROP TABLE IF EXISTS address_checkpoints CASCADE; DROP TABLE IF EXISTS address_checkpoints CASCADE;
DROP TABLE IF EXISTS user_notification_configs CASCADE; DROP TABLE IF EXISTS user_notification_configs CASCADE;
DROP TABLE IF EXISTS addresses 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_created_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE addresses ( CREATE TABLE addresses (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id VARCHAR(128) NOT NULL, -- Firebase user ID (multi-user support)
address VARCHAR(42) NOT NULL, -- Ethereum address format (0x + 40 hex chars) address VARCHAR(42) NOT NULL, -- Ethereum address format (0x + 40 hex chars)
label VARCHAR(255), -- Optional human-readable label label VARCHAR(255), -- Optional human-readable label
created_at TIMESTAMP DEFAULT NOW(), created_at TIMESTAMP DEFAULT NOW(),
-- Unique users can track the same address independently
UNIQUE(user_id, address) UNIQUE(user_id, address)
); );
@@ -64,20 +51,16 @@ CREATE TABLE address_checkpoints (
); );
CREATE TABLE user_notification_configs ( CREATE TABLE user_notification_configs (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, user_id VARCHAR(128) PRIMARY KEY,
discord_webhook_url TEXT, -- Discord webhook URL (nullable) discord_webhook_url TEXT, -- Discord 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) slack_webhook_url TEXT, -- Slack incoming webhook URL (nullable)
email VARCHAR(255), -- Email for notifications (nullable)
notification_enabled BOOLEAN DEFAULT TRUE, -- Master on/off switch notification_enabled BOOLEAN DEFAULT TRUE, -- Master on/off switch
created_at TIMESTAMP DEFAULT NOW(), created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW() updated_at TIMESTAMP DEFAULT NOW()
); );
-- Create indexes for common queries -- 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_addresses_user_id ON addresses(user_id);
CREATE INDEX idx_alert_rules_address_id ON alert_rules(address_id); CREATE INDEX idx_alert_rules_address_id ON alert_rules(address_id);
CREATE INDEX idx_alert_rules_enabled ON alert_rules(enabled); CREATE INDEX idx_alert_rules_enabled ON alert_rules(enabled);

View File

@@ -0,0 +1,93 @@
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
}

View File

@@ -0,0 +1,115 @@
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)
}
}

View File

@@ -9,34 +9,20 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
) )
const ( var pool *pgxpool.Pool
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) { func Connect(dsn string) (*pgxpool.Pool, error) {
cfg, err := pgxpool.ParseConfig(dsn) cfg, err := pgxpool.ParseConfig(dsn)
if err != nil { if err != nil {
return nil, fmt.Errorf("parse database config: %w", err) return nil, fmt.Errorf("parse database config: %w", err)
} }
cfg.MaxConns = maxConns cfg.MaxConns = 20
cfg.MinConns = minConns cfg.MinConns = 2
cfg.MaxConnIdleTime = maxConnIdleSeconds * time.Second cfg.MaxConnIdleTime = 30 * time.Second
cfg.MaxConnLifetime = maxConnLifetimeMinutes * time.Minute cfg.MaxConnLifetime = 5 * time.Minute
ctx, cancel := context.WithTimeout(context.Background(), connectTimeoutSeconds*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
p, err := pgxpool.NewWithConfig(ctx, cfg) p, err := pgxpool.NewWithConfig(ctx, cfg)
@@ -46,13 +32,11 @@ func Connect(dsn string) (*pgxpool.Pool, error) {
if err := p.Ping(ctx); err != nil { if err := p.Ping(ctx); err != nil {
p.Close() p.Close()
return nil, fmt.Errorf("ping database: %w", err) return nil, fmt.Errorf("ping database: %w", err)
} }
log.Println("Connected to PostgreSQL database") log.Println("Connected to PostgreSQL database")
pool = p pool = p
return p, nil return p, nil
} }

View File

@@ -0,0 +1,114 @@
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"`
}

View File

@@ -0,0 +1,35 @@
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)
}
}
}

View File

@@ -10,10 +10,10 @@ import (
"google.golang.org/api/option" "google.golang.org/api/option"
) )
var ( //nolint:gochecknoglobals var (
authClient *auth.Client //nolint:gochecknoglobals authClient *auth.Client
once sync.Once //nolint:gochecknoglobals once sync.Once
errInit error //nolint:gochecknoglobals initErr error
) )
func Init(projectID string) error { func Init(projectID string) error {
@@ -30,20 +30,18 @@ func Init(projectID string) error {
app, err = fb.NewApp(ctx, nil) app, err = fb.NewApp(ctx, nil)
} }
if err != nil { if err != nil {
errInit = fmt.Errorf("initialize firebase app: %w", err) initErr = fmt.Errorf("initialize firebase app: %w", err)
return return
} }
authClient, err = app.Auth(ctx) authClient, err = app.Auth(ctx)
if err != nil { if err != nil {
errInit = fmt.Errorf("initialize firebase auth: %w", err) initErr = fmt.Errorf("initialize firebase auth: %w", err)
return return
} }
}) })
return errInit return initErr
} }
func Auth() *auth.Client { func Auth() *auth.Client {

View File

@@ -1,4 +1,3 @@
// Package handlers implements HTTP request handlers for the API.
package handlers package handlers
import ( import (
@@ -8,9 +7,9 @@ import (
"regexp" "regexp"
"strings" "strings"
"github.com/kjannette/koin-ping/backend/internal/domain" "github.com/kjannette/koin-ping/backend-go/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/middleware" "github.com/kjannette/koin-ping/backend-go/internal/middleware"
"github.com/kjannette/koin-ping/backend/internal/models" "github.com/kjannette/koin-ping/backend-go/internal/models"
) )
var ethAddressRe = regexp.MustCompile(`^0x[a-fA-F0-9]{40}$`) var ethAddressRe = regexp.MustCompile(`^0x[a-fA-F0-9]{40}$`)
@@ -33,19 +32,16 @@ func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
log.Printf("Failed to decode address request body: %v", err) log.Printf("Failed to decode address request body: %v", err)
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body")
return return
} }
if body.Address == "" { if body.Address == "" {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Address is required") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Address is required")
return return
} }
if !ethAddressRe.MatchString(body.Address) { if !ethAddressRe.MatchString(body.Address) {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid Ethereum address format") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid Ethereum address format")
return return
} }
@@ -55,12 +51,10 @@ func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
if strings.Contains(err.Error(), "23505") || strings.Contains(err.Error(), "unique") { if strings.Contains(err.Error(), "23505") || strings.Contains(err.Error(), "unique") {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "You are already tracking this address") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "You are already tracking this address")
return return
} }
log.Printf("Error creating address: %v", err) log.Printf("Error creating address: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create address") writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create address")
return return
} }
@@ -68,7 +62,6 @@ func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, addr) 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) { func (h *AddressHandler) List(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context()) userID := middleware.GetUserID(r.Context())
log.Printf("User %s listing addresses", userID) log.Printf("User %s listing addresses", userID)
@@ -77,7 +70,6 @@ func (h *AddressHandler) List(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
log.Printf("Error listing addresses: %v", err) log.Printf("Error listing addresses: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list addresses") writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list addresses")
return return
} }
@@ -89,51 +81,11 @@ func (h *AddressHandler) List(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, addresses) 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) { func (h *AddressHandler) Remove(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context()) userID := middleware.GetUserID(r.Context())
addressID, ok := parseIntParam(r.PathValue("addressId")) addressID, ok := parseIntParam(r.PathValue("addressId"))
if !ok { if !ok {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid address ID") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid address ID")
return return
} }
@@ -143,14 +95,12 @@ func (h *AddressHandler) Remove(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
log.Printf("Error deleting address: %v", err) log.Printf("Error deleting address: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete address") writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete address")
return return
} }
if !deleted { if !deleted {
log.Printf("Address %d not found or not owned by user", addressID) log.Printf("Address %d not found or not owned by user", addressID)
writeError(w, http.StatusNotFound, "NOT_FOUND", "Address not found") writeError(w, http.StatusNotFound, "NOT_FOUND", "Address not found")
return return
} }

View File

@@ -5,22 +5,18 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"github.com/kjannette/koin-ping/backend/internal/domain" "github.com/kjannette/koin-ping/backend-go/internal/middleware"
"github.com/kjannette/koin-ping/backend/internal/middleware" "github.com/kjannette/koin-ping/backend-go/internal/models"
"github.com/kjannette/koin-ping/backend/internal/models"
) )
// AlertEventHandler handles HTTP requests for alert event history.
type AlertEventHandler struct { type AlertEventHandler struct {
alertEvents *models.AlertEventModel alertEvents *models.AlertEventModel
} }
// NewAlertEventHandler creates a new AlertEventHandler.
func NewAlertEventHandler(alertEvents *models.AlertEventModel) *AlertEventHandler { func NewAlertEventHandler(alertEvents *models.AlertEventModel) *AlertEventHandler {
return &AlertEventHandler{alertEvents: alertEvents} 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) { func (h *AlertEventHandler) List(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context()) userID := middleware.GetUserID(r.Context())
@@ -36,7 +32,6 @@ func (h *AlertEventHandler) List(w http.ResponseWriter, r *http.Request) {
if limit < 1 || limit > 100 { if limit < 1 || limit > 100 {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Limit must be between 1 and 100") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Limit must be between 1 and 100")
return return
} }
@@ -44,15 +39,10 @@ func (h *AlertEventHandler) List(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
log.Printf("Error listing alert events: %v", err) log.Printf("Error listing alert events: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list alert events") writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list alert events")
return return
} }
log.Printf("Found %d alert events for user", len(events)) log.Printf("Found %d alert events for user", len(events))
if events == nil {
events = []domain.AlertEvent{}
}
writeJSON(w, http.StatusOK, events) writeJSON(w, http.StatusOK, events)
} }

View File

@@ -2,20 +2,17 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"github.com/kjannette/koin-ping/backend/internal/domain" "github.com/kjannette/koin-ping/backend-go/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/middleware" "github.com/kjannette/koin-ping/backend-go/internal/middleware"
"github.com/kjannette/koin-ping/backend/internal/models" "github.com/kjannette/koin-ping/backend-go/internal/models"
) )
var errThresholdFormat = errors.New("unsupported threshold format")
type AlertRuleHandler struct { type AlertRuleHandler struct {
alertRules *models.AlertRuleModel alertRules *models.AlertRuleModel
addresses *models.AddressModel addresses *models.AddressModel
@@ -30,7 +27,6 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
addressID, ok := parseIntParam(r.PathValue("addressId")) addressID, ok := parseIntParam(r.PathValue("addressId"))
if !ok { if !ok {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid address ID") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid address ID")
return return
} }
@@ -41,7 +37,6 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
log.Printf("Failed to decode alert request body: %v", err) log.Printf("Failed to decode alert request body: %v", err)
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body")
return return
} }
@@ -49,7 +44,6 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
log.Printf("Failed to parse threshold: %v", err) log.Printf("Failed to parse threshold: %v", err)
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "threshold must be a valid number") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "threshold must be a valid number")
return return
} }
@@ -57,18 +51,16 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
if body.Type == "" { if body.Type == "" {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Alert type is required") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Alert type is required")
return return
} }
if !domain.IsValidAlertType(body.Type) { if !domain.IsValidAlertType(body.Type) {
types := make([]string, len(domain.ValidAlertTypes)) types := make([]string, len(domain.ValidAlertTypes))
for i, t := range domain.ValidAlertTypes { for i, t := range domain.ValidAlertTypes {
types[i] = t.String() types[i] = string(t)
} }
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
"Invalid alert type. Must be one of: "+strings.Join(types, ", ")) fmt.Sprintf("Invalid alert type. Must be one of: %s", strings.Join(types, ", ")))
return return
} }
@@ -77,7 +69,6 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
if threshold == nil || *threshold <= 0 { if threshold == nil || *threshold <= 0 {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
fmt.Sprintf("Alert type '%s' requires a positive threshold value", body.Type)) fmt.Sprintf("Alert type '%s' requires a positive threshold value", body.Type))
return return
} }
} }
@@ -86,13 +77,11 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
log.Printf("Error finding address: %v", err) log.Printf("Error finding address: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule") writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule")
return return
} }
if addr == nil { if addr == nil {
log.Printf("Address %d not found or not owned by user", addressID) log.Printf("Address %d not found or not owned by user", addressID)
writeError(w, http.StatusNotFound, "NOT_FOUND", "Address not found") writeError(w, http.StatusNotFound, "NOT_FOUND", "Address not found")
return return
} }
@@ -100,7 +89,6 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
log.Printf("Error creating alert rule: %v", err) log.Printf("Error creating alert rule: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule") writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule")
return return
} }
@@ -110,12 +98,12 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
func parseThreshold(raw json.RawMessage) (*float64, error) { func parseThreshold(raw json.RawMessage) (*float64, error) {
if len(raw) == 0 { if len(raw) == 0 {
return nil, nil //nolint:nilnil return nil, nil
} }
// Check null before number -- json.Unmarshal treats null as valid for float64 (sets to 0). // Check null before number -- json.Unmarshal treats null as valid for float64 (sets to 0).
if string(raw) == "null" { if string(raw) == "null" {
return nil, nil //nolint:nilnil return nil, nil
} }
var asNumber float64 var asNumber float64
@@ -127,26 +115,23 @@ func parseThreshold(raw json.RawMessage) (*float64, error) {
if err := json.Unmarshal(raw, &asString); err == nil { if err := json.Unmarshal(raw, &asString); err == nil {
asString = strings.TrimSpace(asString) asString = strings.TrimSpace(asString)
if asString == "" { if asString == "" {
return nil, nil //nolint:nilnil return nil, nil
} }
parsed, parseErr := strconv.ParseFloat(asString, 64) parsed, parseErr := strconv.ParseFloat(asString, 64)
if parseErr != nil { if parseErr != nil {
return nil, parseErr return nil, parseErr
} }
return &parsed, nil return &parsed, nil
} }
return nil, errThresholdFormat return nil, fmt.Errorf("unsupported threshold format")
} }
// ListByAddress handles GET requests to list alert rules for an address.
func (h *AlertRuleHandler) ListByAddress(w http.ResponseWriter, r *http.Request) { func (h *AlertRuleHandler) ListByAddress(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context()) userID := middleware.GetUserID(r.Context())
addressID, ok := parseIntParam(r.PathValue("addressId")) addressID, ok := parseIntParam(r.PathValue("addressId"))
if !ok { if !ok {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid address ID") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid address ID")
return return
} }
@@ -156,13 +141,11 @@ func (h *AlertRuleHandler) ListByAddress(w http.ResponseWriter, r *http.Request)
if err != nil { if err != nil {
log.Printf("Error finding address: %v", err) log.Printf("Error finding address: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list alerts") writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list alerts")
return return
} }
if addr == nil { if addr == nil {
log.Printf("Address %d not found or not owned by user", addressID) log.Printf("Address %d not found or not owned by user", addressID)
writeError(w, http.StatusNotFound, "NOT_FOUND", "Address not found") writeError(w, http.StatusNotFound, "NOT_FOUND", "Address not found")
return return
} }
@@ -170,7 +153,6 @@ func (h *AlertRuleHandler) ListByAddress(w http.ResponseWriter, r *http.Request)
if err != nil { if err != nil {
log.Printf("Error listing alerts: %v", err) log.Printf("Error listing alerts: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list alerts") writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list alerts")
return return
} }
@@ -182,13 +164,11 @@ func (h *AlertRuleHandler) ListByAddress(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, alerts) writeJSON(w, http.StatusOK, alerts)
} }
// UpdateStatus handles PATCH requests to enable or disable an alert rule.
func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) { func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context()) userID := middleware.GetUserID(r.Context())
alertID, ok := parseIntParam(r.PathValue("alertId")) alertID, ok := parseIntParam(r.PathValue("alertId"))
if !ok { if !ok {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid alert ID") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid alert ID")
return return
} }
@@ -198,7 +178,6 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
log.Printf("Failed to decode update request body: %v", err) log.Printf("Failed to decode update request body: %v", err)
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body")
return return
} }
@@ -206,7 +185,6 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
if body.Enabled == nil { if body.Enabled == nil {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "enabled must be a boolean value") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "enabled must be a boolean value")
return return
} }
@@ -214,13 +192,11 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
if err != nil { if err != nil {
log.Printf("Error finding alert: %v", err) log.Printf("Error finding alert: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert") writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert")
return return
} }
if alert == nil { if alert == nil {
log.Printf("Alert %d not found or not owned by user", alertID) log.Printf("Alert %d not found or not owned by user", alertID)
writeError(w, http.StatusNotFound, "NOT_FOUND", "Alert rule not found") writeError(w, http.StatusNotFound, "NOT_FOUND", "Alert rule not found")
return return
} }
@@ -228,7 +204,6 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
if err != nil { if err != nil {
log.Printf("Error updating alert: %v", err) log.Printf("Error updating alert: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert") writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert")
return return
} }
@@ -236,13 +211,11 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, updated) writeJSON(w, http.StatusOK, updated)
} }
// Remove handles DELETE requests to remove an alert rule.
func (h *AlertRuleHandler) Remove(w http.ResponseWriter, r *http.Request) { func (h *AlertRuleHandler) Remove(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context()) userID := middleware.GetUserID(r.Context())
alertID, ok := parseIntParam(r.PathValue("alertId")) alertID, ok := parseIntParam(r.PathValue("alertId"))
if !ok { if !ok {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid alert ID") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid alert ID")
return return
} }
@@ -252,20 +225,17 @@ func (h *AlertRuleHandler) Remove(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
log.Printf("Error finding alert: %v", err) log.Printf("Error finding alert: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete alert") writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete alert")
return return
} }
if alert == nil { if alert == nil {
log.Printf("Alert %d not found or not owned by user", alertID) log.Printf("Alert %d not found or not owned by user", alertID)
writeError(w, http.StatusNotFound, "NOT_FOUND", "Alert rule not found") writeError(w, http.StatusNotFound, "NOT_FOUND", "Alert rule not found")
return return
} }
if _, err := h.alertRules.Remove(r.Context(), alertID); err != nil { if _, err := h.alertRules.Remove(r.Context(), alertID); err != nil {
log.Printf("Error deleting alert: %v", err) log.Printf("Error deleting alert: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete alert") writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete alert")
return return
} }

View File

@@ -1,4 +1,3 @@
//nolint:testpackage // parseThreshold is unexported; internal test package required
package handlers package handlers
import ( import (
@@ -7,13 +6,8 @@ import (
"testing" "testing"
) )
//nolint:gocognit
func TestParseThreshold(t *testing.T) { func TestParseThreshold(t *testing.T) {
t.Parallel()
t.Run("nil/empty raw message returns nil", func(t *testing.T) { t.Run("nil/empty raw message returns nil", func(t *testing.T) {
t.Parallel()
val, err := parseThreshold(nil) val, err := parseThreshold(nil)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
@@ -24,8 +18,6 @@ func TestParseThreshold(t *testing.T) {
}) })
t.Run("empty slice returns nil", func(t *testing.T) { t.Run("empty slice returns nil", func(t *testing.T) {
t.Parallel()
val, err := parseThreshold(json.RawMessage{}) val, err := parseThreshold(json.RawMessage{})
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
@@ -36,8 +28,6 @@ func TestParseThreshold(t *testing.T) {
}) })
t.Run("JSON null returns nil", func(t *testing.T) { t.Run("JSON null returns nil", func(t *testing.T) {
t.Parallel()
val, err := parseThreshold(json.RawMessage("null")) val, err := parseThreshold(json.RawMessage("null"))
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
@@ -48,8 +38,6 @@ func TestParseThreshold(t *testing.T) {
}) })
t.Run("number 10 returns 10.0", func(t *testing.T) { t.Run("number 10 returns 10.0", func(t *testing.T) {
t.Parallel()
val, err := parseThreshold(json.RawMessage("10")) val, err := parseThreshold(json.RawMessage("10"))
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
@@ -63,8 +51,6 @@ func TestParseThreshold(t *testing.T) {
}) })
t.Run("number 0.5 returns 0.5", func(t *testing.T) { t.Run("number 0.5 returns 0.5", func(t *testing.T) {
t.Parallel()
val, err := parseThreshold(json.RawMessage("0.5")) val, err := parseThreshold(json.RawMessage("0.5"))
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
@@ -75,8 +61,6 @@ func TestParseThreshold(t *testing.T) {
}) })
t.Run("string '10' returns 10.0", func(t *testing.T) { t.Run("string '10' returns 10.0", func(t *testing.T) {
t.Parallel()
val, err := parseThreshold(json.RawMessage(`"10"`)) val, err := parseThreshold(json.RawMessage(`"10"`))
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
@@ -87,8 +71,6 @@ func TestParseThreshold(t *testing.T) {
}) })
t.Run("string '0.001' returns 0.001", func(t *testing.T) { t.Run("string '0.001' returns 0.001", func(t *testing.T) {
t.Parallel()
val, err := parseThreshold(json.RawMessage(`"0.001"`)) val, err := parseThreshold(json.RawMessage(`"0.001"`))
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
@@ -99,8 +81,6 @@ func TestParseThreshold(t *testing.T) {
}) })
t.Run("string with spaces ' 10 ' returns 10.0", func(t *testing.T) { t.Run("string with spaces ' 10 ' returns 10.0", func(t *testing.T) {
t.Parallel()
val, err := parseThreshold(json.RawMessage(`" 10 "`)) val, err := parseThreshold(json.RawMessage(`" 10 "`))
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
@@ -111,8 +91,6 @@ func TestParseThreshold(t *testing.T) {
}) })
t.Run("empty string returns nil", func(t *testing.T) { t.Run("empty string returns nil", func(t *testing.T) {
t.Parallel()
val, err := parseThreshold(json.RawMessage(`""`)) val, err := parseThreshold(json.RawMessage(`""`))
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
@@ -123,8 +101,6 @@ func TestParseThreshold(t *testing.T) {
}) })
t.Run("whitespace-only string returns nil", func(t *testing.T) { t.Run("whitespace-only string returns nil", func(t *testing.T) {
t.Parallel()
val, err := parseThreshold(json.RawMessage(`" "`)) val, err := parseThreshold(json.RawMessage(`" "`))
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
@@ -135,8 +111,6 @@ func TestParseThreshold(t *testing.T) {
}) })
t.Run("invalid string returns error", func(t *testing.T) { t.Run("invalid string returns error", func(t *testing.T) {
t.Parallel()
_, err := parseThreshold(json.RawMessage(`"abc"`)) _, err := parseThreshold(json.RawMessage(`"abc"`))
if err == nil { if err == nil {
t.Fatal("expected error for non-numeric string") t.Fatal("expected error for non-numeric string")
@@ -144,8 +118,6 @@ func TestParseThreshold(t *testing.T) {
}) })
t.Run("boolean returns error", func(t *testing.T) { t.Run("boolean returns error", func(t *testing.T) {
t.Parallel()
_, err := parseThreshold(json.RawMessage("true")) _, err := parseThreshold(json.RawMessage("true"))
if err == nil { if err == nil {
t.Fatal("expected error for boolean") t.Fatal("expected error for boolean")
@@ -153,8 +125,6 @@ func TestParseThreshold(t *testing.T) {
}) })
t.Run("array returns error", func(t *testing.T) { t.Run("array returns error", func(t *testing.T) {
t.Parallel()
_, err := parseThreshold(json.RawMessage("[1,2]")) _, err := parseThreshold(json.RawMessage("[1,2]"))
if err == nil { if err == nil {
t.Fatal("expected error for array") t.Fatal("expected error for array")
@@ -162,10 +132,7 @@ func TestParseThreshold(t *testing.T) {
}) })
} }
//nolint:funlen
func TestDecodeAlertBody(t *testing.T) { func TestDecodeAlertBody(t *testing.T) {
t.Parallel()
// Verifies that the struct used in Create handler can decode all // Verifies that the struct used in Create handler can decode all
// payload shapes the frontend might send. // payload shapes the frontend might send.
@@ -216,15 +183,12 @@ func TestDecodeAlertBody(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var body alertBody var body alertBody
err := json.Unmarshal([]byte(tt.payload), &body) err := json.Unmarshal([]byte(tt.payload), &body)
if tt.wantErr { if tt.wantErr {
if err == nil { if err == nil {
t.Fatal("expected decode error") t.Fatal("expected decode error")
} }
return return
} }
if err != nil { if err != nil {
@@ -235,8 +199,6 @@ func TestDecodeAlertBody(t *testing.T) {
} }
func TestOldStructFailsWithStringThreshold(t *testing.T) { func TestOldStructFailsWithStringThreshold(t *testing.T) {
t.Parallel()
// Documents the original bug: *float64 cannot decode a string threshold. // Documents the original bug: *float64 cannot decode a string threshold.
type oldAlertBody struct { type oldAlertBody struct {
Type string `json:"type"` Type string `json:"type"`

View File

@@ -7,22 +7,20 @@ import (
"regexp" "regexp"
"strings" "strings"
"github.com/kjannette/koin-ping/backend/internal/config" "github.com/kjannette/koin-ping/backend-go/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/domain" "github.com/kjannette/koin-ping/backend-go/internal/middleware"
"github.com/kjannette/koin-ping/backend/internal/middleware" "github.com/kjannette/koin-ping/backend-go/internal/models"
"github.com/kjannette/koin-ping/backend/internal/models" "github.com/kjannette/koin-ping/backend-go/internal/notifications"
"github.com/kjannette/koin-ping/backend/internal/notifications"
) )
var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`) var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
type NotificationConfigHandler struct { type NotificationConfigHandler struct {
configs *models.NotificationConfigModel configs *models.NotificationConfigModel
cfg *config.Config
} }
func NewNotificationConfigHandler(configs *models.NotificationConfigModel, cfg *config.Config) *NotificationConfigHandler { func NewNotificationConfigHandler(configs *models.NotificationConfigModel) *NotificationConfigHandler {
return &NotificationConfigHandler{configs: configs, cfg: cfg} return &NotificationConfigHandler{configs: configs}
} }
func (h *NotificationConfigHandler) GetConfig(w http.ResponseWriter, r *http.Request) { func (h *NotificationConfigHandler) GetConfig(w http.ResponseWriter, r *http.Request) {
@@ -53,10 +51,8 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
var body struct { var body struct {
DiscordWebhookURL *string `json:"discord_webhook_url"` DiscordWebhookURL *string `json:"discord_webhook_url"`
TelegramChatID *string `json:"telegram_chat_id"`
TelegramBotToken *string `json:"telegram_bot_token"`
Email *string `json:"email"`
SlackWebhookURL *string `json:"slack_webhook_url"` SlackWebhookURL *string `json:"slack_webhook_url"`
Email *string `json:"email"`
NotificationEnabled *bool `json:"notification_enabled"` NotificationEnabled *bool `json:"notification_enabled"`
} }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
@@ -67,9 +63,8 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
log.Printf("User %s updating notification config", userID) log.Printf("User %s updating notification config", userID)
if body.DiscordWebhookURL == nil && body.TelegramChatID == nil && if body.DiscordWebhookURL == nil && body.SlackWebhookURL == nil &&
body.TelegramBotToken == nil && body.Email == nil && body.Email == nil && body.NotificationEnabled == nil {
body.SlackWebhookURL == nil && body.NotificationEnabled == nil {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
"At least one configuration field must be provided") "At least one configuration field must be provided")
return return
@@ -102,10 +97,8 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
cfg := domain.NotificationConfig{ cfg := domain.NotificationConfig{
DiscordWebhookURL: body.DiscordWebhookURL, DiscordWebhookURL: body.DiscordWebhookURL,
TelegramChatID: body.TelegramChatID,
TelegramBotToken: body.TelegramBotToken,
Email: body.Email,
SlackWebhookURL: body.SlackWebhookURL, SlackWebhookURL: body.SlackWebhookURL,
Email: body.Email,
NotificationEnabled: enabled, NotificationEnabled: enabled,
} }
@@ -142,75 +135,49 @@ func (h *NotificationConfigHandler) DeleteConfig(w http.ResponseWriter, r *http.
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// TestChannels sends a test message to all configured notification channels. func (h *NotificationConfigHandler) TestWebhook(w http.ResponseWriter, r *http.Request) {
func (h *NotificationConfigHandler) TestChannels(w http.ResponseWriter, r *http.Request) { _ = middleware.GetUserID(r.Context())
userID := middleware.GetUserID(r.Context())
log.Printf("User %s testing notification channels", userID) 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
}
cfg, err := h.configs.GetConfig(r.Context(), userID)
if err != nil { if err != nil {
log.Printf("Error getting notification config for test: %v", err) log.Printf("Webhook test failed: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to get notification config") writeJSON(w, http.StatusOK, map[string]interface{}{"success": false, "error": err.Error()})
return return
} }
if cfg == nil { writeJSON(w, http.StatusOK, map[string]interface{}{"success": ok})
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})
} }

View File

@@ -0,0 +1,79 @@
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),
})
}

View File

@@ -0,0 +1,98 @@
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 ""
}

View File

@@ -2,11 +2,9 @@ package models
import ( import (
"context" "context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/kjannette/koin-ping/backend/internal/domain" "github.com/kjannette/koin-ping/backend-go/internal/domain"
) )
type AddressModel struct { type AddressModel struct {
@@ -99,25 +97,7 @@ func (m *AddressModel) FindByID(ctx context.Context, id int, userID *string) (*d
} }
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if err.Error() == "no rows in result set" {
return nil, nil
}
return nil, err
}
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, nil
} }
return nil, err return nil, err

View File

@@ -2,11 +2,9 @@ package models
import ( import (
"context" "context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/kjannette/koin-ping/backend/internal/domain" "github.com/kjannette/koin-ping/backend-go/internal/domain"
) )
type AlertEventModel struct { type AlertEventModel struct {
@@ -73,15 +71,10 @@ func (m *AlertEventModel) Create(ctx context.Context, alertRuleID int, message s
err := m.pool.QueryRow(ctx, err := m.pool.QueryRow(ctx,
`INSERT INTO alert_events (alert_rule_id, message, address_label, tx_hash) `INSERT INTO alert_events (alert_rule_id, message, address_label, tx_hash)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4)
ON CONFLICT DO NOTHING
RETURNING id, alert_rule_id, message, address_label, tx_hash, timestamp`, RETURNING id, alert_rule_id, message, address_label, tx_hash, timestamp`,
alertRuleID, message, addressLabel, txHash, alertRuleID, message, addressLabel, txHash,
).Scan(&e.ID, &e.AlertRuleID, &e.Message, &e.AddressLabel, &e.TxHash, &e.Timestamp) ).Scan(&e.ID, &e.AlertRuleID, &e.Message, &e.AddressLabel, &e.TxHash, &e.Timestamp)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// Duplicate silently skipped by ON CONFLICT DO NOTHING
return nil, nil
}
return nil, err return nil, err
} }
return &e, nil return &e, nil

View File

@@ -2,11 +2,9 @@ package models
import ( import (
"context" "context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/kjannette/koin-ping/backend/internal/domain" "github.com/kjannette/koin-ping/backend-go/internal/domain"
) )
type AlertRuleModel struct { type AlertRuleModel struct {
@@ -23,7 +21,7 @@ func (m *AlertRuleModel) Create(ctx context.Context, addressID int, alertType do
`INSERT INTO alert_rules (address_id, type, threshold, enabled) `INSERT INTO alert_rules (address_id, type, threshold, enabled)
VALUES ($1, $2, $3, TRUE) VALUES ($1, $2, $3, TRUE)
RETURNING id, address_id, type, threshold, enabled, created_at`, RETURNING id, address_id, type, threshold, enabled, created_at`,
addressID, alertType.String(), threshold, addressID, string(alertType), threshold,
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt) ).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -77,7 +75,7 @@ func (m *AlertRuleModel) FindByID(ctx context.Context, id int, userID *string) (
} }
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if err.Error() == "no rows in result set" {
return nil, nil return nil, nil
} }
return nil, err return nil, err
@@ -95,7 +93,7 @@ func (m *AlertRuleModel) UpdateEnabled(ctx context.Context, id int, enabled bool
id, enabled, id, enabled,
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt) ).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if err.Error() == "no rows in result set" {
return nil, nil return nil, nil
} }
return nil, err return nil, err

View File

@@ -2,12 +2,9 @@ package models
import ( import (
"context" "context"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/kjannette/koin-ping/backend/internal/domain" "github.com/kjannette/koin-ping/backend-go/internal/domain"
) )
type CheckpointModel struct { type CheckpointModel struct {
@@ -18,20 +15,6 @@ func NewCheckpointModel(pool *pgxpool.Pool) *CheckpointModel {
return &CheckpointModel{pool: pool} 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. // 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) { func (m *CheckpointModel) GetLastCheckedBlock(ctx context.Context, addressID int) (int, bool, error) {
var block int var block int
@@ -40,7 +23,7 @@ func (m *CheckpointModel) GetLastCheckedBlock(ctx context.Context, addressID int
addressID, addressID,
).Scan(&block) ).Scan(&block)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if err.Error() == "no rows in result set" {
return 0, false, nil return 0, false, nil
} }
return 0, false, err return 0, false, err

View File

@@ -2,11 +2,9 @@ package models
import ( import (
"context" "context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/kjannette/koin-ping/backend/internal/domain" "github.com/kjannette/koin-ping/backend-go/internal/domain"
) )
type NotificationConfigModel struct { type NotificationConfigModel struct {
@@ -20,15 +18,15 @@ func NewNotificationConfigModel(pool *pgxpool.Pool) *NotificationConfigModel {
func (m *NotificationConfigModel) GetConfig(ctx context.Context, userID string) (*domain.NotificationConfig, error) { func (m *NotificationConfigModel) GetConfig(ctx context.Context, userID string) (*domain.NotificationConfig, error) {
var c domain.NotificationConfig var c domain.NotificationConfig
err := m.pool.QueryRow(ctx, err := m.pool.QueryRow(ctx,
`SELECT user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token, `SELECT user_id, discord_webhook_url, slack_webhook_url,
email, slack_webhook_url, notification_enabled, created_at, updated_at email, notification_enabled, created_at, updated_at
FROM user_notification_configs FROM user_notification_configs
WHERE user_id = $1`, WHERE user_id = $1`,
userID, userID,
).Scan(&c.UserID, &c.DiscordWebhookURL, &c.TelegramChatID, &c.TelegramBotToken, ).Scan(&c.UserID, &c.DiscordWebhookURL, &c.SlackWebhookURL,
&c.Email, &c.SlackWebhookURL, &c.NotificationEnabled, &c.CreatedAt, &c.UpdatedAt) &c.Email, &c.NotificationEnabled, &c.CreatedAt, &c.UpdatedAt)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if err.Error() == "no rows in result set" {
return nil, nil return nil, nil
} }
return nil, err return nil, err
@@ -40,24 +38,21 @@ func (m *NotificationConfigModel) UpsertConfig(ctx context.Context, userID strin
var c domain.NotificationConfig var c domain.NotificationConfig
err := m.pool.QueryRow(ctx, err := m.pool.QueryRow(ctx,
`INSERT INTO user_notification_configs `INSERT INTO user_notification_configs
(user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token, (user_id, discord_webhook_url, slack_webhook_url, email, notification_enabled, updated_at)
email, slack_webhook_url, notification_enabled, updated_at) VALUES ($1, $2, $3, $4, $5, NOW())
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
ON CONFLICT (user_id) ON CONFLICT (user_id)
DO UPDATE SET DO UPDATE SET
discord_webhook_url = $2, discord_webhook_url = COALESCE($2, user_notification_configs.discord_webhook_url),
telegram_chat_id = $3, slack_webhook_url = COALESCE($3, user_notification_configs.slack_webhook_url),
telegram_bot_token = $4, email = COALESCE($4, user_notification_configs.email),
email = $5, notification_enabled = $5,
slack_webhook_url = $6,
notification_enabled = $7,
updated_at = NOW() updated_at = NOW()
RETURNING user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token, RETURNING user_id, discord_webhook_url, slack_webhook_url,
email, slack_webhook_url, notification_enabled, created_at, updated_at`, email, notification_enabled, created_at, updated_at`,
userID, cfg.DiscordWebhookURL, cfg.TelegramChatID, cfg.TelegramBotToken, userID, cfg.DiscordWebhookURL, cfg.SlackWebhookURL,
cfg.Email, cfg.SlackWebhookURL, cfg.NotificationEnabled, cfg.Email, cfg.NotificationEnabled,
).Scan(&c.UserID, &c.DiscordWebhookURL, &c.TelegramChatID, &c.TelegramBotToken, ).Scan(&c.UserID, &c.DiscordWebhookURL, &c.SlackWebhookURL,
&c.Email, &c.SlackWebhookURL, &c.NotificationEnabled, &c.CreatedAt, &c.UpdatedAt) &c.Email, &c.NotificationEnabled, &c.CreatedAt, &c.UpdatedAt)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -77,8 +72,7 @@ func (m *NotificationConfigModel) Remove(ctx context.Context, userID string) (bo
func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.NotificationConfig, error) { func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.NotificationConfig, error) {
rows, err := m.pool.Query(ctx, rows, err := m.pool.Query(ctx,
`SELECT user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token, `SELECT user_id, discord_webhook_url, slack_webhook_url, email
email, slack_webhook_url
FROM user_notification_configs FROM user_notification_configs
WHERE notification_enabled = TRUE`, WHERE notification_enabled = TRUE`,
) )
@@ -90,8 +84,7 @@ func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.Not
var configs []domain.NotificationConfig var configs []domain.NotificationConfig
for rows.Next() { for rows.Next() {
var c domain.NotificationConfig var c domain.NotificationConfig
if err := rows.Scan(&c.UserID, &c.DiscordWebhookURL, &c.TelegramChatID, if err := rows.Scan(&c.UserID, &c.DiscordWebhookURL, &c.SlackWebhookURL, &c.Email); err != nil {
&c.TelegramBotToken, &c.Email, &c.SlackWebhookURL); err != nil {
return nil, err return nil, err
} }
c.NotificationEnabled = true c.NotificationEnabled = true

View File

@@ -2,7 +2,6 @@ package notifications
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
@@ -10,28 +9,11 @@ import (
"time" "time"
) )
const ( type AlertMetadata struct {
// discordHTTPTimeoutSeconds is the timeout for Discord webhook requests. TxHash string
discordHTTPTimeoutSeconds = 10 AddressLabel string
AlertType string
// Alert color codes for Discord embeds. Address string
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 { type discordEmbed struct {
@@ -91,9 +73,7 @@ func SendDiscordNotification(webhookURL, message string, meta AlertMetadata) (bo
return false, fmt.Errorf("marshal discord payload: %w", err) return false, fmt.Errorf("marshal discord payload: %w", err)
} }
resp, err := discordHTTPClient.Post( resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body))
webhookURL, "application/json", bytes.NewReader(body),
)
if err != nil { if err != nil {
log.Printf("Failed to send Discord notification: %v", err) log.Printf("Failed to send Discord notification: %v", err)
return false, err return false, err
@@ -101,12 +81,8 @@ func SendDiscordNotification(webhookURL, message string, meta AlertMetadata) (bo
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 { 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) log.Printf("Discord webhook failed: HTTP %d", resp.StatusCode)
if isPermanentStatusCode(resp.StatusCode) { return false, fmt.Errorf("discord webhook failed: HTTP %d", resp.StatusCode)
return false, &PermanentError{Err: err}
}
return false, err
} }
return true, nil return true, nil
@@ -122,9 +98,7 @@ func TestDiscordWebhook(webhookURL string) (bool, error) {
return false, err return false, err
} }
resp, err := discordHTTPClient.Post( resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body))
webhookURL, "application/json", bytes.NewReader(body),
)
if err != nil { if err != nil {
log.Printf("Discord webhook test failed: %v", err) log.Printf("Discord webhook test failed: %v", err)
return false, err return false, err
@@ -137,14 +111,14 @@ func TestDiscordWebhook(webhookURL string) (bool, error) {
func colorForAlertType(alertType string) int { func colorForAlertType(alertType string) int {
switch alertType { switch alertType {
case "incoming_tx": case "incoming_tx":
return colorGreen return 0x00ff00 // Green
case "outgoing_tx": case "outgoing_tx":
return colorOrange return 0xff9900 // Orange
case "large_transfer": case "large_transfer":
return colorRed return 0xff0000 // Red
case "balance_below": case "balance_below":
return colorRed return 0xff0000 // Red
default: default:
return colorBlue return 0x0099ff // Blue
} }
} }

View File

@@ -0,0 +1,99 @@
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)
}
}
}

View File

@@ -0,0 +1,111 @@
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"
}
}

View File

@@ -0,0 +1,102 @@
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")
}
}

View File

@@ -0,0 +1,128 @@
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 "🔔"
}
}

View File

@@ -0,0 +1,128 @@
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)
}
}
}

View File

@@ -0,0 +1,211 @@
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()
}

View File

@@ -0,0 +1,96 @@
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")
}
}

View File

@@ -3,7 +3,7 @@ package ethereum
import ( import (
"context" "context"
"github.com/kjannette/koin-ping/backend/internal/domain" "github.com/kjannette/koin-ping/backend-go/internal/domain"
) )
// EthereumObserver defines the interface for blockchain interaction. // EthereumObserver defines the interface for blockchain interaction.
@@ -12,5 +12,4 @@ type EthereumObserver interface {
GetLatestBlockNumber(ctx context.Context) (int, error) GetLatestBlockNumber(ctx context.Context) (int, error)
GetBlockTransactions(ctx context.Context, blockNumber int) ([]domain.NormalizedTx, error) GetBlockTransactions(ctx context.Context, blockNumber int) ([]domain.NormalizedTx, error)
GetBalance(ctx context.Context, address string) (string, error) GetBalance(ctx context.Context, address string) (string, error)
GetTokenTransfers(ctx context.Context, fromBlock, toBlock int, address string) ([]domain.NormalizedTx, error)
} }

View File

@@ -4,23 +4,12 @@ import (
"context" "context"
"fmt" "fmt"
"log" "log"
"sync"
"time"
"golang.org/x/sync/semaphore" "github.com/kjannette/koin-ping/backend-go/internal/domain"
"github.com/kjannette/koin-ping/backend-go/internal/models"
"github.com/kjannette/koin-ping/backend/internal/domain" "github.com/kjannette/koin-ping/backend-go/internal/notifications"
"github.com/kjannette/koin-ping/backend/internal/models" "github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
"github.com/kjannette/koin-ping/backend/internal/notifications" "github.com/kjannette/koin-ping/backend-go/internal/wei"
"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 { type EvaluatorService struct {
@@ -29,10 +18,7 @@ type EvaluatorService struct {
alertEvents *models.AlertEventModel alertEvents *models.AlertEventModel
addresses *models.AddressModel addresses *models.AddressModel
notifConfigs *models.NotificationConfigModel notifConfigs *models.NotificationConfigModel
resendAPIKey string smtpConfig notifications.SMTPConfig
emailFrom string
notifSem *semaphore.Weighted
notifWg sync.WaitGroup
} }
func NewEvaluatorService( func NewEvaluatorService(
@@ -41,8 +27,7 @@ func NewEvaluatorService(
alertEvents *models.AlertEventModel, alertEvents *models.AlertEventModel,
addresses *models.AddressModel, addresses *models.AddressModel,
notifConfigs *models.NotificationConfigModel, notifConfigs *models.NotificationConfigModel,
resendAPIKey string, smtpCfg notifications.SMTPConfig,
emailFrom string,
) *EvaluatorService { ) *EvaluatorService {
return &EvaluatorService{ return &EvaluatorService{
eth: eth, eth: eth,
@@ -50,9 +35,7 @@ func NewEvaluatorService(
alertEvents: alertEvents, alertEvents: alertEvents,
addresses: addresses, addresses: addresses,
notifConfigs: notifConfigs, notifConfigs: notifConfigs,
resendAPIKey: resendAPIKey, smtpConfig: smtpCfg,
emailFrom: emailFrom,
notifSem: semaphore.NewWeighted(maxConcurrentNotifications),
} }
} }
@@ -181,104 +164,21 @@ func (s *EvaluatorService) fireAlert(ctx context.Context, rule domain.AlertRule,
message := s.buildMessage(rule, obs) message := s.buildMessage(rule, obs)
txHash := &obs.Hash txHash := &obs.Hash
event, err := s.alertEvents.Create(ctx, rule.ID, message, &addressLabel, txHash) _, err = s.alertEvents.Create(ctx, rule.ID, message, &addressLabel, txHash)
if err != nil { if err != nil {
return err 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) log.Printf("[ALERT FIRED] Rule %d (%s) - %s - TX: %s", rule.ID, rule.Type, message, obs.Hash)
if addr != nil { if addr != nil {
userID := addr.UserID go s.sendNotifications(ctx, addr.UserID, message, obs, addressLabel, rule, addr.Address)
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 return nil
} }
// WaitForNotifications blocks until all in-flight notification goroutines finish. func (s *EvaluatorService) sendNotifications(ctx context.Context, userID, message string, obs domain.ObservedTx, addressLabel string, rule domain.AlertRule, address string) {
func (s *EvaluatorService) WaitForNotifications() {
s.notifWg.Wait()
}
func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig) []notifications.Notifier {
var notifiers []notifications.Notifier
if cfg.DiscordWebhookURL != nil && *cfg.DiscordWebhookURL != "" {
notifiers = append(notifiers, &notifications.DiscordNotifier{WebhookURL: *cfg.DiscordWebhookURL})
}
if cfg.TelegramBotToken != nil && *cfg.TelegramBotToken != "" &&
cfg.TelegramChatID != nil && *cfg.TelegramChatID != "" {
notifiers = append(notifiers, &notifications.TelegramNotifier{
BotToken: *cfg.TelegramBotToken,
ChatID: *cfg.TelegramChatID,
})
}
if cfg.SlackWebhookURL != nil && *cfg.SlackWebhookURL != "" {
notifiers = append(notifiers, &notifications.SlackNotifier{WebhookURL: *cfg.SlackWebhookURL})
}
if cfg.Email != nil && *cfg.Email != "" {
notifiers = append(notifiers, &notifications.EmailNotifier{
APIKey: s.resendAPIKey,
From: s.emailFrom,
To: *cfg.Email,
})
}
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) notifConfig, err := s.notifConfigs.GetConfig(ctx, userID)
if err != nil { if err != nil {
log.Printf("Failed to get notification config: %v", err) log.Printf("Failed to get notification config: %v", err)
@@ -296,20 +196,35 @@ func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message
Address: address, Address: address,
} }
for _, n := range s.buildNotifiers(notifConfig) { if notifConfig.DiscordWebhookURL != nil && *notifConfig.DiscordWebhookURL != "" {
if err := sendWithRetry(ctx, n, message, meta); err != nil { sent, err := notifications.SendDiscordNotification(*notifConfig.DiscordWebhookURL, message, meta)
log.Printf("Notification channel failed for user %s after retries: %v", userID, err) if err != nil || !sent {
log.Printf("Discord notification failed for user %s: %v", userID, err)
} else { } else {
log.Printf("Notification sent to user %s via %T", userID, n) 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 { func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.ObservedTx) string {
if obs.IsTokenTransfer() {
return s.buildTokenMessage(rule, obs)
}
switch rule.Type { switch rule.Type {
case domain.AlertIncomingTx: case domain.AlertIncomingTx:
ethStr, _ := wei.FormatAsEth(obs.Value, 4) ethStr, _ := wei.FormatAsEth(obs.Value, 4)
@@ -334,36 +249,3 @@ func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.Observ
return "Alert triggered" 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)
}
}

View File

@@ -5,9 +5,9 @@ import (
"log" "log"
"strings" "strings"
"github.com/kjannette/koin-ping/backend/internal/domain" "github.com/kjannette/koin-ping/backend-go/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/models" "github.com/kjannette/koin-ping/backend-go/internal/models"
"github.com/kjannette/koin-ping/backend/internal/protocols/ethereum" "github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
) )
const maxBlocksPerRun = 100 const maxBlocksPerRun = 100
@@ -80,15 +80,6 @@ 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 { if _, err := s.checkpoint.UpdateLastCheckedBlock(ctx, addr.ID, endBlock); err != nil {
return nil, err return nil, err
} }

View File

@@ -0,0 +1,113 @@
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)
}
})
}

View File

@@ -98,54 +98,3 @@ func FormatAsEth(weiString string, decimals int) (string, error) {
} }
return fmt.Sprintf("%.*f ETH", decimals, eth), nil 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()
}

View File

@@ -0,0 +1,179 @@
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")
}
}

View File

@@ -1,32 +0,0 @@
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

View File

@@ -1,20 +0,0 @@
Start DB:
brew services start postgresql@15
From the backend directory, you have a few options:
Option 1: Single command (both API + poller)
cd /Users/kjannette/workspace/koin_ping_0.2.0/backendmake dev-all
Option 2: Two separate terminals
Terminal 1 (API server):
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend go run ./cmd/api
Terminal 2 (Poller):
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend go run ./cmd/poller
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).
make poller — Builds and runs the poller.
make poller-dev — Runs the poller with auto-reload.
make dev-all — Runs both the API and poller concurrently.

Binary file not shown.

View File

@@ -1,171 +0,0 @@
// 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)
alertRuleHandler := handlers.NewAlertRuleHandler(alertRuleModel, addressModel)
alertEventHandler := handlers.NewAlertEventHandler(alertEventModel)
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, cfg)
emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel)
statusHandler := handlers.NewStatusHandler(checkpointModel)
stripeHandler := handlers.NewStripeHandler(userModel, cfg)
accountHandler := handlers.NewAccountHandler(userModel, 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)
// 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)))
// 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)
})
}

View File

@@ -1,3 +0,0 @@
CREATE UNIQUE INDEX IF NOT EXISTS idx_alert_events_dedup
ON alert_events (alert_rule_id, tx_hash)
WHERE tx_hash IS NOT NULL;

View File

@@ -1,13 +0,0 @@
-- 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);

View File

@@ -1,73 +0,0 @@
-- 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;

View File

@@ -1,8 +0,0 @@
-- 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);

View File

@@ -1,111 +0,0 @@
//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
StripePriceID 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"),
StripePriceID: os.Getenv("STRIPE_PRICE_ID"),
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,
)
}
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
}

View File

@@ -1,149 +0,0 @@
package domain
import "time"
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
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"`
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"`
}

View File

@@ -1,66 +0,0 @@
package handlers
import (
"log"
"net/http"
"time"
"github.com/kjannette/koin-ping/backend/internal/config"
"github.com/kjannette/koin-ping/backend/internal/middleware"
"github.com/kjannette/koin-ping/backend/internal/models"
)
type AccountHandler struct {
users *models.UserModel
cfg *config.Config
}
func NewAccountHandler(users *models.UserModel, cfg *config.Config) *AccountHandler {
return &AccountHandler{users: users, cfg: cfg}
}
type accountResponse struct {
UserID string `json:"user_id"`
Email string `json:"email"`
UserName string `json:"user_name"`
SubscriptionStatus string `json:"subscription_status"`
SubscriptionPlan string `json:"subscription_plan"`
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"`
}
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
}
resp := accountResponse{
UserID: user.ID,
Email: email,
UserName: email,
SubscriptionStatus: user.SubscriptionStatus,
SubscriptionPlan: "monthly/$1.99",
}
if user.SubscriptionCreatedAt != nil {
t := user.SubscriptionCreatedAt.Format(time.DateOnly)
resp.MemberSince = &t
}
// MOCKED: NextBillingDate, CancelAtPeriodEnd, PeriodEndDate.
// TODO: stripe-go v82 removed Subscription.CurrentPeriodEnd / CancelAtPeriodEnd.
// Research SubscriptionItem.CurrentPeriodEnd or use Stripe REST API directly.
resp.NextBillingDate = nil
resp.CancelAtPeriodEnd = false
resp.PeriodEndDate = nil
writeJSON(w, http.StatusOK, resp)
}

View File

@@ -1,101 +0,0 @@
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",
})
}

View File

@@ -1,71 +0,0 @@
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",
})
}

View File

@@ -1,287 +0,0 @@
package handlers
import (
"encoding/json"
"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/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}
}
// CreateCheckoutSession creates a Stripe Checkout session for the monthly subscription.
func (h *StripeHandler) CreateCheckoutSession(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
}
params := &stripe.CheckoutSessionParams{
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
Price: stripe.String(h.cfg.StripePriceID),
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),
}
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_created_at": user.SubscriptionCreatedAt,
})
}
// VerifyCheckoutSession retrieves a completed checkout session from Stripe,
// confirms payment, and activates the user's subscription in the database.
// This is the primary activation path; webhooks serve as a backup.
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 != 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
}
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"); err != nil {
log.Printf("VerifyCheckout: failed to activate subscription: %v", err)
}
}
log.Printf("Checkout verified for user %s, customer %s, subscription %s", userID, customerID, subscriptionID)
writeJSON(w, http.StatusOK, map[string]string{"subscription_status": "active"})
}
// 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.
// This endpoint must NOT require authentication (Stripe calls it directly).
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
}
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"); err != nil {
log.Printf("Failed to activate subscription: %v", err)
}
}
log.Printf("Checkout completed for user %s, customer %s, subscription %s", userID, customerID, subscriptionID)
}
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)
if err := h.users.ActivateSubscription(r.Context(), customerID, sub.ID, status); err != nil {
log.Printf("Failed to update subscription status: %v", err)
}
log.Printf("Subscription %s updated to %s for customer %s", sub.ID, status, 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)
}

View File

@@ -1,152 +0,0 @@
// 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"
)
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)
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 ""
}

View File

@@ -1,89 +0,0 @@
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_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.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) error {
_, err := m.pool.Exec(ctx,
`UPDATE users
SET stripe_subscription_id = $2,
subscription_status = $3,
subscription_created_at = COALESCE(subscription_created_at, NOW()),
updated_at = NOW()
WHERE stripe_customer_id = $1`,
stripeCustomerID, subscriptionID, status,
)
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
}

View File

@@ -1,158 +0,0 @@
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"
}
}

View File

@@ -1,38 +0,0 @@
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
}

View File

@@ -1,132 +0,0 @@
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"
}
}

View File

@@ -1,126 +0,0 @@
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
}

View File

@@ -1,421 +0,0 @@
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()
}

View File

@@ -1,34 +0,0 @@
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
}

View File

@@ -1,196 +0,0 @@
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
}

Binary file not shown.

View File

@@ -5,27 +5,24 @@ React + Vite frontend for the on-chain monitoring and alerting system.
## Getting Started ## Getting Started
1. Install dependencies: 1. Install dependencies:
```bash
```bash npm install
npm install ```
```
2. Run the development server: 2. Run the development server:
```bash
```bash npm run dev
npm run dev ```
```
3. Build for production: 3. Build for production:
```bash
```bash npm run build
npm run build ```
```
4. Preview production build: 4. Preview production build:
```bash ```bash
npm run preview npm run preview
``` ```
## Project Structure ## Project Structure
@@ -37,5 +34,5 @@ React + Vite frontend for the on-chain monitoring and alerting system.
## Development ## Development
The frontend runs on port 3000 and proxies API requests to the backend running The frontend runs on port 3000 and proxies API requests to the backend running on port 5000.
on port 5000.

File diff suppressed because it is too large Load Diff

View File

@@ -1,36 +1,32 @@
{ {
"name": "frontend", "name": "frontend",
"version": "1.0.0", "version": "1.0.0",
"description": "koin_ping blockchain monitor UI", "description": "koin_ping blockchain monoitor UI",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview"
"test": "vite build", },
"lint": "prettier --check 'src/**/*.{js,jsx,ts,tsx,css}'", "keywords": [
"fmt": "prettier --write 'src/**/*.{js,jsx,ts,tsx,css}'" "cryptocurrency",
}, "blockchain",
"keywords": [ "blockchain event moitoring"
"cryptocurrency", ],
"blockchain", "author": "Steven Jannette",
"blockchain event monitoring" "license": "MIT",
], "type": "module",
"author": "Steven Jannette", "dependencies": {
"license": "MIT", "firebase": "^12.7.0",
"type": "module", "react": "^19.2.3",
"dependencies": { "react-dom": "^19.2.3",
"firebase": "^12.7.0", "react-router-dom": "^7.11.0"
"react": "^19.2.3", },
"react-dom": "^19.2.3", "devDependencies": {
"react-router-dom": "^7.11.0" "@types/react": "^19.2.7",
}, "@types/react-dom": "^19.2.3",
"devDependencies": { "@vitejs/plugin-react": "^5.1.2",
"@types/react": "^19.2.7", "typescript": "^5.9.3",
"@types/react-dom": "^19.2.3", "vite": "^7.3.0"
"@vitejs/plugin-react": "^5.1.2", }
"prettier": "^3.8.1", }
"typescript": "^5.9.3",
"vite": "^7.3.0"
}
}

View File

@@ -1,4 +0,0 @@
{
"tabWidth": 4,
"proseWrap": "always"
}

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -1,38 +1,60 @@
import { Routes, Route, Navigate } from "react-router-dom"; import { Routes, Route, Link, Navigate } from "react-router-dom";
import { useAuth } from "./contexts/AuthContext"; import { useAuth } from "./contexts/AuthContext";
import Navbar from "./components/Navbar"; import Login from "./pages/Login";
import Login from "./pages/login/Login";
import Signup from "./pages/Signup"; import Signup from "./pages/Signup";
import Subscribe from "./pages/subscribe/subscribe"; import Addresses from "./pages/Addresses";
import Addresses from "./pages/addresses/Addresses"; import Alerts from "./pages/Alerts";
import Alerts from "./pages/alerts/Alerts"; import AlertHistory from "./pages/AlertHistory";
import AlertHistory from "./pages/alertHistory/AlertHistory";
import Account from "./pages/user_account/Account";
export default function App() { export default function App() {
const { currentUser } = useAuth(); const { currentUser, logout } = useAuth();
// Show login/signup routes if not authenticated
if (!currentUser) { if (!currentUser) {
return ( return (
<Routes> <Routes>
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} /> <Route path="/signup" element={<Signup />} />
<Route path="/subscribe" element={<Subscribe />} />
<Route path="*" element={<Navigate to="/login" />} /> <Route path="*" element={<Navigate to="/login" />} />
</Routes> </Routes>
); );
} }
// Show main app if authenticated
return ( return (
<div> <div style={{ padding: "1rem" }}>
<Navbar /> <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>
<Routes> <Routes>
<Route path="/" element={<Addresses />} /> <Route path="/" element={<Addresses />} />
<Route path="/addresses" element={<Addresses />} /> <Route path="/addresses" element={<Addresses />} />
<Route path="/alerts" element={<Alerts />} /> <Route path="/alerts" element={<Alerts />} />
<Route path="/alertevents" element={<AlertHistory />} /> <Route path="/history" element={<AlertHistory />} />
<Route path="/account" element={<Account />} />
<Route path="/subscribe" element={<Subscribe />} />
<Route path="*" element={<Navigate to="/addresses" />} /> <Route path="*" element={<Navigate to="/addresses" />} />
</Routes> </Routes>
</div> </div>

View File

@@ -1,25 +0,0 @@
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();
}

View File

@@ -1,7 +1,7 @@
// API client for address management // API client for address management
import { getAuthHeaders, getAuthHeadersSimple } from "./authHeaders"; import { getAuthHeaders, getAuthHeadersSimple } from './authHeaders';
import { API_BASE } from "./config"; import { API_BASE } from './config';
/** /**
* Create a new blockchain address to monitor * Create a new blockchain address to monitor
@@ -11,37 +11,35 @@ import { API_BASE } from "./config";
* @returns {Promise<Object>} Created address with id and metadata * @returns {Promise<Object>} Created address with id and metadata
*/ */
export async function createAddress(data) { export async function createAddress(data) {
try { try {
const headers = await getAuthHeaders(); const headers = await getAuthHeaders();
const response = await fetch(`${API_BASE}/addresses`, { const response = await fetch(`${API_BASE}/addresses`, {
method: "POST", method: 'POST',
headers: headers, headers: headers,
body: JSON.stringify(data), body: JSON.stringify(data)
}); });
if (!response.ok) { if (!response.ok) {
// Try to parse error response, but handle if it's not JSON // Try to parse error response, but handle if it's not JSON
let errorMessage = "Failed to create address"; let errorMessage = 'Failed to create address';
try { try {
const error = await response.json(); const error = await response.json();
errorMessage = error.message || errorMessage; errorMessage = error.message || errorMessage;
} catch { } catch {
// If JSON parse fails, use status text // If JSON parse fails, use status text
errorMessage = `Server error: ${response.status} ${response.statusText}`; errorMessage = `Server error: ${response.status} ${response.statusText}`;
} }
throw new Error(errorMessage); throw new Error(errorMessage);
} }
return response.json(); return response.json();
} catch (error) { } catch (error) {
// Handle network errors // Handle network errors
if (error.message.includes("fetch")) { if (error.message.includes('fetch')) {
throw new Error( throw new Error('Cannot connect to server. Is the backend running?');
"Cannot connect to server. Is the backend running?",
);
}
throw error;
} }
throw error;
}
} }
/** /**
@@ -49,70 +47,33 @@ export async function createAddress(data) {
* @returns {Promise<Array>} List of all addresses * @returns {Promise<Array>} List of all addresses
*/ */
export async function getAddresses() { export async function getAddresses() {
try { try {
const headers = await getAuthHeadersSimple(); const headers = await getAuthHeadersSimple();
const response = await fetch(`${API_BASE}/addresses`, { const response = await fetch(`${API_BASE}/addresses`, {
headers: headers, headers: headers
}); });
if (!response.ok) { if (!response.ok) {
// Try to parse error response, but handle if it's not JSON // Try to parse error response, but handle if it's not JSON
let errorMessage = "Failed to fetch addresses"; let errorMessage = 'Failed to fetch addresses';
try { try {
const error = await response.json(); const error = await response.json();
errorMessage = error.message || errorMessage; errorMessage = error.message || errorMessage;
} catch { } catch {
// If JSON parse fails, use status text // If JSON parse fails, use status text
errorMessage = `Server error: ${response.status} ${response.statusText}`; errorMessage = `Server error: ${response.status} ${response.statusText}`;
} }
throw new Error(errorMessage); throw new Error(errorMessage);
} }
return response.json(); return response.json();
} catch (error) { } catch (error) {
// Handle network errors or JSON parse errors // Handle network errors or JSON parse errors
if (error.message.includes("fetch")) { if (error.message.includes('fetch')) {
throw new Error( throw new Error('Cannot connect to server. Is the backend running?');
"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;
}
} }
/** /**
@@ -121,32 +82,31 @@ export async function updateAddress(addressId, data) {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
export async function deleteAddress(addressId) { export async function deleteAddress(addressId) {
try { try {
const headers = await getAuthHeadersSimple(); const headers = await getAuthHeadersSimple();
const response = await fetch(`${API_BASE}/addresses/${addressId}`, { const response = await fetch(`${API_BASE}/addresses/${addressId}`, {
method: "DELETE", method: 'DELETE',
headers: headers, headers: headers
}); });
if (!response.ok && response.status !== 204) { if (!response.ok && response.status !== 204) {
// Try to parse error response, but handle if it's not JSON // Try to parse error response, but handle if it's not JSON
let errorMessage = "Failed to delete address"; let errorMessage = 'Failed to delete address';
try { try {
const error = await response.json(); const error = await response.json();
errorMessage = error.message || errorMessage; errorMessage = error.message || errorMessage;
} catch { } catch {
// If JSON parse fails, use status text // If JSON parse fails, use status text
errorMessage = `Server error: ${response.status} ${response.statusText}`; errorMessage = `Server error: ${response.status} ${response.statusText}`;
} }
throw new Error(errorMessage); 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;
}
} }

View File

@@ -1,37 +1,35 @@
// API client for alert event history // API client for alert event history
import { getAuthHeadersSimple } from "./authHeaders"; import { getAuthHeadersSimple } from './authHeaders';
import { API_BASE } from "./config"; import { API_BASE } from './config';
/** /**
* Get all alert events (history) * Get all alert events (history)
* @returns {Promise<Array>} List of alert events, sorted by most recent first * @returns {Promise<Array>} List of alert events, sorted by most recent first
*/ */
export async function getAlertEvents() { export async function getAlertEvents() {
try { try {
const headers = await getAuthHeadersSimple(); const headers = await getAuthHeadersSimple();
const response = await fetch(`${API_BASE}/alert-events`, { const response = await fetch(`${API_BASE}/alert-events`, {
headers: headers, headers: headers
}); });
if (!response.ok) { if (!response.ok) {
let errorMessage = "Failed to fetch alert events"; let errorMessage = 'Failed to fetch alert events';
try { try {
const error = await response.json(); const error = await response.json();
errorMessage = error.message || errorMessage; errorMessage = error.message || errorMessage;
} catch { } catch {
errorMessage = `Server error: ${response.status} ${response.statusText}`; errorMessage = `Server error: ${response.status} ${response.statusText}`;
} }
throw new Error(errorMessage); throw new Error(errorMessage);
} }
return response.json(); return response.json();
} catch (error) { } catch (error) {
if (error.message.includes("fetch")) { if (error.message.includes('fetch')) {
throw new Error( throw new Error('Cannot connect to server. Is the backend running?');
"Cannot connect to server. Is the backend running?",
);
}
throw error;
} }
throw error;
}
} }

View File

@@ -1,16 +1,16 @@
// API client for alert rule management // API client for alert rule management
import { getAuthHeaders, getAuthHeadersSimple } from "./authHeaders"; import { getAuthHeaders, getAuthHeadersSimple } from './authHeaders';
import { API_BASE } from "./config"; import { API_BASE } from './config';
/** /**
* Alert types supported by the system * Alert types supported by the system
*/ */
export const ALERT_TYPES = { export const ALERT_TYPES = {
INCOMING_TX: "incoming_tx", INCOMING_TX: 'incoming_tx',
OUTGOING_TX: "outgoing_tx", OUTGOING_TX: 'outgoing_tx',
LARGE_TRANSFER: "large_transfer", LARGE_TRANSFER: 'large_transfer',
BALANCE_BELOW: "balance_below", BALANCE_BELOW: 'balance_below'
}; };
/** /**
@@ -22,37 +22,32 @@ export const ALERT_TYPES = {
* @returns {Promise<Object>} Created alert rule * @returns {Promise<Object>} Created alert rule
*/ */
export async function createAlert(addressId, data) { export async function createAlert(addressId, data) {
try { try {
const headers = await getAuthHeaders(); const headers = await getAuthHeaders();
const response = await fetch( const response = await fetch(`${API_BASE}/addresses/${addressId}/alerts`, {
`${API_BASE}/addresses/${addressId}/alerts`, method: 'POST',
{ headers: headers,
method: "POST", body: JSON.stringify(data)
headers: headers, });
body: JSON.stringify(data),
}, if (!response.ok) {
); let errorMessage = 'Failed to create alert rule';
try {
if (!response.ok) { const error = await response.json();
let errorMessage = "Failed to create alert rule"; errorMessage = error.message || errorMessage;
try { } catch {
const error = await response.json(); errorMessage = `Server error: ${response.status} ${response.statusText}`;
errorMessage = error.message || errorMessage; }
} catch { throw new Error(errorMessage);
errorMessage = `Server error: ${response.status} ${response.statusText}`; }
}
throw new Error(errorMessage); return response.json();
} } catch (error) {
if (error.message.includes('fetch')) {
return response.json(); throw new Error('Cannot connect to server. Is the backend running?');
} catch (error) {
if (error.message.includes("fetch")) {
throw new Error(
"Cannot connect to server. Is the backend running?",
);
}
throw error;
} }
throw error;
}
} }
/** /**
@@ -61,35 +56,30 @@ export async function createAlert(addressId, data) {
* @returns {Promise<Array>} List of alert rules * @returns {Promise<Array>} List of alert rules
*/ */
export async function getAlerts(addressId) { export async function getAlerts(addressId) {
try { try {
const headers = await getAuthHeadersSimple(); const headers = await getAuthHeadersSimple();
const response = await fetch( const response = await fetch(`${API_BASE}/addresses/${addressId}/alerts`, {
`${API_BASE}/addresses/${addressId}/alerts`, headers: headers
{ });
headers: headers,
}, if (!response.ok) {
); let errorMessage = 'Failed to fetch alert rules';
try {
if (!response.ok) { const error = await response.json();
let errorMessage = "Failed to fetch alert rules"; errorMessage = error.message || errorMessage;
try { } catch {
const error = await response.json(); errorMessage = `Server error: ${response.status} ${response.statusText}`;
errorMessage = error.message || errorMessage; }
} catch { throw new Error(errorMessage);
errorMessage = `Server error: ${response.status} ${response.statusText}`; }
}
throw new Error(errorMessage); return response.json();
} } catch (error) {
if (error.message.includes('fetch')) {
return response.json(); throw new Error('Cannot connect to server. Is the backend running?');
} catch (error) {
if (error.message.includes("fetch")) {
throw new Error(
"Cannot connect to server. Is the backend running?",
);
}
throw error;
} }
throw error;
}
} }
/** /**
@@ -99,34 +89,32 @@ export async function getAlerts(addressId) {
* @returns {Promise<Object>} Updated alert rule * @returns {Promise<Object>} Updated alert rule
*/ */
export async function updateAlertStatus(alertId, enabled) { export async function updateAlertStatus(alertId, enabled) {
try { try {
const headers = await getAuthHeaders(); const headers = await getAuthHeaders();
const response = await fetch(`${API_BASE}/alerts/${alertId}`, { const response = await fetch(`${API_BASE}/alerts/${alertId}`, {
method: "PATCH", method: 'PATCH',
headers: headers, headers: headers,
body: JSON.stringify({ enabled }), body: JSON.stringify({ enabled })
}); });
if (!response.ok) { if (!response.ok) {
let errorMessage = "Failed to update alert rule"; let errorMessage = 'Failed to update alert rule';
try { try {
const error = await response.json(); const error = await response.json();
errorMessage = error.message || errorMessage; errorMessage = error.message || errorMessage;
} catch { } catch {
errorMessage = `Server error: ${response.status} ${response.statusText}`; errorMessage = `Server error: ${response.status} ${response.statusText}`;
} }
throw new Error(errorMessage); throw new Error(errorMessage);
} }
return response.json(); return response.json();
} catch (error) { } catch (error) {
if (error.message.includes("fetch")) { if (error.message.includes('fetch')) {
throw new Error( throw new Error('Cannot connect to server. Is the backend running?');
"Cannot connect to server. Is the backend running?",
);
}
throw error;
} }
throw error;
}
} }
/** /**
@@ -135,29 +123,27 @@ export async function updateAlertStatus(alertId, enabled) {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
export async function deleteAlert(alertId) { export async function deleteAlert(alertId) {
try { try {
const headers = await getAuthHeadersSimple(); const headers = await getAuthHeadersSimple();
const response = await fetch(`${API_BASE}/alerts/${alertId}`, { const response = await fetch(`${API_BASE}/alerts/${alertId}`, {
method: "DELETE", method: 'DELETE',
headers: headers, headers: headers
}); });
if (!response.ok && response.status !== 204) { if (!response.ok && response.status !== 204) {
let errorMessage = "Failed to delete alert rule"; let errorMessage = 'Failed to delete alert rule';
try { try {
const error = await response.json(); const error = await response.json();
errorMessage = error.message || errorMessage; errorMessage = error.message || errorMessage;
} catch { } catch {
errorMessage = `Server error: ${response.status} ${response.statusText}`; errorMessage = `Server error: ${response.status} ${response.statusText}`;
} }
throw new Error(errorMessage); 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;
}
} }

View File

@@ -1,30 +1,30 @@
/** /**
* Auth Headers Helper * Auth Headers Helper
* *
* Provides authentication headers for API calls * Provides authentication headers for API calls
* Includes Firebase ID token in Authorization header * Includes Firebase ID token in Authorization header
*/ */
import { auth } from "../firebase/config"; import { auth } from '../firebase/config';
/** /**
* Get headers with authentication token * Get headers with authentication token
* @returns {Promise<Object>} Headers object with Authorization * @returns {Promise<Object>} Headers object with Authorization
*/ */
export async function getAuthHeaders() { export async function getAuthHeaders() {
const currentUser = auth.currentUser; const currentUser = auth.currentUser;
if (!currentUser) {
throw new Error('No authenticated user');
}
if (!currentUser) { // Get Firebase ID token
throw new Error("No authenticated user"); const token = await currentUser.getIdToken();
}
// Get Firebase ID token return {
const token = await currentUser.getIdToken(); 'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
return { };
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
};
} }
/** /**
@@ -32,15 +32,16 @@ export async function getAuthHeaders() {
* @returns {Promise<Object>} Headers object with Authorization * @returns {Promise<Object>} Headers object with Authorization
*/ */
export async function getAuthHeadersSimple() { export async function getAuthHeadersSimple() {
const currentUser = auth.currentUser; const currentUser = auth.currentUser;
if (!currentUser) {
throw new Error('No authenticated user');
}
if (!currentUser) { const token = await currentUser.getIdToken();
throw new Error("No authenticated user");
}
const token = await currentUser.getIdToken(); return {
'Authorization': `Bearer ${token}`
return { };
Authorization: `Bearer ${token}`,
};
} }

View File

@@ -1 +1 @@
export const API_BASE = import.meta.env.VITE_API_BASE || "/v1"; export const API_BASE = import.meta.env.VITE_API_BASE || '/v1';

View File

@@ -1,183 +1,116 @@
import { getAuthHeaders } from "./authHeaders"; import { getAuthHeaders } from './authHeaders';
import { API_BASE } from "./config"; import { API_BASE } from './config';
/** /**
* Get notification configuration for current user * Get notification configuration for current user
* @returns {Promise<Object>} Notification config * @returns {Promise<Object>} Notification config
*/ */
export async function getNotificationConfig() { export async function getNotificationConfig() {
try { try {
const headers = await getAuthHeaders(); const headers = await getAuthHeaders();
const response = await fetch(`${API_BASE}/notification-config`, { const response = await fetch(`${API_BASE}/notification-config`, {
headers: headers, headers: headers
}); });
if (!response.ok) { if (!response.ok) {
let errorMessage = "Failed to fetch notification config"; let errorMessage = 'Failed to fetch notification config';
try { try {
const error = await response.json(); const error = await response.json();
errorMessage = error.message || errorMessage; errorMessage = error.message || errorMessage;
} catch { } catch {
errorMessage = `Server error: ${response.status} ${response.statusText}`; errorMessage = `Server error: ${response.status} ${response.statusText}`;
} }
throw new Error(errorMessage); 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;
} }
return response.json();
} catch (error) {
if (error.message.includes('fetch')) {
throw new Error('Cannot connect to server. Is the backend running?');
}
throw error;
}
} }
/** /**
* Update notification configuration * Update notification configuration
* @param {Object} config * @param {Object} config - Configuration to update
* @param {string} [config.discord_webhook_url] * @param {string} [config.discord_webhook_url] - Discord webhook URL
* @param {string} [config.telegram_chat_id] * @param {string} [config.slack_webhook_url] - Slack webhook URL
* @param {string} [config.telegram_bot_token] * @param {string} [config.email] - Email address
* @param {string} [config.email] * @param {boolean} [config.notification_enabled] - Enable/disable notifications
* @param {string} [config.slack_webhook_url]
* @param {boolean} [config.notification_enabled]
* @returns {Promise<Object>} Updated config * @returns {Promise<Object>} Updated config
*/ */
export async function updateNotificationConfig(config) { export async function updateNotificationConfig(config) {
try { try {
const headers = await getAuthHeaders(); const headers = await getAuthHeaders();
const response = await fetch(`${API_BASE}/notification-config`, { const response = await fetch(`${API_BASE}/notification-config`, {
method: "PUT", method: 'PUT',
headers: headers, headers: headers,
body: JSON.stringify(config), body: JSON.stringify(config)
}); });
if (!response.ok) { if (!response.ok) {
let errorMessage = "Failed to update notification config"; let errorMessage = 'Failed to update notification config';
try { try {
const error = await response.json(); const error = await response.json();
errorMessage = error.message || errorMessage; errorMessage = error.message || errorMessage;
} catch { } catch {
errorMessage = `Server error: ${response.status} ${response.statusText}`; errorMessage = `Server error: ${response.status} ${response.statusText}`;
} }
throw new Error(errorMessage); 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;
} }
return response.json();
} catch (error) {
if (error.message.includes('fetch')) {
throw new Error('Cannot connect to server. Is the backend running?');
}
throw error;
}
} }
/** /**
* Set up email notifications via Resend. * Test a webhook URL by sending a test message via the backend
* Reads the user's saved email from their config and sends a confirmation. * @param {string} type - 'discord' or 'slack'
* @returns {Promise<Object>} Setup result * @param {string} webhookUrl - Webhook URL to test
* @returns {Promise<boolean>} True if test successful
*/ */
export async function setupEmail() { async function testWebhook(type, webhookUrl) {
try { try {
const headers = await getAuthHeaders(); const headers = await getAuthHeaders();
const response = await fetch(`${API_BASE}/email/setup`, { const response = await fetch(`${API_BASE}/notification-config/test`, {
method: "POST", method: 'POST',
headers: headers, headers: headers,
}); body: JSON.stringify({ type, url: webhookUrl })
});
if (!response.ok) { if (!response.ok) {
let errorMessage = "Failed to set up email"; return false;
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;
}
} }
/** /**
* Send an alert digest email to the user's configured email address. * Test a Discord webhook URL
* @returns {Promise<Object>} Digest send result * @param {string} webhookUrl - Discord webhook URL to test
* @returns {Promise<boolean>} True if test successful
*/ */
export async function sendEmailDigest() { export async function testDiscordWebhook(webhookUrl) {
try { return testWebhook('discord', webhookUrl);
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 all configured notification channels via the backend * Test a Slack webhook URL
* @returns {Promise<Object>} Results per channel * @param {string} webhookUrl - Slack webhook URL to test
* @returns {Promise<boolean>} True if test successful
*/ */
export async function testNotificationChannels() { export async function testSlackWebhook(webhookUrl) {
try { return testWebhook('slack', webhookUrl);
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;
}
} }

View File

@@ -1,18 +1,18 @@
// API client for system status // API client for system status
import { API_BASE } from "./config"; import { API_BASE } from './config';
/** /**
* Get system status including latest processed block and health * Get system status including latest processed block and health
* @returns {Promise<Object>} System status * @returns {Promise<Object>} System status
*/ */
export async function getSystemStatus() { export async function getSystemStatus() {
const response = await fetch(`${API_BASE}/status`); const response = await fetch(`${API_BASE}/status`);
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
throw new Error(error.message || "Failed to fetch system status"); throw new Error(error.message || 'Failed to fetch system status');
} }
return response.json(); return response.json();
} }

View File

@@ -1,41 +0,0 @@
import { getAuthHeaders } from "./authHeaders";
import { API_BASE } from "./config";
export async function createCheckoutSession() {
const headers = await getAuthHeaders();
const res = await fetch(`${API_BASE}/stripe/create-checkout-session`, {
method: "POST",
headers,
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message || "Failed to create checkout session");
}
return res.json();
}
export async function verifyCheckoutSession(sessionId) {
const headers = await getAuthHeaders();
const res = await fetch(`${API_BASE}/stripe/verify-checkout`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message || "Failed to verify checkout session");
}
return res.json();
}
export async function getSubscriptionStatus() {
const headers = await getAuthHeaders();
const res = await fetch(`${API_BASE}/stripe/subscription-status`, {
headers,
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message || "Failed to get subscription status");
}
return res.json();
}

View File

@@ -3,43 +3,43 @@ import Input from "./Input";
import Button from "./Button"; import Button from "./Button";
export default function AddressForm({ onSubmit }) { export default function AddressForm({ onSubmit }) {
const [address, setAddress] = useState(""); const [address, setAddress] = useState("");
const [label, setLabel] = useState(""); const [label, setLabel] = useState("");
const canSubmit = address.trim().length > 0; const canSubmit = address.trim().length > 0;
function handleSubmit(e) { function handleSubmit(e) {
e.preventDefault(); e.preventDefault();
if (!canSubmit) return; if (!canSubmit) return;
onSubmit({ onSubmit({
address: address.trim(), address: address.trim(),
label: label.trim(), label: label.trim(),
}); });
setAddress(""); setAddress("");
setLabel(""); setLabel("");
} }
return ( return (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<Input <Input
label="Blockchain Address" label="Blockchain Address"
value={address} value={address}
onChange={setAddress} onChange={setAddress}
placeholder="0x..." placeholder="0x..."
/> />
<Input <Input
label="Label (optional)" label="Label (optional)"
value={label} value={label}
onChange={setLabel} onChange={setLabel}
placeholder="Treasury, Cold Wallet, etc." placeholder="Treasury, Cold Wallet, etc."
/> />
<Button type="submit" disabled={!canSubmit} className="mt-md"> <Button type="submit" disabled={!canSubmit}>
Add Address Add Address
</Button> </Button>
</form> </form>
); );
} }

View File

@@ -1,7 +0,0 @@
.alert-form__options {
margin-bottom: 1rem;
}
.alert-form__radio {
display: block;
}

View File

@@ -1,72 +1,71 @@
import { useState } from "react"; import { useState } from "react";
import Input from "./Input"; import Input from "./Input";
import Button from "./Button"; import Button from "./Button";
import "./AlertForm.css";
const ALERT_TYPES = [ const ALERT_TYPES = [
{ value: "incoming_tx", label: "Incoming transaction" }, { value: "incoming_tx", label: "Incoming transaction" },
{ value: "outgoing_tx", label: "Outgoing transaction" }, { value: "outgoing_tx", label: "Outgoing transaction" },
{ value: "large_transfer", label: "Large transfer" }, { value: "large_transfer", label: "Large transfer" },
{ value: "balance_below", label: "Balance below threshold" }, { value: "balance_below", label: "Balance below threshold" },
]; ];
export default function AlertForm({ onSubmit }) { export default function AlertForm({ onSubmit }) {
const [type, setType] = useState("incoming_tx"); const [type, setType] = useState("incoming_tx");
const [threshold, setThreshold] = useState(""); const [threshold, setThreshold] = useState("");
const needsThreshold = const needsThreshold =
type === "large_transfer" || type === "balance_below"; type === "large_transfer" || type === "balance_below";
const canSubmit = needsThreshold const canSubmit = needsThreshold
? threshold.trim() !== "" && ? threshold.trim() !== "" &&
!isNaN(Number(threshold)) && !isNaN(Number(threshold)) &&
Number(threshold) > 0 Number(threshold) > 0
: true; : true;
function handleSubmit(e) { function handleSubmit(e) {
e.preventDefault(); e.preventDefault();
if (!canSubmit) return; if (!canSubmit) return;
onSubmit({ onSubmit({
type, type,
threshold: needsThreshold ? Number(threshold) : undefined, threshold: needsThreshold ? Number(threshold) : undefined,
}); });
setThreshold(""); setThreshold("");
} }
return ( return (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="alert-form__options"> <div style={{ marginBottom: "1rem" }}>
{ALERT_TYPES.map((opt) => ( {ALERT_TYPES.map((opt) => (
<label key={opt.value} className="alert-form__radio"> <label key={opt.value} style={{ display: "block" }}>
<input <input
type="radio" type="radio"
name="alertType" name="alertType"
value={opt.value} value={opt.value}
checked={type === opt.value} checked={type === opt.value}
onChange={() => setType(opt.value)} onChange={() => setType(opt.value)}
/>{" "} />
{opt.label} {" "}{opt.label}
</label> </label>
))} ))}
</div> </div>
{needsThreshold && ( {needsThreshold && (
<Input <Input
label="Amount (ETH)" label="Amount (ETH)"
type="number" type="number"
step="0.000001" step="0.000001"
min="0" min="0"
value={threshold} value={threshold}
onChange={setThreshold} onChange={setThreshold}
placeholder="e.g. 10" placeholder="e.g. 10"
/> />
)} )}
<Button type="submit" disabled={!canSubmit}> <Button type="submit" disabled={!canSubmit}>
Create Alert Create Alert
</Button> </Button>
</form> </form>
); );
} }

Some files were not shown because too many files have changed in this diff Show More