Compare commits

...

86 Commits

Author SHA1 Message Date
S Jannette
7b45c5b2ab Merge pull request #26 from kjannette/add-alert-thresholds
enhanced alert granularity with min/max thresholds
2026-03-06 23:39:15 -05:00
KS Jannette
0fb4dcd7d1 enhanced alert granularity with min/max thresholds 2026-03-06 23:33:12 -05:00
KS Jannette
d3530deb46 m 2026-03-06 03:09:44 -05:00
KS Jannette
aadcdc628e hot 3 2026-03-06 03:08:47 -05:00
KS Jannette
2d4fcefdcf hottie 2026-03-06 03:00:14 -05:00
KS Jannette
81303573fd quick style changes 2026-03-06 02:51:35 -05:00
KS Jannette
76fadf05d9 hot f 2026-03-05 16:14:51 -05:00
KS Jannette
b9e3d8ae82 hotfix 2026-03-05 14:52:35 -05:00
S Jannette
6c5ffc2340 Merge pull request #25 from kjannette/media-queries-2
mobile navigation
2026-03-05 11:04:07 -05:00
KS Jannette
e32aa4b6aa mobile navigation
Some checks are pending
check / check (push) Waiting to run
2026-03-05 11:03:42 -05:00
S Jannette
682c168250 Merge pull request #24 from kjannette/media-queries
media queries
2026-03-05 10:31:18 -05:00
KS Jannette
e5c8f91e3f media queries
Some checks are pending
check / check (push) Waiting to run
2026-03-05 10:29:37 -05:00
S Jannette
f6255ec1f1 Merge pull request #23 from kjannette/account-info-page
Account info page
2026-03-05 08:26:39 -05:00
KS Jannette
4b77e7a4d4 add user account page and associated functionaility to support it
Some checks are pending
check / check (push) Waiting to run
2026-03-05 08:25:24 -05:00
KS Jannette
8f1212813f edits 2026-03-05 07:39:46 -05:00
S Jannette
95ec1a520a Merge pull request #22 from kjannette/frontend-feature-roundup
style
2026-03-05 07:27:41 -05:00
KS Jannette
4b228d9b57 style
Some checks are pending
check / check (push) Waiting to run
2026-03-05 07:19:12 -05:00
S Jannette
08bc3e1ea2 Merge pull request #21 from kjannette/poller-refinements-3
adjust rpc call to eth block timing
2026-03-05 06:51:54 -05:00
KS Jannette
a091d39c08 adjust rpc call to eth block timing
Some checks are pending
check / check (push) Waiting to run
2026-03-05 06:48:11 -05:00
S Jannette
35699a41bd Merge pull request #20 from kjannette/loginpage-style
Loginpage style
2026-03-05 02:18:22 -05:00
KS Jannette
2e96982d3a m
Some checks are pending
check / check (push) Waiting to run
2026-03-05 02:17:11 -05:00
KS Jannette
e48de2d6b6 style tweaks 2026-03-05 02:14:55 -05:00
KS Jannette
9414af8108 adjust css 2026-03-05 02:07:51 -05:00
S Jannette
068ca2f834 Merge pull request #19 from kjannette/more-poller-refinement
Some checks are pending
check / check (push) Waiting to run
Added PermanentError type, IsPermanent() helper.
2026-03-05 00:00:49 -05:00
KS Jannette
7f10bcb7de Added PermanentError type, IsPermanent() helper. Permanent errors are logged and not retried
Some checks are pending
check / check (push) Waiting to run
2026-03-05 00:00:09 -05:00
S Jannette
f91ca33752 Merge pull request #18 from kjannette/cleanup
general cleanup - removed old comments, enforced naming conventions etc
2026-03-04 23:32:01 -05:00
KS Jannette
2c86bba235 general cleanup - removed old comments, enforced naming conventions etc
Some checks are pending
check / check (push) Waiting to run
2026-03-04 23:29:58 -05:00
S Jannette
44dad43f1d Merge pull request #17 from kjannette/poller-tweaks
updated evaluator service -
2026-03-04 23:16:56 -05:00
KS Jannette
2fe0e5b8e9 updated evaluator service - added semaphore.Weighted(5) and sync.WaitGroup etc to cap conncurrent requests; also WaitForNotifications() so caller blocks until notifications finish. jsonrpc.go -- finally, bumped rpcRetryBaseMS from 1000 to 2000 - RPC retries at 2s/4s/8s backoff rate
Some checks are pending
check / check (push) Waiting to run
2026-03-04 23:14:55 -05:00
S Jannette
8f08105246 Merge pull request #16 from kjannette/stripe-2
Stripe 2
2026-03-04 22:43:49 -05:00
KS Jannette
2bc06669e9 config
Some checks are pending
check / check (push) Waiting to run
2026-03-04 22:40:36 -05:00
KS Jannette
a2d1ff9d76 restructure pages dir 2026-03-04 22:07:05 -05:00
S Jannette
dcbf0756d2 Merge pull request #15 from kjannette/stripe
Stripe
2026-03-04 21:39:48 -05:00
KS Jannette
239ea7db85 addition Stripe payment integration work
Some checks are pending
check / check (push) Waiting to run
2026-03-04 21:38:36 -05:00
KS Jannette
9ad414313b Stripe integration 2026-03-04 15:08:37 -05:00
S Jannette
3ee1598478 Merge pull request #14 from kjannette/feat-discrete-accounts
Feat discrete accounts
2026-03-03 19:14:00 -05:00
KS Jannette
91336130b9 discrete user accounts
Some checks are pending
check / check (push) Waiting to run
2026-03-03 19:10:24 -05:00
KS Jannette
e4e859d051 style 2026-03-03 18:39:20 -05:00
S Jannette
6f6a2c6d18 Merge pull request #13 from kjannette/refactor-css
Refactor css
2026-03-02 03:43:34 -05:00
KS Jannette
63eecaaf72 m
Some checks are pending
check / check (push) Waiting to run
2026-03-02 03:42:58 -05:00
KS Jannette
8a5f5db903 refact css sytem 2026-03-02 03:36:21 -05:00
KS Jannette
c2515472b2 more css hottie 2026-03-02 00:34:34 -05:00
S Jannette
5ad51271af Merge pull request #12 from kjannette/migrate-inline-css
Migrate inline css
2026-03-02 00:28:46 -05:00
KS Jannette
b3a08e8f25 css trick
Some checks are pending
check / check (push) Waiting to run
2026-03-02 00:28:13 -05:00
KS Jannette
8716be0201 fudge 2026-03-02 00:09:24 -05:00
KS Jannette
647be874e5 m 2026-03-02 00:09:01 -05:00
KS Jannette
c12c4fe742 add image 2026-03-01 23:14:25 -05:00
S Jannette
5f5253d495 Revise README for clarity and feature description
Updated the README to clarify features and improve readability.
2026-03-01 21:48:53 -05:00
S Jannette
bbe2374703 Merge pull request #11 from kjannette/history-reconfig-possible-bugfix
History reconfig possible bugfix
2026-03-01 20:38:56 -05:00
KS Jannette
205317c920 update docs
Some checks are pending
check / check (push) Waiting to run
2026-03-01 20:35:08 -05:00
KS Jannette
25850825b4 updated types and poller 2026-03-01 20:32:21 -05:00
S Jannette
f01115de86 Merge pull request #10 from kjannette/webhook-configs
webhook configs for users, changes to system
2026-03-01 19:08:42 -05:00
KS Jannette
6735000050 webhook configs for users, changes to system
Some checks are pending
check / check (push) Waiting to run
2026-03-01 19:06:32 -05:00
KS Jannette
42a64fc043 Add multi-step onboarding wizard to replace bare signup form
Introduces a 5-step guided flow (Create Account → Add Wallet → Alert
Rules → Notifications → Done) that takes new users from zero to fully
configured alerts in one linear session. /signup now redirects to
/onboarding; already-onboarded users who revisit /onboarding are
redirected to /addresses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 12:54:55 -05:00
S Jannette
9935817fa8 Merge pull request #9 from kjannette/setupTelegramonfig
Setup telegramonfig
2026-03-01 09:49:16 -05:00
KS Jannette
8d5716bdb7 Add guide for setup of Telegram
Some checks are pending
check / check (push) Waiting to run
2026-03-01 09:48:52 -05:00
KS Jannette
aafe96d9a1 Finish config 2026-03-01 09:40:54 -05:00
S Jannette
93168209b5 Merge pull request #8 from kjannette/audit1
Audit1
2026-03-01 07:55:57 -05:00
S Jannette
6840af1a47 Merge branch 'master' into audit1 2026-03-01 07:55:49 -05:00
KS Jannette
7c36f3c214 readme change
Some checks are pending
check / check (push) Waiting to run
2026-03-01 07:55:00 -05:00
S Jannette
c707b82f36 Revise README with new project overview
Updated project description and added overview section.
2026-03-01 07:51:57 -05:00
KS Jannette
37ff644aa7 audit: fix issues #1-#9 (mock data, notifier interface, retry/timeout, digest scheduling, real status, RPC retry, dedup, address edit UI)
- #1: remove mock alert events that poisoned production responses
- #2: add Notifier interface + Send implementations for all channels
- #3/#4: notification goroutines now carry a 30s context timeout and retry up to 3x with exponential backoff
- #5: schedule email digest in poller via DIGEST_INTERVAL_HOURS (default 24h)
- #6: SystemStatus queries real checkpoint data; returns starting/active/idle with latestBlock and lag
- #7: Ethereum RPC client retries on network errors, 429, and 5xx with 1s/2s backoff
- #8: alert_events dedup index + ON CONFLICT DO NOTHING to prevent duplicate events on poller restart
- #9: PATCH /v1/addresses/{id} for label editing; frontend address list gains inline edit and remove buttons
2026-03-01 07:49:08 -05:00
S Jannette
c80ed89c0a Merge pull request #7 from kjannette/resend-sdk
add sdk
2026-03-01 04:37:58 -05:00
KS Jannette
6590cbf4be add sdk
Some checks are pending
check / check (push) Waiting to run
2026-03-01 04:31:32 -05:00
KS Jannette
61a8c5fe8b hottie
Some checks are pending
check / check (push) Waiting to run
2026-03-01 03:54:06 -05:00
S Jannette
c79136a06e Merge pull request #6 from kjannette/db-updates-to-keep-up-withe-alerts-functionality
notifcation dongif setttings - save to DB
2026-03-01 03:37:04 -05:00
KS Jannette
a5590a438f notifcation dongif setttings - save to DB
Some checks are pending
check / check (push) Waiting to run
2026-03-01 03:36:32 -05:00
S Jannette
1c196cae9f Merge pull request #5 from kjannette/notifcations-logic-upgrade
Building out notification update channels
2026-03-01 03:23:31 -05:00
KS Jannette
c494bf5f53 Building out notification update channels
Some checks are pending
check / check (push) Waiting to run
2026-03-01 03:23:08 -05:00
S Jannette
d07dc971fe Merge pull request #4 from kjannette/style2
more style, mostly of w3 compliance
2026-03-01 02:06:25 -05:00
KS Jannette
112c9d0627 more style, mostly of w3 compliance
Some checks are pending
check / check (push) Waiting to run
2026-03-01 02:05:36 -05:00
S Jannette
b1e63a24c7 Merge pull request #3 from kjannette/format-majorly
Format majorly
2026-02-28 20:43:34 -05:00
KS Jannette
7357315810 Cleanup
Some checks are pending
check / check (push) Waiting to run
2026-02-28 20:42:57 -05:00
KS Jannette
90a338d46c fixed numerous linter issues 2026-02-28 20:17:55 -05:00
KS Jannette
230dc99dba Add .claude/ to .prettierignore
Prevents prettier from checking Claude Code's settings file.
2026-02-28 20:08:17 -05:00
KS Jannette
62e6c64ddb fmt: apply prettier to Markdown and doc files 2026-02-28 20:07:47 -05:00
KS Jannette
da8b45012a fmt: apply prettier to frontend JS/TS/CSS/JSON files
Initial prettier pass with tabWidth=4.
2026-02-28 20:07:28 -05:00
KS Jannette
a5d1bc171c fmt: apply gofmt to Go source files
Alignment of struct literal field values in config.go and domain/types.go.
2026-02-28 20:07:04 -05:00
KS Jannette
4f6ed5dd20 Add Dockerfile, .dockerignore, and Gitea Actions workflow
Dockerfile: golang:1.24-alpine (pinned by sha256), installs Node.js,
npm, golangci-lint (pinned by commit hash), and runs make check as a
build step so the image only builds on a green branch.

.gitea/workflows/check.yml: runs docker build . on push (actions/checkout
pinned by commit sha).
2026-02-28 20:05:45 -05:00
KS Jannette
6901c759da Add .prettierrc, .prettierignore; update frontend/package.json scripts
- Root .prettierrc sets tabWidth=4 and proseWrap=always
- Root .prettierignore excludes node_modules, build output, lockfiles
- package.json: adds test (vite build), lint, fmt scripts; adds prettier
  as a devDependency; fixes typos in description and keywords

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 20:02:48 -05:00
KS Jannette
b283d0f835 Add backend-go/.golangci.yml
Standard golangci-lint v2 config fetched from the canonical upstream
source. Must not be modified by agents — only manually by the user.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 20:02:16 -05:00
KS Jannette
aac62bacf4 Update README with all required sections
Adds Description (with project name, category, license, author),
Getting Started, Rationale, Design, TODO, License, and Author sections
per REPO_POLICIES.md requirements.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 20:02:01 -05:00
KS Jannette
5c721ca271 Add REPO_POLICIES.md and LICENSE
REPO_POLICIES.md is copied from the canonical upstream source
(last_modified: 2026-02-22). LICENSE is MIT, copyright 2026 Steven Jannette.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 20:01:31 -05:00
KS Jannette
2fcbcf5b54 Update .gitignore to be comprehensive
Adds OS files, editor artifacts, Node modules, secrets, Go build
artifacts, and frontend build output to the root gitignore.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 20:00:15 -05:00
KS Jannette
21ae859d1c Add .editorconfig
Enforces 4-space indentation (tabs for Go and Makefiles), LF line
endings, UTF-8 encoding, and trailing-whitespace trimming.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 19:59:22 -05:00
KS Jannette
6648cd347d Add root Makefile with all required targets
Adds test, lint, fmt, fmt-check, check, docker, and hooks targets
delegating to the Go backend and JS frontend sub-projects.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 19:59:04 -05:00
121 changed files with 10982 additions and 5525 deletions

3
.dockerignore Normal file
View File

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

15
.editorconfig Normal file
View File

@@ -0,0 +1,15 @@
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[Makefile]
indent_style = tab
[*.go]
indent_style = tab

45
.gitignore vendored Normal file
View File

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

7
.prettierignore Normal file
View File

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

4
.prettierrc Normal file
View File

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

22
Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
# syntax=docker/dockerfile:1
# golang:1.24-alpine, 2025-02-28
FROM golang:1.24-alpine@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191
# Install system dependencies: Node.js, npm, make, git
RUN apk add --no-cache nodejs npm make git
# Install golangci-lint at a pinned commit hash
# golangci-lint v2.10.1 (2025-02-17), commit 5d1e709b7be35cb2025444e19de266b056b7b7ee
RUN GOBIN=/usr/local/bin go install \
github.com/golangci/golangci-lint/cmd/golangci-lint@5d1e709b7be35cb2025444e19de266b056b7b7ee
WORKDIR /app
COPY . .
# Install frontend dependencies
RUN cd frontend && npm ci
# Run all checks — build fails if the branch is not green
RUN make check

20
LICENSE Normal file
View File

@@ -0,0 +1,20 @@
MIT License
Copyright (c) 2026 Steven Jannette
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

90
Makefile Normal file
View File

@@ -0,0 +1,90 @@
.PHONY: test lint fmt fmt-check check docker hooks build run dev clean \
test-go test-js lint-go lint-js fmt-go fmt-js fmt-check-go fmt-check-js \
build-go build-js
GODIR := backend
JSDIR := frontend
PRETTIER := $(JSDIR)/node_modules/.bin/prettier
# ── required targets ────────────────────────────────────────────
all: check
check: test lint fmt-check
test: test-go test-js
lint: lint-go lint-js
fmt: fmt-go fmt-js
fmt-check: fmt-check-go fmt-check-js
# ── go ──────────────────────────────────────────────────────────
test-go:
cd $(GODIR) && go test -timeout 30s ./...
lint-go:
cd $(GODIR) && golangci-lint run ./...
fmt-go:
cd $(GODIR) && gofmt -w .
fmt-check-go:
@diff=$$(cd $(GODIR) && gofmt -l .); \
if [ -n "$$diff" ]; then \
echo "Go files need formatting:"; \
echo "$$diff"; \
exit 1; \
fi
# ── js / frontend ───────────────────────────────────────────────
test-js:
cd $(JSDIR) && npm test
lint-js:
$(PRETTIER) --check '**/*.{js,jsx,ts,tsx,css,md,json}'
fmt-js:
$(PRETTIER) --write '**/*.{js,jsx,ts,tsx,css,md,json}'
fmt-check-js:
$(PRETTIER) --check '**/*.{js,jsx,ts,tsx,css,md,json}'
# ── build ───────────────────────────────────────────────────────
build: build-go build-js
build-go:
cd $(GODIR) && go build -o bin/api ./cmd/api
cd $(GODIR) && go build -o bin/poller ./cmd/poller
build-js:
cd $(JSDIR) && npm run build
# ── dev / run ───────────────────────────────────────────────────
run:
cd $(GODIR) && go run ./cmd/api
dev:
cd $(GODIR) && go run ./cmd/api
# ── docker ──────────────────────────────────────────────────────
docker:
docker build -t koin-ping .
# ── hooks ───────────────────────────────────────────────────────
hooks:
printf '#!/bin/sh\nmake check\n' > .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
# ── clean ───────────────────────────────────────────────────────
clean:
rm -rf $(GODIR)/bin/
rm -rf $(JSDIR)/dist/

211
README.md
View File

@@ -1,95 +1,156 @@
# Koin Ping
A lightweight on-chain monitoring and alerting system designed to give users situational awareness over blockchain addresses they care about.
## Overview
# Overview
Koin Ping is designed to reliably observe on-chain activity and notify users when predefined conditions are met. It does not execute transactions, manage wallets, or speculate on prices.
## Project Structure
```
koin_ping/
├── backend/ # Node.js + Express + PostgreSQL backend
│ ├── api/ # API endpoints and server configuration
│ ├── poller/ # Blockchain polling logic
│ ├── alerts/ # Alert evaluation and management
│ ├── notifications/ # Notification delivery system
│ ├── domain/ # Domain models and business logic
│ ├── infra/ # Infrastructure (database, external services)
│ └── shared/ # Shared utilities and helpers
└── frontend/ # React + Vite frontend
├── public/ # Static assets
└── src/
├── api/ # Frontend API calls to backend
├── components/ # Reusable UI components
├── pages/ # Top-level pages (views)
└── utils/ # Utility functions
```
## Tech Stack
### Backend
- **Runtime:** Node.js
- **Framework:** Express
- **Database:** PostgreSQL
- **Key Dependencies:**
- `pg` - PostgreSQL client
- `dotenv` - Environment variable management
- `cors` - CORS middleware
- `nodemon` - Development auto-reload
### Frontend
- **Framework:** React 19
- **Build Tool:** Vite
- **Language:** JavaScript/TypeScript (mixed)
- **Type Checking:** TypeScript
Koin Ping observes on-chain activity and notifies users when predefined conditions are met. It does not execute transactions, manage wallets, or speculate on prices.
## Getting Started
### Prerequisites
- Node.js (v18 or higher recommended)
- PostgreSQL database
- npm or yarn
### Backend Setup
- Go 1.24+
- Node.js 18+ and npm (or yarn — preferred per repo policy)
- PostgreSQL 15+
- `golangci-lint` v2 (for `make lint`)
1. Navigate to the backend directory:
```bash
cd backend
### Setup
```bash
# Clone and enter the repo
git clone <repo-url>
cd koin_ping_0.2.0
# Install pre-commit hook
make hooks
# Install frontend dependencies
cd frontend && npm install && cd ..
# Copy and fill in environment variables
cp backend/.env.example backend/.env
# edit backend/.env with your DATABASE_URL, FIREBASE_PROJECT_ID, ETH_RPC_URL
# Run checks (requires golangci-lint)
make check
# Start the API server
make run
# Start the poller (separate terminal)
cd backend && go run ./cmd/poller
# Start the frontend dev server (separate terminal)
cd frontend && npm run dev
```
The API listens on `http://localhost:3001` and the frontend on
`http://localhost:3000` by default for development.
## Rationale
Crypto users may actively monitor addresses with webhooks integrating popular
messaging platforms: Discord, Slack, Telegram.
This lightweight, reliable framework makes instant awareness of on-chain
activity trivial, without polling block explorers manually. Koin Ping watches
addresses, evaluates configurable alert rules (incoming transactions, outgoing
transactions, "large" transfers, balance thresholds), and sends notifications.
## Design
The system is split into two independently deployable processes and one
frontend:
```
koin_ping_0.2.0/
├── backend/ # Go monorepo root
│ ├── cmd/api/ # HTTP REST API server
│ ├── cmd/poller/ # Blockchain polling daemon
│ └── internal/
│ ├── config/ # Environment-based config loading
│ ├── database/ # pgx connection pool
│ ├── domain/ # Shared domain types
│ ├── firebase/ # Firebase auth client
│ ├── handlers/ # HTTP handler wiring
│ ├── middleware/ # Auth middleware
│ ├── models/ # SQL persistence layer
│ ├── notifications/ # Discord webhook delivery
│ ├── protocols/ethereum/ # Ethereum JSON-RPC client
│ ├── services/ # Observer and evaluator business logic
│ └── wei/ # ETH/Wei conversion utilities
└── frontend/ # React + Vite SPA
└── src/
├── api/ # Fetch wrappers for the REST API
├── components/ # Reusable UI components
├── contexts/ # React contexts (auth, etc.)
├── firebase/ # Firebase SDK initialization
└── pages/ # Top-level route pages
```
**API server** (`cmd/api`): standard-library `net/http` with Firebase JWT
authentication middleware. Exposes CRUD endpoints for addresses, alert rules,
alert events, and notification configuration.
**Poller** (`cmd/poller`): long-running daemon that polls Ethereum via JSON-RPC,
compares observed transactions against persisted alert rules, fires alert events
to the database, and dispatches Discord notifications.
**Frontend** (`frontend/`): React 19 SPA built with Vite. Authenticates with
Firebase, communicates with the API via fetch, and renders the address/alert
management UI.
## Setting Up Your Alert Platforms
Koin Ping can send real-time alerts to **Telegram**, **Discord**, **Slack**, and **Email**. Each channel is configured per-user through the **Notification Settings** panel on the Alerts page.
Below are step-by-step guides for setting up each platform.
---
### Telegram
To receive alerts via Telegram, you need to create a bot and get your chat ID.
#### 1. Create a Telegram Bot
1. Open Telegram and search for **@BotFather** (look for the blue verified checkmark).
2. Open the conversation with BotFather and send: `/newbot`
3. BotFather will ask for a **display name** — enter something like `Koin Ping Alerts`.
4. BotFather will ask for a **username** — it must end in `bot`, e.g. `MyKoinPingBot`.
5. BotFather will reply with your **Bot Token** — a string that looks like `123456789:ABCdefGHIjklMNOpqrSTUvwxYZ`. Copy it.
#### 2. Get Your Chat ID
1. In Telegram, search for the bot username you just created and open the chat.
2. Tap **Start** or send any message (e.g. `hello`).
3. Open the following URL in your browser, replacing `YOUR_BOT_TOKEN` with the token from step 1:
```
https://api.telegram.org/botYOUR_BOT_TOKEN/getUpdates
```
2. Install dependencies (already done):
```bash
npm install
```
4. In the JSON response, find the `"chat"` object — the `"id"` field is your **Chat ID** (a numeric value).
3. Create a `.env` file (see backend/README.md for required variables)
> **Tip:** If the `'result"` array is empty, make sure you sent a message to your bot first, then refresh the page.
4. Start the development server:
```bash
npm run dev
```
#### 3. Save in Koin Ping
The backend will run on `http://localhost:3001`
1. Go to the **Alerts** page in Koin Ping.
2. In the **Notification Settings** panel, find the **Telegram** section.
3. Paste your **Bot Token** and **Chat ID** into the corresponding fields.
4. Click **Save Settings**.
5. Click **Test All Channels** to verify — you should receive a test message from your bot in Telegram.
### Frontend Setup
---
1. Navigate to the frontend directory:
```bash
cd frontend
```
*Guides for Discord, Slack, and Email coming soon.*
2. Install dependencies (already done):
```bash
npm install
```
## License
MIT. See [LICENSE](LICENSE).
## Author
Steven Jannette
3. Start the development server:
```bash
npm run dev
```
The frontend will run on `http://localhost:3000`

View File

@@ -1,16 +0,0 @@
# Server
PORT=3001
API_BASE_PATH=/v1
NODE_ENV=development
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/koin_ping
# Ethereum JSON-RPC
ETH_RPC_URL=https://mainnet.infura.io/v3/YOUR-PROJECT-ID
# Polling interval (ms, minimum 1000)
POLL_INTERVAL_MS=60000
# Firebase
FIREBASE_PROJECT_ID=koin-ping

View File

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

View File

@@ -1,98 +0,0 @@
package main
import (
"fmt"
"log"
"net/http"
"os"
"github.com/joho/godotenv"
"github.com/kjannette/koin-ping/backend-go/internal/config"
"github.com/kjannette/koin-ping/backend-go/internal/database"
"github.com/kjannette/koin-ping/backend-go/internal/firebase"
"github.com/kjannette/koin-ping/backend-go/internal/handlers"
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
"github.com/kjannette/koin-ping/backend-go/internal/models"
)
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)
}
defer database.Close()
if err := firebase.Init(cfg.FirebaseProjectID); err != nil {
log.Fatalf("Failed to initialize Firebase: %v", err)
}
addressModel := models.NewAddressModel(pool)
alertRuleModel := models.NewAlertRuleModel(pool)
alertEventModel := models.NewAlertEventModel(pool)
notifConfigModel := models.NewNotificationConfigModel(pool)
addressHandler := handlers.NewAddressHandler(addressModel)
alertRuleHandler := handlers.NewAlertRuleHandler(alertRuleModel, addressModel)
alertEventHandler := handlers.NewAlertEventHandler(alertEventModel)
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel)
mux := http.NewServeMux()
b := cfg.APIBasePath // e.g. "/v1"
// Public routes
mux.HandleFunc("GET "+b+"/health", handlers.HealthCheck)
mux.HandleFunc("GET "+b+"/status", handlers.SystemStatus)
// Authenticated routes — addresses
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)))
// Authenticated routes — alert rules
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)))
// Authenticated routes — alert events
mux.Handle("GET "+b+"/alert-events", middleware.Authenticate(http.HandlerFunc(alertEventHandler.List)))
// Authenticated routes — notification config
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)))
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,81 +0,0 @@
package config
import (
"fmt"
"os"
"strconv"
"strings"
)
type Config struct {
Port int
APIBasePath string
DatabaseURL string
DBHost string
DBPort int
DBUser string
DBPassword string
DBName string
FirebaseProjectID string
EthRPCURL string
PollIntervalMS int
NodeEnv string
}
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"),
}
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

@@ -1,115 +0,0 @@
package domain
import "time"
type Address struct {
ID int `json:"id"`
UserID string `json:"user_id"`
Address string `json:"address"`
Label *string `json:"label"`
CreatedAt time.Time `json:"created_at"`
}
type AlertType string
const (
AlertIncomingTx AlertType = "incoming_tx"
AlertOutgoingTx AlertType = "outgoing_tx"
AlertLargeTransfer AlertType = "large_transfer"
AlertBalanceBelow AlertType = "balance_below"
)
var ValidAlertTypes = []AlertType{
AlertIncomingTx,
AlertOutgoingTx,
AlertLargeTransfer,
AlertBalanceBelow,
}
var ThresholdRequiredTypes = []AlertType{
AlertLargeTransfer,
AlertBalanceBelow,
}
func IsValidAlertType(t string) bool {
for _, v := range ValidAlertTypes {
if string(v) == t {
return true
}
}
return false
}
func IsThresholdRequired(t AlertType) bool {
for _, v := range ThresholdRequiredTypes {
if v == t {
return true
}
}
return false
}
type AlertRule struct {
ID int `json:"id"`
AddressID int `json:"address_id"`
Type AlertType `json:"type"`
Threshold *float64 `json:"threshold"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
}
type AlertEvent struct {
ID int `json:"id"`
AlertRuleID int `json:"alert_rule_id"`
Message string `json:"message"`
AddressLabel *string `json:"address_label"`
TxHash *string `json:"tx_hash"`
Timestamp time.Time `json:"timestamp"`
}
type AddressCheckpoint struct {
AddressID int `json:"address_id"`
LastCheckedBlock int `json:"last_checked_block"`
LastCheckedAt time.Time `json:"last_checked_at"`
}
type CheckpointDetail struct {
AddressID int `json:"address_id"`
Address string `json:"address"`
Label *string `json:"label"`
LastCheckedBlock int `json:"last_checked_block"`
LastCheckedAt time.Time `json:"last_checked_at"`
}
type NotificationConfig struct {
UserID string `json:"user_id"`
DiscordWebhookURL *string `json:"discord_webhook_url"`
TelegramChatID *string `json:"telegram_chat_id"`
TelegramBotToken *string `json:"telegram_bot_token,omitempty"`
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

@@ -1,23 +0,0 @@
package handlers
import (
"net/http"
"time"
)
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",
})
}
func SystemStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{
"latestBlock": 0,
"lag": 0,
"status": "healthy",
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}

View File

@@ -1,98 +0,0 @@
package middleware
import (
"context"
"encoding/json"
"log"
"net/http"
"strings"
fbauth "github.com/kjannette/koin-ping/backend-go/internal/firebase"
)
type contextKey string
const (
UserIDKey contextKey = "user_id"
UserEmailKey contextKey = "user_email"
)
type errorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func Authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
log.Println("No Authorization header or invalid format")
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "No authentication token provided",
})
return
}
token := strings.TrimPrefix(authHeader, "Bearer ")
if token == "" {
log.Println("Empty token")
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "Invalid token format",
})
return
}
log.Println("Verifying Firebase token...")
decoded, err := fbauth.Auth().VerifyIDToken(r.Context(), token)
if err != nil {
log.Printf("Token verification failed: %v", err)
errMsg := err.Error()
if strings.Contains(errMsg, "expired") {
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "TOKEN_EXPIRED",
Message: "Authentication token has expired",
})
return
}
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "Failed to verify authentication token",
})
return
}
userID := decoded.UID
email, _ := decoded.Claims["email"].(string)
log.Printf("Token verified! User ID: %s, Email: %s", userID, email)
ctx := context.WithValue(r.Context(), UserIDKey, userID)
ctx = context.WithValue(ctx, UserEmailKey, email)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func GetUserID(ctx context.Context) string {
if v, ok := ctx.Value(UserIDKey).(string); ok {
return v
}
return ""
}
func GetUserEmail(ctx context.Context) string {
if v, ok := ctx.Value(UserEmailKey).(string); ok {
return v
}
return ""
}

View File

@@ -1,211 +0,0 @@
package ethereum
import (
"bytes"
"context"
"encoding/json"
"fmt"
"math/big"
"net/http"
"strings"
"time"
"github.com/kjannette/koin-ping/backend-go/internal/domain"
)
const rpcTimeoutMS = 30000
type JsonRpcEthereum struct {
rpcURL string
client *http.Client
}
func NewJsonRpcEthereum(rpcURL string) (*JsonRpcEthereum, error) {
if rpcURL == "" {
return nil, fmt.Errorf("JsonRpcEthereum requires a valid RPC URL")
}
return &JsonRpcEthereum{
rpcURL: rpcURL,
client: &http.Client{
Timeout: time.Duration(rpcTimeoutMS) * time.Millisecond,
},
}, nil
}
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID int64 `json:"id"`
Method string `json:"method"`
Params []interface{} `json:"params"`
}
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID int64 `json:"id"`
Result json.RawMessage `json:"result"`
Error *rpcError `json:"error"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func (j *JsonRpcEthereum) callRPC(ctx context.Context, method string, params ...interface{}) (json.RawMessage, error) {
if params == nil {
params = []interface{}{}
}
body, err := json.Marshal(rpcRequest{
JSONRPC: "2.0",
ID: time.Now().UnixMilli(),
Method: method,
Params: params,
})
if err != nil {
return nil, fmt.Errorf("marshal RPC request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, j.rpcURL, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create RPC request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := j.client.Do(req)
if err != nil {
return nil, fmt.Errorf("RPC call failed [%s]: %w", method, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d: %s for %s", resp.StatusCode, resp.Status, method)
}
var rpcResp rpcResponse
if err := json.NewDecoder(resp.Body).Decode(&rpcResp); err != nil {
return nil, fmt.Errorf("decode RPC response: %w", err)
}
if rpcResp.Error != nil {
return nil, fmt.Errorf("RPC Error [%s]: %s (code: %d)", method, rpcResp.Error.Message, rpcResp.Error.Code)
}
return rpcResp.Result, nil
}
func (j *JsonRpcEthereum) GetLatestBlockNumber(ctx context.Context) (int, error) {
result, err := j.callRPC(ctx, "eth_blockNumber")
if err != nil {
return 0, err
}
var hexBlock string
if err := json.Unmarshal(result, &hexBlock); err != nil {
return 0, fmt.Errorf("unmarshal block number: %w", err)
}
return hexToInt(hexBlock)
}
type rpcBlock struct {
Timestamp string `json:"timestamp"`
Transactions []rpcTx `json:"transactions"`
}
type rpcTx struct {
Hash string `json:"hash"`
From string `json:"from"`
To *string `json:"to"`
Value string `json:"value"`
BlockNumber string `json:"blockNumber"`
}
func (j *JsonRpcEthereum) GetBlockTransactions(ctx context.Context, blockNumber int) ([]domain.NormalizedTx, error) {
hexBlock := fmt.Sprintf("0x%x", blockNumber)
result, err := j.callRPC(ctx, "eth_getBlockByNumber", hexBlock, true)
if err != nil {
return nil, err
}
if string(result) == "null" {
return nil, nil
}
var block rpcBlock
if err := json.Unmarshal(result, &block); err != nil {
return nil, fmt.Errorf("unmarshal block: %w", err)
}
blockTimestamp, err := hexToInt64(block.Timestamp)
if err != nil {
return nil, fmt.Errorf("parse block timestamp: %w", err)
}
txs := make([]domain.NormalizedTx, 0, len(block.Transactions))
for _, tx := range block.Transactions {
bn, _ := hexToInt(tx.BlockNumber)
var toLower *string
if tx.To != nil {
s := strings.ToLower(*tx.To)
toLower = &s
}
txs = append(txs, domain.NormalizedTx{
Hash: tx.Hash,
From: strings.ToLower(tx.From),
To: toLower,
Value: hexToDecimalString(tx.Value),
BlockNumber: bn,
BlockTimestamp: blockTimestamp,
})
}
return txs, nil
}
func (j *JsonRpcEthereum) GetBalance(ctx context.Context, address string) (string, error) {
result, err := j.callRPC(ctx, "eth_getBalance", address, "latest")
if err != nil {
return "", err
}
var hexBalance string
if err := json.Unmarshal(result, &hexBalance); err != nil {
return "", fmt.Errorf("unmarshal balance: %w", err)
}
return hexToDecimalString(hexBalance), nil
}
func hexToInt(hex string) (int, error) {
hex = strings.TrimPrefix(hex, "0x")
n, ok := new(big.Int).SetString(hex, 16)
if !ok {
return 0, fmt.Errorf("invalid hex: %s", hex)
}
return int(n.Int64()), nil
}
func hexToInt64(hex string) (int64, error) {
hex = strings.TrimPrefix(hex, "0x")
n, ok := new(big.Int).SetString(hex, 16)
if !ok {
return 0, fmt.Errorf("invalid hex: %s", hex)
}
return n.Int64(), nil
}
func hexToDecimalString(hex string) string {
if hex == "" || hex == "0x" || hex == "0x0" {
return "0"
}
clean := strings.TrimPrefix(hex, "0x")
n, ok := new(big.Int).SetString(clean, 16)
if !ok {
return "0"
}
return n.String()
}

View File

@@ -1,232 +0,0 @@
package services
import (
"context"
"fmt"
"log"
"github.com/kjannette/koin-ping/backend-go/internal/domain"
"github.com/kjannette/koin-ping/backend-go/internal/models"
"github.com/kjannette/koin-ping/backend-go/internal/notifications"
"github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
"github.com/kjannette/koin-ping/backend-go/internal/wei"
)
type EvaluatorService struct {
eth ethereum.EthereumObserver
alertRules *models.AlertRuleModel
alertEvents *models.AlertEventModel
addresses *models.AddressModel
notifConfigs *models.NotificationConfigModel
}
func NewEvaluatorService(
eth ethereum.EthereumObserver,
alertRules *models.AlertRuleModel,
alertEvents *models.AlertEventModel,
addresses *models.AddressModel,
notifConfigs *models.NotificationConfigModel,
) *EvaluatorService {
return &EvaluatorService{
eth: eth,
alertRules: alertRules,
alertEvents: alertEvents,
addresses: addresses,
notifConfigs: notifConfigs,
}
}
func (s *EvaluatorService) Evaluate(ctx context.Context, observations []domain.ObservedTx) (int, error) {
alertsFired := 0
for _, obs := range observations {
fired, err := s.evaluateObservation(ctx, obs)
if err != nil {
log.Printf("Error evaluating observation for address ID %d: %v", obs.AddressID, err)
continue
}
alertsFired += fired
}
return alertsFired, nil
}
func (s *EvaluatorService) evaluateObservation(ctx context.Context, obs domain.ObservedTx) (int, error) {
rules, err := s.alertRules.ListByAddress(ctx, obs.AddressID)
if err != nil {
return 0, err
}
alertsFired := 0
for _, rule := range rules {
if !rule.Enabled {
continue
}
matches, err := s.ruleMatches(ctx, rule, obs)
if err != nil {
log.Printf("Error matching rule %d: %v", rule.ID, err)
continue
}
if matches {
if err := s.fireAlert(ctx, rule, obs); err != nil {
log.Printf("Error firing alert for rule %d: %v", rule.ID, err)
continue
}
alertsFired++
}
}
return alertsFired, nil
}
func (s *EvaluatorService) ruleMatches(ctx context.Context, rule domain.AlertRule, obs domain.ObservedTx) (bool, error) {
switch rule.Type {
case domain.AlertIncomingTx:
return obs.Direction == domain.DirectionIncoming, nil
case domain.AlertOutgoingTx:
return obs.Direction == domain.DirectionOutgoing, nil
case domain.AlertLargeTransfer:
return s.matchesLargeTransfer(rule, obs)
case domain.AlertBalanceBelow:
return s.matchesBalanceBelow(ctx, rule, obs)
default:
log.Printf("Unknown rule type: %s", rule.Type)
return false, nil
}
}
func (s *EvaluatorService) matchesLargeTransfer(rule domain.AlertRule, obs domain.ObservedTx) (bool, error) {
if rule.Threshold == nil {
return false, nil
}
thresholdWei, err := wei.FromEth(*rule.Threshold)
if err != nil {
return false, err
}
return wei.GreaterThanOrEqual(obs.Value, thresholdWei)
}
// matchesBalanceBelow only triggers after outgoing transactions, since incoming
// transactions increase balance and can't cause it to drop below threshold.
func (s *EvaluatorService) matchesBalanceBelow(ctx context.Context, rule domain.AlertRule, obs domain.ObservedTx) (bool, error) {
if rule.Threshold == nil {
return false, nil
}
if obs.Direction != domain.DirectionOutgoing {
return false, nil
}
addr, err := s.addresses.FindByID(ctx, obs.AddressID, nil)
if err != nil {
return false, err
}
if addr == nil {
log.Printf("Address ID %d not found", obs.AddressID)
return false, nil
}
balanceWei, err := s.eth.GetBalance(ctx, addr.Address)
if err != nil {
return false, err
}
thresholdWei, err := wei.FromEth(*rule.Threshold)
if err != nil {
return false, err
}
return wei.LessThan(balanceWei, thresholdWei)
}
func (s *EvaluatorService) fireAlert(ctx context.Context, rule domain.AlertRule, obs domain.ObservedTx) error {
addr, err := s.addresses.FindByID(ctx, obs.AddressID, nil)
if err != nil {
return err
}
addressLabel := "Unknown"
if addr != nil {
if addr.Label != nil {
addressLabel = *addr.Label
} else {
addressLabel = addr.Address
}
}
message := s.buildMessage(rule, obs)
txHash := &obs.Hash
_, err = s.alertEvents.Create(ctx, rule.ID, message, &addressLabel, txHash)
if err != nil {
return err
}
log.Printf("[ALERT FIRED] Rule %d (%s) - %s - TX: %s", rule.ID, rule.Type, message, obs.Hash)
// Send Discord notification (non-fatal on failure)
if addr != nil {
go s.sendNotification(ctx, addr.UserID, message, obs, addressLabel, rule, addr.Address)
}
return nil
}
func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message string, obs domain.ObservedTx, addressLabel string, rule domain.AlertRule, address string) {
notifConfig, err := s.notifConfigs.GetConfig(ctx, userID)
if err != nil {
log.Printf("Failed to get notification config: %v", err)
return
}
if notifConfig == nil || !notifConfig.NotificationEnabled || notifConfig.DiscordWebhookURL == nil {
return
}
sent, err := notifications.SendDiscordNotification(
*notifConfig.DiscordWebhookURL,
message,
notifications.AlertMetadata{
TxHash: obs.Hash,
AddressLabel: addressLabel,
AlertType: string(rule.Type),
Address: address,
},
)
if err != nil || !sent {
log.Printf("Discord notification failed for user %s: %v", userID, err)
} else {
log.Printf("Discord notification sent to user %s", userID)
}
}
func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.ObservedTx) string {
switch rule.Type {
case domain.AlertIncomingTx:
ethStr, _ := wei.FormatAsEth(obs.Value, 4)
return fmt.Sprintf("Incoming transaction: %s received", ethStr)
case domain.AlertOutgoingTx:
ethStr, _ := wei.FormatAsEth(obs.Value, 4)
return fmt.Sprintf("Outgoing transaction: %s sent", ethStr)
case domain.AlertLargeTransfer:
ethStr, _ := wei.FormatAsEth(obs.Value, 4)
threshold := float64(0)
if rule.Threshold != nil {
threshold = *rule.Threshold
}
return fmt.Sprintf("Large transfer detected: %s (threshold: %g ETH)", ethStr, threshold)
case domain.AlertBalanceBelow:
threshold := float64(0)
if rule.Threshold != nil {
threshold = *rule.Threshold
}
return fmt.Sprintf("Balance dropped below threshold of %g ETH", threshold)
default:
return "Alert triggered"
}
}

32
backend/.golangci.yml Normal file
View File

@@ -0,0 +1,32 @@
version: "2"
run:
timeout: 5m
modules-download-mode: readonly
linters:
default: all
disable:
# Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields
- depguard # Dependency allow/block lists
- godot # Requires comments to end with periods
- wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go
linters-settings:
lll:
line-length: 88
funlen:
lines: 80
statements: 50
cyclop:
max-complexity: 15
dupl:
threshold: 100
issues:
exclude-use-default: false
max-issues-per-linter: 0
max-same-issues: 0

View File

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

20
backend/README.md Normal file
View File

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

BIN
backend/api Executable file

Binary file not shown.

171
backend/cmd/api/main.go Normal file
View File

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

View File

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

View File

@@ -92,6 +92,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/resend/resend-go/v3 v3.1.1 h1:Uwpf/tZU+O/r/3nMWE6zUAMIG9dX/vTBS3wlQzYJKSw=
github.com/resend/resend-go/v3 v3.1.1/go.mod h1:iI7VA0NoGjWvsNii5iNC5Dy0llsI3HncXPejhniYzwE=
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -99,6 +101,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stripe/stripe-go/v82 v82.5.1 h1:05q6ZDKoe8PLMpQV072obF74HCgP4XJeJYoNuRSX2+8=
github.com/stripe/stripe-go/v82 v82.5.1/go.mod h1:majCQX6AfObAvJiHraPi/5udwHi4ojRvJnnxckvHrX8=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=

View File

@@ -0,0 +1,2 @@
ALTER TABLE user_notification_configs
ADD COLUMN IF NOT EXISTS slack_webhook_url TEXT;

View File

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

View File

@@ -0,0 +1,13 @@
-- Migration 005: Create users table with UUID primary key
-- This establishes a local user record for each Firebase-authenticated user.
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
firebase_uid VARCHAR(128) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL,
display_name VARCHAR(255),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_users_firebase_uid ON users(firebase_uid);

View File

@@ -0,0 +1,73 @@
-- Migration 006: Migrate user_id columns from Firebase UID strings to user UUIDs
--
-- Prerequisites: migration 005 (users table) must be applied first.
-- This migration backfills the users table from existing data, then swaps the
-- VARCHAR user_id columns for UUID foreign keys referencing users(id).
BEGIN;
-- 1. Backfill users table from existing Firebase UIDs in addresses
INSERT INTO users (firebase_uid, email)
SELECT DISTINCT user_id, ''
FROM addresses
WHERE user_id IS NOT NULL
ON CONFLICT (firebase_uid) DO NOTHING;
-- 2. Backfill from notification configs (catches users with configs but no addresses)
INSERT INTO users (firebase_uid, email)
SELECT DISTINCT user_id, ''
FROM user_notification_configs
WHERE user_id IS NOT NULL
ON CONFLICT (firebase_uid) DO NOTHING;
-- ============================================================
-- 3. Migrate addresses.user_id from VARCHAR to UUID
-- ============================================================
ALTER TABLE addresses ADD COLUMN user_uuid UUID;
UPDATE addresses a
SET user_uuid = u.id
FROM users u
WHERE u.firebase_uid = a.user_id;
-- Drop old constraints and column
ALTER TABLE addresses DROP CONSTRAINT IF EXISTS addresses_user_id_address_key;
DROP INDEX IF EXISTS idx_addresses_user_id;
ALTER TABLE addresses DROP COLUMN user_id;
-- Rename and constrain
ALTER TABLE addresses RENAME COLUMN user_uuid TO user_id;
ALTER TABLE addresses ALTER COLUMN user_id SET NOT NULL;
ALTER TABLE addresses ADD CONSTRAINT fk_addresses_user
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE addresses ADD CONSTRAINT addresses_user_id_address_key
UNIQUE (user_id, address);
CREATE INDEX idx_addresses_user_id ON addresses(user_id);
-- ============================================================
-- 4. Migrate user_notification_configs.user_id from VARCHAR to UUID
-- ============================================================
-- Drop the PK first (it's on user_id)
ALTER TABLE user_notification_configs DROP CONSTRAINT IF EXISTS user_notification_configs_pkey;
DROP INDEX IF EXISTS idx_notification_configs_enabled;
ALTER TABLE user_notification_configs ADD COLUMN user_uuid UUID;
UPDATE user_notification_configs nc
SET user_uuid = u.id
FROM users u
WHERE u.firebase_uid = nc.user_id;
ALTER TABLE user_notification_configs DROP COLUMN user_id;
ALTER TABLE user_notification_configs RENAME COLUMN user_uuid TO user_id;
ALTER TABLE user_notification_configs ALTER COLUMN user_id SET NOT NULL;
ALTER TABLE user_notification_configs ADD CONSTRAINT user_notification_configs_pkey
PRIMARY KEY (user_id);
ALTER TABLE user_notification_configs ADD CONSTRAINT fk_notification_configs_user
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
CREATE INDEX idx_notification_configs_enabled
ON user_notification_configs(notification_enabled);
COMMIT;

View File

@@ -0,0 +1,8 @@
-- Migration 007: Add Stripe subscription fields to users table
ALTER TABLE users ADD COLUMN IF NOT EXISTS stripe_customer_id VARCHAR(255);
ALTER TABLE users ADD COLUMN IF NOT EXISTS stripe_subscription_id VARCHAR(255);
ALTER TABLE users ADD COLUMN IF NOT EXISTS subscription_status VARCHAR(50) DEFAULT 'none';
ALTER TABLE users ADD COLUMN IF NOT EXISTS subscription_created_at TIMESTAMP;
CREATE INDEX IF NOT EXISTS idx_users_stripe_customer_id ON users(stripe_customer_id);

View File

@@ -0,0 +1,6 @@
ALTER TABLE alert_rules ADD COLUMN minimum DECIMAL(20, 6);
ALTER TABLE alert_rules ADD COLUMN maximum DECIMAL(20, 6);
ALTER TABLE alert_rules ADD CONSTRAINT non_negative_minimum CHECK (minimum IS NULL OR minimum >= 0);
ALTER TABLE alert_rules ADD CONSTRAINT non_negative_maximum CHECK (maximum IS NULL OR maximum >= 0);
ALTER TABLE alert_rules ADD CONSTRAINT min_lte_max CHECK (minimum IS NULL OR maximum IS NULL OR minimum <= maximum);

View File

@@ -4,15 +4,28 @@ DROP TABLE IF EXISTS alert_rules CASCADE;
DROP TABLE IF EXISTS address_checkpoints CASCADE;
DROP TABLE IF EXISTS user_notification_configs CASCADE;
DROP TABLE IF EXISTS addresses CASCADE;
DROP TABLE IF EXISTS users CASCADE;
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
firebase_uid VARCHAR(128) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL,
display_name VARCHAR(255),
stripe_customer_id VARCHAR(255),
stripe_subscription_id VARCHAR(255),
subscription_status VARCHAR(50) DEFAULT 'none',
subscription_created_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE addresses (
id SERIAL PRIMARY KEY,
user_id VARCHAR(128) NOT NULL, -- Firebase user ID (multi-user support)
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
address VARCHAR(42) NOT NULL, -- Ethereum address format (0x + 40 hex chars)
label VARCHAR(255), -- Optional human-readable label
created_at TIMESTAMP DEFAULT NOW(),
-- Unique users can track the same address independently
UNIQUE(user_id, address)
);
@@ -22,6 +35,8 @@ CREATE TABLE alert_rules (
address_id INTEGER NOT NULL REFERENCES addresses(id) ON DELETE CASCADE,
type VARCHAR(50) NOT NULL, -- 'incoming_tx', 'outgoing_tx', 'large_transfer', 'balance_below'
threshold DECIMAL(20, 6), -- ETH amount threshold (nullable for tx types that don't need it)
minimum DECIMAL(20, 6), -- Optional min amount filter for incoming/outgoing alerts
maximum DECIMAL(20, 6), -- Optional max amount filter for incoming/outgoing alerts
enabled BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
@@ -31,7 +46,11 @@ CREATE TABLE alert_rules (
CONSTRAINT positive_threshold CHECK (
threshold IS NULL OR threshold > 0
)
),
CONSTRAINT non_negative_minimum CHECK (minimum IS NULL OR minimum >= 0),
CONSTRAINT non_negative_maximum CHECK (maximum IS NULL OR maximum >= 0),
CONSTRAINT min_lte_max CHECK (minimum IS NULL OR maximum IS NULL OR minimum <= maximum)
);
@@ -51,17 +70,20 @@ CREATE TABLE address_checkpoints (
);
CREATE TABLE user_notification_configs (
user_id VARCHAR(128) PRIMARY KEY,
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
discord_webhook_url TEXT, -- Discord webhook URL (nullable)
telegram_chat_id VARCHAR(128), -- Telegram chat ID (nullable)
telegram_bot_token VARCHAR(255), -- Telegram bot token (nullable, future use)
telegram_bot_token VARCHAR(255), -- Telegram bot token (nullable)
email VARCHAR(255), -- Email for notifications (nullable)
slack_webhook_url TEXT, -- Slack incoming webhook URL (nullable)
notification_enabled BOOLEAN DEFAULT TRUE, -- Master on/off switch
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Create indexes for common queries
CREATE INDEX idx_users_firebase_uid ON users(firebase_uid);
CREATE INDEX idx_users_stripe_customer_id ON users(stripe_customer_id);
CREATE INDEX idx_addresses_user_id ON addresses(user_id);
CREATE INDEX idx_alert_rules_address_id ON alert_rules(address_id);
CREATE INDEX idx_alert_rules_enabled ON alert_rules(enabled);

View File

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

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

View File

@@ -0,0 +1,151 @@
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"`
Minimum *float64 `json:"minimum"`
Maximum *float64 `json:"maximum"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
}
type AlertEvent struct {
ID int `json:"id"`
AlertRuleID int `json:"alert_rule_id"` //nolint:tagliatelle
Message string `json:"message"`
AddressLabel *string `json:"address_label"` //nolint:tagliatelle
TxHash *string `json:"tx_hash"` //nolint:tagliatelle
Timestamp time.Time `json:"timestamp"`
}
type AddressCheckpoint struct {
AddressID int `json:"address_id"` //nolint:tagliatelle
LastCheckedBlock int `json:"last_checked_block"` //nolint:tagliatelle
LastCheckedAt time.Time `json:"last_checked_at"` //nolint:tagliatelle
}
type CheckpointDetail struct {
AddressID int `json:"address_id"` //nolint:tagliatelle
Address string `json:"address"`
Label *string `json:"label"`
LastCheckedBlock int `json:"last_checked_block"` //nolint:tagliatelle
LastCheckedAt time.Time `json:"last_checked_at"` //nolint:tagliatelle
}
// holds a user's notification preferences.
type NotificationConfig struct {
UserID string `json:"user_id"` //nolint:tagliatelle
DiscordWebhookURL *string `json:"discord_webhook_url"` //nolint:tagliatelle
TelegramChatID *string `json:"telegram_chat_id"` //nolint:tagliatelle
TelegramBotToken *string `json:"telegram_bot_token"` //nolint:tagliatelle
Email *string `json:"email"`
SlackWebhookURL *string `json:"slack_webhook_url"` //nolint:tagliatelle
NotificationEnabled bool `json:"notification_enabled"` //nolint:tagliatelle
CreatedAt *time.Time `json:"created_at,omitempty"` //nolint:tagliatelle
UpdatedAt *time.Time `json:"updated_at,omitempty"` //nolint:tagliatelle
}
// NormalizedTx is a blockchain transaction normalized for internal use.
type NormalizedTx struct {
Hash string `json:"hash"`
From string `json:"from"`
To *string `json:"to"`
Value string `json:"value"` // Wei as string for precision
BlockNumber int `json:"block_number"` //nolint:tagliatelle
BlockTimestamp int64 `json:"block_timestamp"` //nolint:tagliatelle
// ERC-20 token transfer fields (nil for native ETH transfers)
TokenContract *string `json:"token_contract,omitempty"` //nolint:tagliatelle
TokenSymbol *string `json:"token_symbol,omitempty"` //nolint:tagliatelle
TokenDecimals *int `json:"token_decimals,omitempty"` //nolint:tagliatelle
TokenValue *string `json:"token_value,omitempty"` //nolint:tagliatelle
}
func (tx NormalizedTx) IsTokenTransfer() bool {
return tx.TokenContract != nil
}
type Direction string
func (d Direction) String() string { return string(d) }
const (
DirectionIncoming Direction = "incoming"
DirectionOutgoing Direction = "outgoing"
)
type ObservedTx struct {
NormalizedTx
AddressID int `json:"address_id"` //nolint:tagliatelle
Direction Direction `json:"direction"`
}

View File

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

View File

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

View File

@@ -4,21 +4,23 @@ import (
"log"
"net/http"
"strconv"
"time"
"github.com/kjannette/koin-ping/backend-go/internal/domain"
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
"github.com/kjannette/koin-ping/backend-go/internal/models"
"github.com/kjannette/koin-ping/backend/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/middleware"
"github.com/kjannette/koin-ping/backend/internal/models"
)
// AlertEventHandler handles HTTP requests for alert event history.
type AlertEventHandler struct {
alertEvents *models.AlertEventModel
}
// NewAlertEventHandler creates a new AlertEventHandler.
func NewAlertEventHandler(alertEvents *models.AlertEventModel) *AlertEventHandler {
return &AlertEventHandler{alertEvents: alertEvents}
}
// List handles GET requests to list recent alert events for the current user.
func (h *AlertEventHandler) List(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
@@ -34,6 +36,7 @@ func (h *AlertEventHandler) List(w http.ResponseWriter, r *http.Request) {
if limit < 1 || limit > 100 {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Limit must be between 1 and 100")
return
}
@@ -41,49 +44,15 @@ func (h *AlertEventHandler) List(w http.ResponseWriter, r *http.Request) {
if err != nil {
log.Printf("Error listing alert events: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list alert events")
return
}
log.Printf("Found %d alert events for user", len(events))
// MVP scaffolding: return mock data if DB is empty
if len(events) == 0 {
events = mockEvents(limit)
if events == nil {
events = []domain.AlertEvent{}
}
writeJSON(w, http.StatusOK, events)
}
func mockEvents(limit int) []domain.AlertEvent {
label1 := "Treasury Wallet"
label2 := "Cold Storage"
mocks := []domain.AlertEvent{
{
ID: 1,
AlertRuleID: 1,
Message: "Incoming transaction detected: 5.5 ETH received",
AddressLabel: &label1,
Timestamp: time.Now().Add(-2 * time.Hour),
},
{
ID: 2,
AlertRuleID: 2,
Message: "Balance dropped below threshold: Current balance 8.2 ETH",
AddressLabel: &label1,
Timestamp: time.Now().Add(-5 * time.Hour),
},
{
ID: 3,
AlertRuleID: 3,
Message: "Outgoing transaction detected: 2.0 ETH sent",
AddressLabel: &label2,
Timestamp: time.Now().Add(-24 * time.Hour),
},
}
if limit < len(mocks) {
return mocks[:limit]
}
return mocks
}

View File

@@ -2,17 +2,20 @@ package handlers
import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"github.com/kjannette/koin-ping/backend-go/internal/domain"
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
"github.com/kjannette/koin-ping/backend-go/internal/models"
"github.com/kjannette/koin-ping/backend/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/middleware"
"github.com/kjannette/koin-ping/backend/internal/models"
)
var errThresholdFormat = errors.New("unsupported threshold format")
type AlertRuleHandler struct {
alertRules *models.AlertRuleModel
addresses *models.AddressModel
@@ -27,16 +30,20 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
addressID, ok := parseIntParam(r.PathValue("addressId"))
if !ok {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid address ID")
return
}
var body struct {
Type string `json:"type"`
Threshold json.RawMessage `json:"threshold"`
Minimum json.RawMessage `json:"minimum"`
Maximum json.RawMessage `json:"maximum"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
log.Printf("Failed to decode alert request body: %v", err)
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body")
return
}
@@ -44,6 +51,41 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
if err != nil {
log.Printf("Failed to parse threshold: %v", err)
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "threshold must be a valid number")
return
}
minimum, err := parseThreshold(body.Minimum)
if err != nil {
log.Printf("Failed to parse minimum: %v", err)
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be a valid number")
return
}
maximum, err := parseThreshold(body.Maximum)
if err != nil {
log.Printf("Failed to parse maximum: %v", err)
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be a valid number")
return
}
if minimum != nil && *minimum < 0 {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be non-negative")
return
}
if maximum != nil && *maximum < 0 {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be non-negative")
return
}
if minimum != nil && maximum != nil && *minimum > *maximum {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must not exceed maximum")
return
}
@@ -51,16 +93,18 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
if body.Type == "" {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Alert type is required")
return
}
if !domain.IsValidAlertType(body.Type) {
types := make([]string, len(domain.ValidAlertTypes))
for i, t := range domain.ValidAlertTypes {
types[i] = string(t)
types[i] = t.String()
}
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
fmt.Sprintf("Invalid alert type. Must be one of: %s", strings.Join(types, ", ")))
"Invalid alert type. Must be one of: "+strings.Join(types, ", "))
return
}
@@ -69,6 +113,7 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
if threshold == nil || *threshold <= 0 {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
fmt.Sprintf("Alert type '%s' requires a positive threshold value", body.Type))
return
}
}
@@ -77,18 +122,21 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
if err != nil {
log.Printf("Error finding address: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule")
return
}
if addr == nil {
log.Printf("Address %d not found or not owned by user", addressID)
writeError(w, http.StatusNotFound, "NOT_FOUND", "Address not found")
return
}
newAlert, err := h.alertRules.Create(r.Context(), addressID, alertType, threshold)
newAlert, err := h.alertRules.Create(r.Context(), addressID, alertType, threshold, minimum, maximum)
if err != nil {
log.Printf("Error creating alert rule: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule")
return
}
@@ -98,12 +146,12 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
func parseThreshold(raw json.RawMessage) (*float64, error) {
if len(raw) == 0 {
return nil, nil
return nil, nil //nolint:nilnil
}
// Check null before number -- json.Unmarshal treats null as valid for float64 (sets to 0).
if string(raw) == "null" {
return nil, nil
return nil, nil //nolint:nilnil
}
var asNumber float64
@@ -115,23 +163,26 @@ func parseThreshold(raw json.RawMessage) (*float64, error) {
if err := json.Unmarshal(raw, &asString); err == nil {
asString = strings.TrimSpace(asString)
if asString == "" {
return nil, nil
return nil, nil //nolint:nilnil
}
parsed, parseErr := strconv.ParseFloat(asString, 64)
if parseErr != nil {
return nil, parseErr
}
return &parsed, nil
}
return nil, fmt.Errorf("unsupported threshold format")
return nil, errThresholdFormat
}
// ListByAddress handles GET requests to list alert rules for an address.
func (h *AlertRuleHandler) ListByAddress(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
addressID, ok := parseIntParam(r.PathValue("addressId"))
if !ok {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid address ID")
return
}
@@ -141,11 +192,13 @@ func (h *AlertRuleHandler) ListByAddress(w http.ResponseWriter, r *http.Request)
if err != nil {
log.Printf("Error finding address: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list alerts")
return
}
if addr == nil {
log.Printf("Address %d not found or not owned by user", addressID)
writeError(w, http.StatusNotFound, "NOT_FOUND", "Address not found")
return
}
@@ -153,6 +206,7 @@ func (h *AlertRuleHandler) ListByAddress(w http.ResponseWriter, r *http.Request)
if err != nil {
log.Printf("Error listing alerts: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list alerts")
return
}
@@ -164,27 +218,34 @@ func (h *AlertRuleHandler) ListByAddress(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, alerts)
}
// UpdateStatus handles PATCH requests to enable/disable an alert rule and/or update min/max thresholds.
func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
alertID, ok := parseIntParam(r.PathValue("alertId"))
if !ok {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid alert ID")
return
}
var body struct {
Enabled *bool `json:"enabled"`
Minimum json.RawMessage `json:"minimum"`
Maximum json.RawMessage `json:"maximum"`
UpdateMinMax bool `json:"update_min_max"` //nolint:tagliatelle
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
log.Printf("Failed to decode update request body: %v", err)
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body")
return
}
log.Printf("User %s updating alert ID: %d", userID, alertID)
if body.Enabled == nil {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "enabled must be a boolean value")
if body.Enabled == nil && !body.UpdateMinMax {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "must provide enabled (boolean) or update_min_max with minimum/maximum values")
return
}
@@ -192,30 +253,84 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
if err != nil {
log.Printf("Error finding alert: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert")
return
}
if alert == nil {
log.Printf("Alert %d not found or not owned by user", alertID)
writeError(w, http.StatusNotFound, "NOT_FOUND", "Alert rule not found")
return
}
updated, err := h.alertRules.UpdateEnabled(r.Context(), alertID, *body.Enabled)
var updated *domain.AlertRule
if body.UpdateMinMax {
minimum, parseErr := parseThreshold(body.Minimum)
if parseErr != nil {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be a valid number")
return
}
maximum, parseErr := parseThreshold(body.Maximum)
if parseErr != nil {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be a valid number")
return
}
if minimum != nil && *minimum < 0 {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be non-negative")
return
}
if maximum != nil && *maximum < 0 {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be non-negative")
return
}
if minimum != nil && maximum != nil && *minimum > *maximum {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must not exceed maximum")
return
}
updated, err = h.alertRules.UpdateThresholds(r.Context(), alertID, minimum, maximum)
if err != nil {
log.Printf("Error updating alert thresholds: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert")
return
}
log.Printf("Alert %d thresholds updated: min=%v, max=%v", alertID, minimum, maximum)
}
if body.Enabled != nil {
updated, err = h.alertRules.UpdateEnabled(r.Context(), alertID, *body.Enabled)
if err != nil {
log.Printf("Error updating alert: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert")
return
}
log.Printf("Alert %d updated: enabled=%v", alertID, *body.Enabled)
}
writeJSON(w, http.StatusOK, updated)
}
// Remove handles DELETE requests to remove an alert rule.
func (h *AlertRuleHandler) Remove(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
alertID, ok := parseIntParam(r.PathValue("alertId"))
if !ok {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid alert ID")
return
}
@@ -225,17 +340,20 @@ func (h *AlertRuleHandler) Remove(w http.ResponseWriter, r *http.Request) {
if err != nil {
log.Printf("Error finding alert: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete alert")
return
}
if alert == nil {
log.Printf("Alert %d not found or not owned by user", alertID)
writeError(w, http.StatusNotFound, "NOT_FOUND", "Alert rule not found")
return
}
if _, err := h.alertRules.Remove(r.Context(), alertID); err != nil {
log.Printf("Error deleting alert: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete alert")
return
}

View File

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

View File

@@ -0,0 +1,101 @@
package handlers
import (
"log"
"net/http"
"github.com/kjannette/koin-ping/backend/internal/middleware"
"github.com/kjannette/koin-ping/backend/internal/models"
"github.com/kjannette/koin-ping/backend/internal/services"
)
type EmailDigestHandler struct {
digestSvc *services.EmailDigestService
configs *models.NotificationConfigModel
}
func NewEmailDigestHandler(
digestSvc *services.EmailDigestService,
configs *models.NotificationConfigModel,
) *EmailDigestHandler {
return &EmailDigestHandler{digestSvc: digestSvc, configs: configs}
}
// SetupEmail reads the user's email from their notification config and sends
// a confirmation message via Resend to verify the integration works.
func (h *EmailDigestHandler) SetupEmail(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
if !h.digestSvc.Configured() {
writeError(w, http.StatusServiceUnavailable, "EMAIL_NOT_CONFIGURED",
"Email service is not configured on the server")
return
}
cfg, err := h.configs.GetConfig(r.Context(), userID)
if err != nil {
log.Printf("Error getting notification config for email setup: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR",
"Failed to load notification config")
return
}
if cfg == nil || cfg.Email == nil || *cfg.Email == "" {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
"Save an email address in notification settings first")
return
}
if err := h.digestSvc.SetupEmail(*cfg.Email); err != nil {
log.Printf("Email setup failed for user %s: %v", userID, err)
writeError(w, http.StatusBadGateway, "EMAIL_SEND_FAILED",
"Failed to send confirmation email — check server email config")
return
}
log.Printf("Email setup confirmation sent to user %s (%s)", userID, *cfg.Email)
writeJSON(w, http.StatusOK, map[string]any{
"success": true,
"email": *cfg.Email,
"message": "Confirmation email sent",
})
}
// SendDigest compiles and sends a digest of recent alerts to the user's email.
func (h *EmailDigestHandler) SendDigest(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
if !h.digestSvc.Configured() {
writeError(w, http.StatusServiceUnavailable, "EMAIL_NOT_CONFIGURED",
"Email service is not configured on the server")
return
}
cfg, err := h.configs.GetConfig(r.Context(), userID)
if err != nil {
log.Printf("Error getting notification config for digest: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR",
"Failed to load notification config")
return
}
if cfg == nil || cfg.Email == nil || *cfg.Email == "" {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
"No email address configured")
return
}
if err := h.digestSvc.SendDigest(r.Context(), userID, *cfg.Email); err != nil {
log.Printf("Digest send failed for user %s: %v", userID, err)
writeError(w, http.StatusBadGateway, "DIGEST_SEND_FAILED",
"Failed to send digest email")
return
}
log.Printf("Digest sent to user %s (%s)", userID, *cfg.Email)
writeJSON(w, http.StatusOK, map[string]any{
"success": true,
"email": *cfg.Email,
"message": "Digest email sent",
})
}

View File

@@ -7,19 +7,22 @@ import (
"regexp"
"strings"
"github.com/kjannette/koin-ping/backend-go/internal/domain"
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
"github.com/kjannette/koin-ping/backend-go/internal/models"
"github.com/kjannette/koin-ping/backend/internal/config"
"github.com/kjannette/koin-ping/backend/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/middleware"
"github.com/kjannette/koin-ping/backend/internal/models"
"github.com/kjannette/koin-ping/backend/internal/notifications"
)
var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
type NotificationConfigHandler struct {
configs *models.NotificationConfigModel
cfg *config.Config
}
func NewNotificationConfigHandler(configs *models.NotificationConfigModel) *NotificationConfigHandler {
return &NotificationConfigHandler{configs: configs}
func NewNotificationConfigHandler(configs *models.NotificationConfigModel, cfg *config.Config) *NotificationConfigHandler {
return &NotificationConfigHandler{configs: configs, cfg: cfg}
}
func (h *NotificationConfigHandler) GetConfig(w http.ResponseWriter, r *http.Request) {
@@ -51,7 +54,9 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
var body struct {
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"`
NotificationEnabled *bool `json:"notification_enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
@@ -63,7 +68,8 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
log.Printf("User %s updating notification config", userID)
if body.DiscordWebhookURL == nil && body.TelegramChatID == nil &&
body.Email == nil && body.NotificationEnabled == nil {
body.TelegramBotToken == nil && body.Email == nil &&
body.SlackWebhookURL == nil && body.NotificationEnabled == nil {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
"At least one configuration field must be provided")
return
@@ -76,6 +82,13 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
return
}
if body.SlackWebhookURL != nil && *body.SlackWebhookURL != "" &&
!strings.HasPrefix(*body.SlackWebhookURL, "https://hooks.slack.com/") {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
"Invalid Slack webhook URL format")
return
}
if body.Email != nil && *body.Email != "" && !emailRe.MatchString(*body.Email) {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
"Invalid email address format")
@@ -90,7 +103,9 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
cfg := domain.NotificationConfig{
DiscordWebhookURL: body.DiscordWebhookURL,
TelegramChatID: body.TelegramChatID,
TelegramBotToken: body.TelegramBotToken,
Email: body.Email,
SlackWebhookURL: body.SlackWebhookURL,
NotificationEnabled: enabled,
}
@@ -126,3 +141,76 @@ func (h *NotificationConfigHandler) DeleteConfig(w http.ResponseWriter, r *http.
log.Println("Notification config deleted")
w.WriteHeader(http.StatusNoContent)
}
// TestChannels sends a test message to all configured notification channels.
func (h *NotificationConfigHandler) TestChannels(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
log.Printf("User %s testing notification channels", userID)
cfg, err := h.configs.GetConfig(r.Context(), userID)
if err != nil {
log.Printf("Error getting notification config for test: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to get notification config")
return
}
if cfg == nil {
writeError(w, http.StatusNotFound, "NOT_FOUND", "No notification configuration found")
return
}
type channelResult struct {
Channel string `json:"channel"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
var results []channelResult
if cfg.DiscordWebhookURL != nil && *cfg.DiscordWebhookURL != "" {
ok, testErr := notifications.TestDiscordWebhook(*cfg.DiscordWebhookURL)
res := channelResult{Channel: "discord", Success: ok}
if testErr != nil {
res.Error = testErr.Error()
}
results = append(results, res)
}
if cfg.TelegramBotToken != nil && *cfg.TelegramBotToken != "" &&
cfg.TelegramChatID != nil && *cfg.TelegramChatID != "" {
ok, testErr := notifications.TestTelegramWebhook(*cfg.TelegramBotToken, *cfg.TelegramChatID)
res := channelResult{Channel: "telegram", Success: ok}
if testErr != nil {
res.Error = testErr.Error()
}
results = append(results, res)
}
if cfg.SlackWebhookURL != nil && *cfg.SlackWebhookURL != "" {
ok, testErr := notifications.TestSlackWebhook(*cfg.SlackWebhookURL)
res := channelResult{Channel: "slack", Success: ok}
if testErr != nil {
res.Error = testErr.Error()
}
results = append(results, res)
}
if cfg.Email != nil && *cfg.Email != "" {
ok, testErr := notifications.TestEmailNotification(
h.cfg.ResendAPIKey, h.cfg.EmailFrom, *cfg.Email,
)
res := channelResult{Channel: "email", Success: ok}
if testErr != nil {
res.Error = testErr.Error()
}
results = append(results, res)
}
if len(results) == 0 {
writeError(w, http.StatusBadRequest, "NO_CHANNELS",
"No notification channels are configured")
return
}
writeJSON(w, http.StatusOK, map[string]any{"results": results})
}

View File

@@ -0,0 +1,71 @@
package handlers
import (
"log"
"net/http"
"time"
"github.com/kjannette/koin-ping/backend/internal/models"
)
// StatusHandler handles the system status endpoint.
type StatusHandler struct {
checkpoints *models.CheckpointModel
}
// NewStatusHandler creates a new StatusHandler.
func NewStatusHandler(checkpoints *models.CheckpointModel) *StatusHandler {
return &StatusHandler{checkpoints: checkpoints}
}
// GetStatus returns real-time system status derived from checkpoint data.
func (h *StatusHandler) GetStatus(w http.ResponseWriter, r *http.Request) {
block, checkedAt, err := h.checkpoints.GetLatestBlock(r.Context())
if err != nil {
log.Printf("Error querying latest block: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to get system status")
return
}
latestBlock := 0
lag := 0
status := "starting"
if checkedAt != nil {
lag = int(time.Since(*checkedAt).Seconds())
if lag > 600 { //nolint:mnd
status = "idle"
} else {
status = "active"
}
}
if block != nil {
latestBlock = *block
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": status,
"latestBlock": latestBlock,
"lag": lag,
"lastCheckedAt": checkedAtStr(checkedAt),
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}
func checkedAtStr(t *time.Time) string {
if t == nil {
return ""
}
return t.UTC().Format(time.RFC3339)
}
func HealthCheck(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "ok",
"timestamp": time.Now().UTC().Format(time.RFC3339),
"service": "koin-ping-backend",
})
}

View File

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

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

@@ -2,9 +2,11 @@ package models
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/kjannette/koin-ping/backend-go/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/domain"
)
type AddressModel struct {
@@ -97,7 +99,25 @@ func (m *AddressModel) FindByID(ctx context.Context, id int, userID *string) (*d
}
if err != nil {
if err.Error() == "no rows in result set" {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &a, nil
}
// UpdateLabel updates the label for an address owned by userID.
// Returns nil, nil if no row matched (address not found or not owned by user).
func (m *AddressModel) UpdateLabel(ctx context.Context, id int, userID string, label *string) (*domain.Address, error) {
var a domain.Address
err := m.pool.QueryRow(ctx,
`UPDATE addresses SET label = $3 WHERE id = $1 AND user_id = $2
RETURNING id, user_id, address, label, created_at`,
id, userID, label,
).Scan(&a.ID, &a.UserID, &a.Address, &a.Label, &a.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err

View File

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

View File

@@ -2,9 +2,11 @@ package models
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/kjannette/koin-ping/backend-go/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/domain"
)
type AlertRuleModel struct {
@@ -15,14 +17,14 @@ func NewAlertRuleModel(pool *pgxpool.Pool) *AlertRuleModel {
return &AlertRuleModel{pool: pool}
}
func (m *AlertRuleModel) Create(ctx context.Context, addressID int, alertType domain.AlertType, threshold *float64) (*domain.AlertRule, error) {
func (m *AlertRuleModel) Create(ctx context.Context, addressID int, alertType domain.AlertType, threshold, minimum, maximum *float64) (*domain.AlertRule, error) {
var r domain.AlertRule
err := m.pool.QueryRow(ctx,
`INSERT INTO alert_rules (address_id, type, threshold, enabled)
VALUES ($1, $2, $3, TRUE)
RETURNING id, address_id, type, threshold, enabled, created_at`,
addressID, string(alertType), threshold,
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt)
`INSERT INTO alert_rules (address_id, type, threshold, minimum, maximum, enabled)
VALUES ($1, $2, $3, $4, $5, TRUE)
RETURNING id, address_id, type, threshold, minimum, maximum, enabled, created_at`,
addressID, alertType.String(), threshold, minimum, maximum,
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
if err != nil {
return nil, err
}
@@ -31,7 +33,7 @@ func (m *AlertRuleModel) Create(ctx context.Context, addressID int, alertType do
func (m *AlertRuleModel) ListByAddress(ctx context.Context, addressID int) ([]domain.AlertRule, error) {
rows, err := m.pool.Query(ctx,
`SELECT id, address_id, type, threshold, enabled, created_at
`SELECT id, address_id, type, threshold, minimum, maximum, enabled, created_at
FROM alert_rules
WHERE address_id = $1
ORDER BY created_at DESC`,
@@ -45,7 +47,7 @@ func (m *AlertRuleModel) ListByAddress(ctx context.Context, addressID int) ([]do
var rules []domain.AlertRule
for rows.Next() {
var r domain.AlertRule
if err := rows.Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt); err != nil {
if err := rows.Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt); err != nil {
return nil, err
}
rules = append(rules, r)
@@ -59,23 +61,23 @@ func (m *AlertRuleModel) FindByID(ctx context.Context, id int, userID *string) (
if userID != nil {
err = m.pool.QueryRow(ctx,
`SELECT ar.id, ar.address_id, ar.type, ar.threshold, ar.enabled, ar.created_at
`SELECT ar.id, ar.address_id, ar.type, ar.threshold, ar.minimum, ar.maximum, ar.enabled, ar.created_at
FROM alert_rules ar
JOIN addresses a ON a.id = ar.address_id
WHERE ar.id = $1 AND a.user_id = $2`,
id, *userID,
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt)
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
} else {
err = m.pool.QueryRow(ctx,
`SELECT id, address_id, type, threshold, enabled, created_at
`SELECT id, address_id, type, threshold, minimum, maximum, enabled, created_at
FROM alert_rules
WHERE id = $1`,
id,
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt)
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
}
if err != nil {
if err.Error() == "no rows in result set" {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
@@ -89,11 +91,29 @@ func (m *AlertRuleModel) UpdateEnabled(ctx context.Context, id int, enabled bool
`UPDATE alert_rules
SET enabled = $2
WHERE id = $1
RETURNING id, address_id, type, threshold, enabled, created_at`,
RETURNING id, address_id, type, threshold, minimum, maximum, enabled, created_at`,
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.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
if err != nil {
if err.Error() == "no rows in result set" {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &r, nil
}
func (m *AlertRuleModel) UpdateThresholds(ctx context.Context, id int, minimum, maximum *float64) (*domain.AlertRule, error) {
var r domain.AlertRule
err := m.pool.QueryRow(ctx,
`UPDATE alert_rules
SET minimum = $2, maximum = $3
WHERE id = $1
RETURNING id, address_id, type, threshold, minimum, maximum, enabled, created_at`,
id, minimum, maximum,
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err

View File

@@ -2,9 +2,12 @@ package models
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/kjannette/koin-ping/backend-go/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/domain"
)
type CheckpointModel struct {
@@ -15,6 +18,20 @@ func NewCheckpointModel(pool *pgxpool.Pool) *CheckpointModel {
return &CheckpointModel{pool: pool}
}
// GetLatestBlock returns the highest last_checked_block and its timestamp across all addresses.
// Returns nil, nil, nil when no checkpoints exist yet.
func (m *CheckpointModel) GetLatestBlock(ctx context.Context) (*int, *time.Time, error) {
var block *int
var checkedAt *time.Time
err := m.pool.QueryRow(ctx,
`SELECT MAX(last_checked_block), MAX(last_checked_at) FROM address_checkpoints`,
).Scan(&block, &checkedAt)
if err != nil {
return nil, nil, err
}
return block, checkedAt, nil
}
// GetLastCheckedBlock returns the last checked block for an address, or -1 if never checked.
func (m *CheckpointModel) GetLastCheckedBlock(ctx context.Context, addressID int) (int, bool, error) {
var block int
@@ -23,7 +40,7 @@ func (m *CheckpointModel) GetLastCheckedBlock(ctx context.Context, addressID int
addressID,
).Scan(&block)
if err != nil {
if err.Error() == "no rows in result set" {
if errors.Is(err, pgx.ErrNoRows) {
return 0, false, nil
}
return 0, false, err

View File

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

View File

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

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

View File

@@ -0,0 +1,158 @@
package notifications
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
)
type EmailNotifier struct {
APIKey string
From string
To string
}
func (e *EmailNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
_, err := SendEmailNotification(e.APIKey, e.From, e.To, message, meta)
return err
}
const emailHTTPTimeoutSeconds = 10
var emailHTTPClient = &http.Client{ //nolint:gochecknoglobals
Timeout: emailHTTPTimeoutSeconds * time.Second,
}
type resendPayload struct {
From string `json:"from"`
To string `json:"to"`
Subject string `json:"subject"`
HTML string `json:"html"`
}
func SendEmailNotification(apiKey, fromAddress, toAddress, message string, meta AlertMetadata) (bool, error) {
if apiKey == "" {
log.Printf("Skipping email notification: RESEND_API_KEY not configured")
return false, nil
}
subject := fmt.Sprintf("Koin Ping Alert: %s", alertTypeLabel(meta.AlertType))
txLink := ""
if meta.TxHash != "" {
txLink = fmt.Sprintf(
`<p><a href="https://etherscan.io/tx/%s">View on Etherscan</a></p>`,
meta.TxHash,
)
}
html := fmt.Sprintf(`
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #333;">Koin Ping Alert</h2>
<p style="font-size: 16px;">%s</p>
<table style="margin: 16px 0; border-collapse: collapse;">
<tr>
<td style="padding: 4px 12px 4px 0; color: #666;">Address</td>
<td style="padding: 4px 0;">%s</td>
</tr>
<tr>
<td style="padding: 4px 12px 4px 0; color: #666;">Blockchain</td>
<td style="padding: 4px 0; font-family: monospace; font-size: 13px;">%s</td>
</tr>
</table>
%s
<hr style="border: none; border-top: 1px solid #eee; margin: 24px 0;" />
<p style="font-size: 12px; color: #999;">Sent by Koin Ping</p>
</div>`,
message, meta.AddressLabel, meta.Address, txLink)
payload := resendPayload{
From: fromAddress,
To: toAddress,
Subject: subject,
HTML: html,
}
body, err := json.Marshal(payload)
if err != nil {
return false, fmt.Errorf("marshal email payload: %w", err)
}
req, err := http.NewRequest(http.MethodPost, "https://api.resend.com/emails", bytes.NewReader(body))
if err != nil {
return false, fmt.Errorf("create email request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := emailHTTPClient.Do(req)
if err != nil {
log.Printf("Failed to send email notification: %v", err)
return false, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
err := fmt.Errorf("resend API failed: HTTP %d", resp.StatusCode)
log.Printf("Resend API failed: HTTP %d", resp.StatusCode)
if isPermanentStatusCode(resp.StatusCode) {
return false, &PermanentError{Err: err}
}
return false, err
}
return true, nil
}
func TestEmailNotification(apiKey, fromAddress, toAddress string) (bool, error) {
if apiKey == "" {
return false, fmt.Errorf("email not configured: RESEND_API_KEY not set") //nolint:err113
}
payload := resendPayload{
From: fromAddress,
To: toAddress,
Subject: "Koin Ping — Test Notification",
HTML: `<p>Your email alerts are configured correctly!</p><p style="font-size:12px;color:#999;">Sent by Koin Ping</p>`,
}
body, err := json.Marshal(payload)
if err != nil {
return false, err
}
req, err := http.NewRequest(http.MethodPost, "https://api.resend.com/emails", bytes.NewReader(body))
if err != nil {
return false, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := emailHTTPClient.Do(req)
if err != nil {
log.Printf("Email test failed: %v", err)
return false, err
}
defer resp.Body.Close()
return resp.StatusCode >= 200 && resp.StatusCode < 300, nil
}
func alertTypeLabel(alertType string) string {
switch alertType {
case "incoming_tx":
return "Incoming Transaction"
case "outgoing_tx":
return "Outgoing Transaction"
case "large_transfer":
return "Large Transfer"
case "balance_below":
return "Balance Below Threshold"
default:
return "Alert"
}
}

View File

@@ -0,0 +1,38 @@
package notifications
import (
"context"
"errors"
"net/http"
)
// AlertMetadata holds context about the alert being sent.
type AlertMetadata struct {
TxHash string
AddressLabel string
AlertType string
Address string
}
type Notifier interface {
Send(ctx context.Context, message string, meta AlertMetadata) error
}
// PermanentError wraps errors that should not be retried (e.g. 401, 403, 404).
type PermanentError struct{ Err error }
func (e *PermanentError) Error() string { return e.Err.Error() }
func (e *PermanentError) Unwrap() error { return e.Err }
func IsPermanent(err error) bool {
var p *PermanentError
return errors.As(err, &p)
}
func isPermanentStatusCode(code int) bool {
return code == http.StatusUnauthorized ||
code == http.StatusForbidden ||
code == http.StatusNotFound ||
code == http.StatusMethodNotAllowed ||
code == http.StatusGone
}

View File

@@ -0,0 +1,132 @@
package notifications
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
)
// SlackNotifier sends alert notifications via a Slack webhook.
type SlackNotifier struct {
WebhookURL string
}
// Send implements Notifier for Slack.
func (s *SlackNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
_, err := SendSlackNotification(s.WebhookURL, message, meta)
return err
}
const slackHTTPTimeoutSeconds = 10
var slackHTTPClient = &http.Client{ //nolint:gochecknoglobals
Timeout: slackHTTPTimeoutSeconds * time.Second,
}
type slackAttachment struct {
Color string `json:"color"`
Title string `json:"title"`
Text string `json:"text"`
Fields []slackField `json:"fields"`
Footer string `json:"footer"`
Ts int64 `json:"ts"`
}
type slackField struct {
Title string `json:"title"`
Value string `json:"value"`
Short bool `json:"short"`
}
type slackPayload struct {
Text string `json:"text,omitempty"`
Attachments []slackAttachment `json:"attachments,omitempty"`
}
func SendSlackNotification(webhookURL, message string, meta AlertMetadata) (bool, error) {
fields := []slackField{
{Title: "Address", Value: meta.AddressLabel, Short: true},
{Title: "Blockchain Address", Value: fmt.Sprintf("`%s`", meta.Address), Short: false},
}
if meta.TxHash != "" {
fields = append(fields, slackField{
Title: "Transaction",
Value: fmt.Sprintf("<https://etherscan.io/tx/%s|View on Etherscan>", meta.TxHash),
Short: false,
})
}
payload := slackPayload{
Attachments: []slackAttachment{
{
Color: slackColorForAlertType(meta.AlertType),
Title: "Koin Ping Alert",
Text: message,
Fields: fields,
Footer: "Koin Ping",
Ts: time.Now().Unix(),
},
},
}
body, err := json.Marshal(payload)
if err != nil {
return false, fmt.Errorf("marshal slack payload: %w", err)
}
resp, err := slackHTTPClient.Post(webhookURL, "application/json", bytes.NewReader(body))
if err != nil {
log.Printf("Failed to send Slack notification: %v", err)
return false, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
err := fmt.Errorf("slack webhook failed: HTTP %d", resp.StatusCode)
log.Printf("Slack webhook failed: HTTP %d", resp.StatusCode)
if isPermanentStatusCode(resp.StatusCode) {
return false, &PermanentError{Err: err}
}
return false, err
}
return true, nil
}
func TestSlackWebhook(webhookURL string) (bool, error) {
payload := slackPayload{
Text: "Koin Ping test notification — Your Slack alerts are configured correctly!",
}
body, err := json.Marshal(payload)
if err != nil {
return false, err
}
resp, err := slackHTTPClient.Post(webhookURL, "application/json", bytes.NewReader(body))
if err != nil {
log.Printf("Slack webhook test failed: %v", err)
return false, err
}
defer resp.Body.Close()
return resp.StatusCode >= 200 && resp.StatusCode < 300, nil
}
func slackColorForAlertType(alertType string) string {
switch alertType {
case "incoming_tx":
return "#00ff00"
case "outgoing_tx":
return "#ff9900"
case "large_transfer", "balance_below":
return "#ff0000"
default:
return "#0099ff"
}
}

View File

@@ -0,0 +1,126 @@
package notifications
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
)
// TelegramNotifier sends alert notifications via Telegram.
type TelegramNotifier struct {
BotToken string
ChatID string
}
// Send implements Notifier for Telegram.
func (t *TelegramNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
_, err := SendTelegramNotification(t.BotToken, t.ChatID, message, meta)
return err
}
const telegramHTTPTimeoutSeconds = 10
var telegramHTTPClient = &http.Client{ //nolint:gochecknoglobals
Timeout: telegramHTTPTimeoutSeconds * time.Second,
}
type telegramPayload struct {
ChatID string `json:"chat_id"`
Text string `json:"text"`
ParseMode string `json:"parse_mode"`
}
func SendTelegramNotification(botToken, chatID, message string, meta AlertMetadata) (bool, error) {
text := fmt.Sprintf("*Koin Ping Alert*\n\n%s\n\n*Address:* %s\n`%s`",
escapeMarkdown(message), escapeMarkdown(meta.AddressLabel), meta.Address)
if meta.TxHash != "" {
text += fmt.Sprintf("\n\n[View on Etherscan](https://etherscan.io/tx/%s)", meta.TxHash)
}
payload := telegramPayload{
ChatID: chatID,
Text: text,
ParseMode: "Markdown",
}
body, err := json.Marshal(payload)
if err != nil {
return false, fmt.Errorf("marshal telegram payload: %w", err)
}
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken)
resp, err := telegramHTTPClient.Post(url, "application/json", bytes.NewReader(body))
if err != nil {
log.Printf("Failed to send Telegram notification: %v", err)
return false, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
err := fmt.Errorf("telegram API failed: HTTP %d", resp.StatusCode)
log.Printf("Telegram API failed: HTTP %d", resp.StatusCode)
if isPermanentStatusCode(resp.StatusCode) {
return false, &PermanentError{Err: err}
}
return false, err
}
return true, nil
}
func TestTelegramWebhook(botToken, chatID string) (bool, error) {
payload := telegramPayload{
ChatID: chatID,
Text: "Koin Ping test notification — Your Telegram alerts are configured correctly!",
ParseMode: "Markdown",
}
body, err := json.Marshal(payload)
if err != nil {
return false, err
}
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken)
resp, err := telegramHTTPClient.Post(url, "application/json", bytes.NewReader(body))
if err != nil {
log.Printf("Telegram test failed: %v", err)
return false, err
}
defer resp.Body.Close()
return resp.StatusCode >= 200 && resp.StatusCode < 300, nil
}
func escapeMarkdown(s string) string {
replacer := []struct{ old, new string }{
{"_", "\\_"}, {"*", "\\*"}, {"[", "\\["}, {"]", "\\]"},
{"(", "\\("}, {")", "\\)"}, {"~", "\\~"}, {"`", "\\`"},
{">", "\\>"}, {"#", "\\#"}, {"+", "\\+"}, {"-", "\\-"},
{"=", "\\="}, {"|", "\\|"}, {"{", "\\{"}, {"}", "\\}"},
{".", "\\."}, {"!", "\\!"},
}
result := s
for _, r := range replacer {
result = replaceAll(result, r.old, r.new)
}
return result
}
func replaceAll(s, old, new string) string {
out := ""
for i := 0; i < len(s); i++ {
if string(s[i]) == old {
out += new
} else {
out += string(s[i])
}
}
return out
}

View File

@@ -0,0 +1,421 @@
package ethereum
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log"
"math/big"
"net/http"
"strings"
"time"
"github.com/kjannette/koin-ping/backend/internal/domain"
)
const (
rpcTimeoutMS = 30000
rpcMaxRetries = 3
rpcRetryBaseMS = 2000
rpcMinIntervalMS = 1000
)
type JsonRpcEthereum struct {
rpcURL string
client *http.Client
lastCallAt time.Time
}
func NewJsonRpcEthereum(rpcURL string) (*JsonRpcEthereum, error) {
if rpcURL == "" {
return nil, fmt.Errorf("JsonRpcEthereum requires a valid RPC URL")
}
return &JsonRpcEthereum{
rpcURL: rpcURL,
client: &http.Client{
Timeout: time.Duration(rpcTimeoutMS) * time.Millisecond,
},
}, nil
}
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID int64 `json:"id"`
Method string `json:"method"`
Params []interface{} `json:"params"`
}
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID int64 `json:"id"`
Result json.RawMessage `json:"result"`
Error *rpcError `json:"error"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func (j *JsonRpcEthereum) callRPC(ctx context.Context, method string, params ...interface{}) (json.RawMessage, error) {
j.throttle(ctx)
if params == nil {
params = []interface{}{}
}
body, err := json.Marshal(rpcRequest{
JSONRPC: "2.0",
ID: time.Now().UnixMilli(),
Method: method,
Params: params,
})
if err != nil {
return nil, fmt.Errorf("marshal RPC request: %w", err)
}
result, callErr := j.callWithRetry(ctx, method, body)
j.lastCallAt = time.Now()
return result, callErr
}
func (j *JsonRpcEthereum) throttle(ctx context.Context) {
if j.lastCallAt.IsZero() {
return
}
minInterval := time.Duration(rpcMinIntervalMS) * time.Millisecond
elapsed := time.Since(j.lastCallAt)
if elapsed >= minInterval {
return
}
select {
case <-ctx.Done():
case <-time.After(minInterval - elapsed):
}
}
// callWithRetry executes a JSON-RPC POST with exponential backoff on transient errors.
// It retries on network errors, HTTP 429, and HTTP 5xx. It does NOT retry on RPC-level
// errors or other 4xx responses (those are permanent failures).
func (j *JsonRpcEthereum) callWithRetry(ctx context.Context, method string, body []byte) (json.RawMessage, error) {
var lastErr error
for attempt := range rpcMaxRetries {
if attempt > 0 {
wait := time.Duration(rpcRetryBaseMS*(1<<(attempt-1))) * time.Millisecond
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
}
log.Printf("Retrying RPC call [%s] (attempt %d/%d)", method, attempt+1, rpcMaxRetries)
}
result, err := j.doRPCCall(ctx, method, body)
if err == nil {
return result, nil
}
lastErr = err
// Permanent errors: do not retry
if isPermanentRPCError(err) {
return nil, err
}
log.Printf("Transient RPC error [%s] (attempt %d/%d): %v", method, attempt+1, rpcMaxRetries, err)
}
return nil, lastErr
}
func (j *JsonRpcEthereum) doRPCCall(ctx context.Context, method string, body []byte) (json.RawMessage, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, j.rpcURL, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create RPC request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := j.client.Do(req)
if err != nil {
return nil, fmt.Errorf("RPC call failed [%s]: %w", method, err)
}
defer resp.Body.Close()
// 429 and 5xx are transient; other non-200 are permanent.
if resp.StatusCode != http.StatusOK {
err := fmt.Errorf("HTTP %d: %s for %s", resp.StatusCode, resp.Status, method)
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 { //nolint:mnd
return nil, err // transient — will be retried
}
return nil, &permanentRPCError{err}
}
var rpcResp rpcResponse
if err := json.NewDecoder(resp.Body).Decode(&rpcResp); err != nil {
return nil, fmt.Errorf("decode RPC response: %w", err)
}
if rpcResp.Error != nil {
// RPC-level errors are permanent (bad params, unsupported method, etc.)
return nil, &permanentRPCError{
fmt.Errorf("RPC Error [%s]: %s (code: %d)", method, rpcResp.Error.Message, rpcResp.Error.Code),
}
}
return rpcResp.Result, nil
}
type permanentRPCError struct{ cause error }
func (e *permanentRPCError) Error() string { return e.cause.Error() }
func (e *permanentRPCError) Unwrap() error { return e.cause }
func isPermanentRPCError(err error) bool {
var p *permanentRPCError
return errors.As(err, &p)
}
func (j *JsonRpcEthereum) GetLatestBlockNumber(ctx context.Context) (int, error) {
result, err := j.callRPC(ctx, "eth_blockNumber")
if err != nil {
return 0, err
}
var hexBlock string
if err := json.Unmarshal(result, &hexBlock); err != nil {
return 0, fmt.Errorf("unmarshal block number: %w", err)
}
return hexToInt(hexBlock)
}
type rpcBlock struct {
Timestamp string `json:"timestamp"`
Transactions []rpcTx `json:"transactions"`
}
type rpcTx struct {
Hash string `json:"hash"`
From string `json:"from"`
To *string `json:"to"`
Value string `json:"value"`
BlockNumber string `json:"blockNumber"`
}
func (j *JsonRpcEthereum) GetBlockTransactions(ctx context.Context, blockNumber int) ([]domain.NormalizedTx, error) {
hexBlock := fmt.Sprintf("0x%x", blockNumber)
result, err := j.callRPC(ctx, "eth_getBlockByNumber", hexBlock, true)
if err != nil {
return nil, err
}
if string(result) == "null" {
return nil, nil
}
var block rpcBlock
if err := json.Unmarshal(result, &block); err != nil {
return nil, fmt.Errorf("unmarshal block: %w", err)
}
blockTimestamp, err := hexToInt64(block.Timestamp)
if err != nil {
return nil, fmt.Errorf("parse block timestamp: %w", err)
}
txs := make([]domain.NormalizedTx, 0, len(block.Transactions))
for _, tx := range block.Transactions {
bn, _ := hexToInt(tx.BlockNumber)
var toLower *string
if tx.To != nil {
s := strings.ToLower(*tx.To)
toLower = &s
}
txs = append(txs, domain.NormalizedTx{
Hash: tx.Hash,
From: strings.ToLower(tx.From),
To: toLower,
Value: hexToDecimalString(tx.Value),
BlockNumber: bn,
BlockTimestamp: blockTimestamp,
})
}
return txs, nil
}
func (j *JsonRpcEthereum) GetBalance(ctx context.Context, address string) (string, error) {
result, err := j.callRPC(ctx, "eth_getBalance", address, "latest")
if err != nil {
return "", err
}
var hexBalance string
if err := json.Unmarshal(result, &hexBalance); err != nil {
return "", fmt.Errorf("unmarshal balance: %w", err)
}
return hexToDecimalString(hexBalance), nil
}
const erc20TransferTopic = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
const minTransferTopics = 3
type rpcLog struct {
Address string `json:"address"`
Topics []string `json:"topics"`
Data string `json:"data"`
BlockNumber string `json:"blockNumber"`
TxHash string `json:"transactionHash"`
}
// GetTokenTransfers fetches ERC-20 Transfer events for a given address
// across a block range, covering both incoming and outgoing transfers.
func (j *JsonRpcEthereum) GetTokenTransfers(ctx context.Context, fromBlock, toBlock int, address string) ([]domain.NormalizedTx, error) {
paddedAddr := padAddress(address)
hexFrom := fmt.Sprintf("0x%x", fromBlock)
hexTo := fmt.Sprintf("0x%x", toBlock)
incomingLogs, err := j.getTransferLogs(ctx, hexFrom, hexTo, "", paddedAddr)
if err != nil {
return nil, fmt.Errorf("fetch incoming token transfers: %w", err)
}
outgoingLogs, err := j.getTransferLogs(ctx, hexFrom, hexTo, paddedAddr, "")
if err != nil {
return nil, fmt.Errorf("fetch outgoing token transfers: %w", err)
}
seen := make(map[string]bool)
var txs []domain.NormalizedTx
for _, entry := range append(incomingLogs, outgoingLogs...) {
key := entry.TxHash + "|" + entry.Address + "|" + entry.Data
if seen[key] {
continue
}
seen[key] = true
tx, parseErr := parseTransferLog(entry)
if parseErr != nil {
log.Printf("Skipping unparseable transfer log in tx %s: %v", entry.TxHash, parseErr)
continue
}
txs = append(txs, tx)
}
return txs, nil
}
func (j *JsonRpcEthereum) getTransferLogs(ctx context.Context, fromBlock, toBlock, fromAddr, toAddr string) ([]rpcLog, error) {
topics := make([]interface{}, minTransferTopics)
topics[0] = erc20TransferTopic
if fromAddr != "" {
topics[1] = fromAddr
}
if toAddr != "" {
topics[2] = toAddr
}
filter := map[string]interface{}{
"fromBlock": fromBlock,
"toBlock": toBlock,
"topics": topics,
}
result, err := j.callRPC(ctx, "eth_getLogs", filter)
if err != nil {
return nil, err
}
var logs []rpcLog
if err := json.Unmarshal(result, &logs); err != nil {
return nil, fmt.Errorf("unmarshal logs: %w", err)
}
return logs, nil
}
func parseTransferLog(entry rpcLog) (domain.NormalizedTx, error) {
if len(entry.Topics) < minTransferTopics {
return domain.NormalizedTx{}, fmt.Errorf("transfer log has %d topics, expected >= 3", len(entry.Topics))
}
from := topicToAddress(entry.Topics[1])
to := topicToAddress(entry.Topics[2])
tokenValue := hexToDecimalString(entry.Data)
blockNumber, _ := hexToInt(entry.BlockNumber)
contractAddr := strings.ToLower(entry.Address)
tokenInfo, known := LookupToken(contractAddr)
tx := domain.NormalizedTx{
Hash: entry.TxHash,
From: from,
To: &to,
Value: "0",
BlockNumber: blockNumber,
TokenContract: &contractAddr,
TokenValue: &tokenValue,
}
if known {
tx.TokenSymbol = &tokenInfo.Symbol
tx.TokenDecimals = &tokenInfo.Decimals
}
return tx, nil
}
func padAddress(addr string) string {
clean := strings.TrimPrefix(strings.ToLower(addr), "0x")
const addressHexLen = 64
return "0x" + strings.Repeat("0", addressHexLen-len(clean)) + clean
}
func topicToAddress(topic string) string {
clean := strings.TrimPrefix(topic, "0x")
const ethAddrLen = 40
if len(clean) > ethAddrLen {
clean = clean[len(clean)-ethAddrLen:]
}
return "0x" + strings.ToLower(clean)
}
func hexToInt(hex string) (int, error) {
hex = strings.TrimPrefix(hex, "0x")
n, ok := new(big.Int).SetString(hex, 16)
if !ok {
return 0, fmt.Errorf("invalid hex: %s", hex)
}
return int(n.Int64()), nil
}
func hexToInt64(hex string) (int64, error) {
hex = strings.TrimPrefix(hex, "0x")
n, ok := new(big.Int).SetString(hex, 16)
if !ok {
return 0, fmt.Errorf("invalid hex: %s", hex)
}
return n.Int64(), nil
}
func hexToDecimalString(hex string) string {
if hex == "" || hex == "0x" || hex == "0x0" {
return "0"
}
clean := strings.TrimPrefix(hex, "0x")
n, ok := new(big.Int).SetString(clean, 16)
if !ok {
return "0"
}
return n.String()
}

View File

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

View File

@@ -0,0 +1,34 @@
package ethereum
import "strings"
// TokenInfo holds metadata for a known ERC-20 token contract.
type TokenInfo struct {
Symbol string
Decimals int
}
//nolint:gochecknoglobals
var wellKnownTokens = map[string]TokenInfo{
"0xdac17f958d2ee523a2206206994597c13d831ec7": {Symbol: "USDT", Decimals: 6},
"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48": {Symbol: "USDC", Decimals: 6},
"0x6b175474e89094c44da98b954eedeac495271d0f": {Symbol: "DAI", Decimals: 18},
"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2": {Symbol: "WETH", Decimals: 18},
"0x2260fac5e5542a773aa44fbcfedf7c193bc2c599": {Symbol: "WBTC", Decimals: 8},
"0x514910771af9ca656af840dff83e8264ecf986ca": {Symbol: "LINK", Decimals: 18},
"0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": {Symbol: "UNI", Decimals: 18},
"0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9": {Symbol: "AAVE", Decimals: 18},
"0x95ad61b0a150d79219dcf64e1e6cc01f0b64c4ce": {Symbol: "SHIB", Decimals: 18},
"0x6982508145454ce325ddbe47a25d4ec3d2311933": {Symbol: "PEPE", Decimals: 18},
"0xb8c77482e45f1f44de1745f52c74426c631bdd52": {Symbol: "BNB", Decimals: 18},
"0x4fabb145d64652a948d72533023f6e7a623c7c53": {Symbol: "BUSD", Decimals: 18},
"0x75231f58b43240c9718dd58b4967c5114342a86c": {Symbol: "OKB", Decimals: 18},
"0x582d872a1b094fc48f5de31d3b73f2d9be47def1": {Symbol: "TON", Decimals: 9},
"0x4d224452801aced8b2f0aebe155379bb5d594381": {Symbol: "APE", Decimals: 18},
}
// LookupToken returns metadata for a known token contract, if found.
func LookupToken(contractAddress string) (TokenInfo, bool) {
info, ok := wellKnownTokens[strings.ToLower(contractAddress)]
return info, ok
}

View File

@@ -0,0 +1,196 @@
package services
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"github.com/kjannette/koin-ping/backend/internal/models"
)
const (
resendAPIURL = "https://api.resend.com/emails"
emailHTTPTimeout = 10 * time.Second
defaultDigestMaxItems = 50
)
var digestHTTPClient = &http.Client{Timeout: emailHTTPTimeout} //nolint:gochecknoglobals
// handles email setup and digest sending via Resend.
type EmailDigestService struct {
apiKey string
fromAddress string
alertEvents *models.AlertEventModel
notifCfgs *models.NotificationConfigModel
}
func NewEmailDigestService(
apiKey, fromAddress string,
alertEvents *models.AlertEventModel,
notifCfgs *models.NotificationConfigModel,
) *EmailDigestService {
return &EmailDigestService{
apiKey: apiKey,
fromAddress: fromAddress,
alertEvents: alertEvents,
notifCfgs: notifCfgs,
}
}
// Configured returns true when the Resend API key is present.
func (s *EmailDigestService) Configured() bool {
return s.apiKey != ""
}
// SetupEmail validates the email works by sending a welcome/confirmation
// message via Resend. Called when a user saves their email in notification settings.
func (s *EmailDigestService) SetupEmail(toAddress string) error {
if !s.Configured() {
return fmt.Errorf("email service not configured: RESEND_API_KEY not set") //nolint:err113
}
html := `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #333;">Welcome to Koin Ping Email Alerts</h2>
<p>Your email has been successfully configured for alert notifications.</p>
<p>You will receive alert digests at this address when events are triggered
on your watched addresses.</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 24px 0;" />
<p style="font-size: 12px; color: #999;">Sent by Koin Ping</p>
</div>`
return s.send(toAddress, "Koin Ping — Email Alerts Configured", html)
}
func (s *EmailDigestService) SendDigest(ctx context.Context, userID, toAddress string) error {
if !s.Configured() {
return fmt.Errorf("email service not configured: RESEND_API_KEY not set") //nolint:err113
}
events, err := s.alertEvents.ListRecentByUser(ctx, userID, defaultDigestMaxItems)
if err != nil {
return fmt.Errorf("fetch alert events: %w", err)
}
if len(events) == 0 {
log.Printf("No recent alerts for user %s — skipping digest", userID)
return nil
}
var rows string
for _, e := range events {
label := "—"
if e.AddressLabel != nil {
label = *e.AddressLabel
}
txLink := "—"
if e.TxHash != nil {
txLink = fmt.Sprintf(
`<a href="https://etherscan.io/tx/%s" style="color:#0066cc;">%s…</a>`,
*e.TxHash, (*e.TxHash)[:10],
)
}
rows += fmt.Sprintf(`
<tr>
<td style="padding:6px 8px; border-bottom:1px solid #eee;">%s</td>
<td style="padding:6px 8px; border-bottom:1px solid #eee;">%s</td>
<td style="padding:6px 8px; border-bottom:1px solid #eee;">%s</td>
<td style="padding:6px 8px; border-bottom:1px solid #eee; font-size:12px; color:#666;">%s</td>
</tr>`,
label, e.Message, txLink,
e.Timestamp.Format("Jan 2 15:04 UTC"),
)
}
html := fmt.Sprintf(`
<div style="font-family: sans-serif; max-width: 700px; margin: 0 auto;">
<h2 style="color: #333;">Koin Ping — Alert Digest</h2>
<p>Here are your recent alerts (%d total):</p>
<table style="width:100%%; border-collapse:collapse; font-size:14px;">
<thead>
<tr style="background:#f5f5f5;">
<th style="padding:8px; text-align:left;">Address</th>
<th style="padding:8px; text-align:left;">Alert</th>
<th style="padding:8px; text-align:left;">Tx</th>
<th style="padding:8px; text-align:left;">Time</th>
</tr>
</thead>
<tbody>%s</tbody>
</table>
<hr style="border:none; border-top:1px solid #eee; margin:24px 0;" />
<p style="font-size:12px; color:#999;">Sent by Koin Ping</p>
</div>`, len(events), rows)
subject := fmt.Sprintf("Koin Ping Digest — %d alerts", len(events))
return s.send(toAddress, subject, html)
}
func (s *EmailDigestService) SendDigestsForAllUsers(ctx context.Context) (int, error) {
if !s.Configured() {
return 0, nil
}
configs, err := s.notifCfgs.ListEnabled(ctx)
if err != nil {
return 0, fmt.Errorf("list enabled configs: %w", err)
}
sent := 0
for _, cfg := range configs {
if cfg.Email == nil || *cfg.Email == "" {
continue
}
if err := s.SendDigest(ctx, cfg.UserID, *cfg.Email); err != nil {
log.Printf("Failed to send digest to user %s: %v", cfg.UserID, err)
continue
}
sent++
}
return sent, nil
}
type resendEmailPayload struct {
From string `json:"from"`
To string `json:"to"`
Subject string `json:"subject"`
HTML string `json:"html"`
}
func (s *EmailDigestService) send(to, subject, html string) error {
payload := resendEmailPayload{
From: s.fromAddress,
To: to,
Subject: subject,
HTML: html,
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal email payload: %w", err)
}
req, err := http.NewRequest(http.MethodPost, resendAPIURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+s.apiKey)
resp, err := digestHTTPClient.Do(req)
if err != nil {
return fmt.Errorf("send email via Resend: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("Resend API returned HTTP %d", resp.StatusCode) //nolint:err113
}
return nil
}

View File

@@ -0,0 +1,405 @@
package services
import (
"context"
"fmt"
"log"
"sync"
"time"
"golang.org/x/sync/semaphore"
"github.com/kjannette/koin-ping/backend/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/models"
"github.com/kjannette/koin-ping/backend/internal/notifications"
"github.com/kjannette/koin-ping/backend/internal/protocols/ethereum"
"github.com/kjannette/koin-ping/backend/internal/wei"
)
const (
notificationTimeout = 30 * time.Second
notificationMaxRetries = 3
notificationRetryBase = time.Second
maxConcurrentNotifications = 5
)
type EvaluatorService struct {
eth ethereum.EthereumObserver
alertRules *models.AlertRuleModel
alertEvents *models.AlertEventModel
addresses *models.AddressModel
notifConfigs *models.NotificationConfigModel
resendAPIKey string
emailFrom string
notifSem *semaphore.Weighted
notifWg sync.WaitGroup
}
func NewEvaluatorService(
eth ethereum.EthereumObserver,
alertRules *models.AlertRuleModel,
alertEvents *models.AlertEventModel,
addresses *models.AddressModel,
notifConfigs *models.NotificationConfigModel,
resendAPIKey string,
emailFrom string,
) *EvaluatorService {
return &EvaluatorService{
eth: eth,
alertRules: alertRules,
alertEvents: alertEvents,
addresses: addresses,
notifConfigs: notifConfigs,
resendAPIKey: resendAPIKey,
emailFrom: emailFrom,
notifSem: semaphore.NewWeighted(maxConcurrentNotifications),
}
}
func (s *EvaluatorService) Evaluate(ctx context.Context, observations []domain.ObservedTx) (int, error) {
alertsFired := 0
for _, obs := range observations {
fired, err := s.evaluateObservation(ctx, obs)
if err != nil {
log.Printf("Error evaluating observation for address ID %d: %v", obs.AddressID, err)
continue
}
alertsFired += fired
}
return alertsFired, nil
}
func (s *EvaluatorService) evaluateObservation(ctx context.Context, obs domain.ObservedTx) (int, error) {
rules, err := s.alertRules.ListByAddress(ctx, obs.AddressID)
if err != nil {
return 0, err
}
alertsFired := 0
for _, rule := range rules {
if !rule.Enabled {
continue
}
matches, err := s.ruleMatches(ctx, rule, obs)
if err != nil {
log.Printf("Error matching rule %d: %v", rule.ID, err)
continue
}
if matches {
if err := s.fireAlert(ctx, rule, obs); err != nil {
log.Printf("Error firing alert for rule %d: %v", rule.ID, err)
continue
}
alertsFired++
}
}
return alertsFired, nil
}
func (s *EvaluatorService) ruleMatches(ctx context.Context, rule domain.AlertRule, obs domain.ObservedTx) (bool, error) {
switch rule.Type {
case domain.AlertIncomingTx:
return s.matchesDirectionalTx(rule, obs, domain.DirectionIncoming)
case domain.AlertOutgoingTx:
return s.matchesDirectionalTx(rule, obs, domain.DirectionOutgoing)
case domain.AlertLargeTransfer:
return s.matchesLargeTransfer(rule, obs)
case domain.AlertBalanceBelow:
return s.matchesBalanceBelow(ctx, rule, obs)
default:
log.Printf("Unknown rule type: %s", rule.Type)
return false, nil
}
}
func (s *EvaluatorService) matchesDirectionalTx(rule domain.AlertRule, obs domain.ObservedTx, expected domain.Direction) (bool, error) {
if obs.Direction != expected {
return false, nil
}
if rule.Minimum != nil {
minWei, err := wei.FromEth(*rule.Minimum)
if err != nil {
return false, err
}
aboveMin, err := wei.GreaterThanOrEqual(obs.Value, minWei)
if err != nil {
return false, err
}
if !aboveMin {
return false, nil
}
}
if rule.Maximum != nil {
maxWei, err := wei.FromEth(*rule.Maximum)
if err != nil {
return false, err
}
belowMax, err := wei.LessThanOrEqual(obs.Value, maxWei)
if err != nil {
return false, err
}
if !belowMax {
return false, nil
}
}
return true, nil
}
func (s *EvaluatorService) matchesLargeTransfer(rule domain.AlertRule, obs domain.ObservedTx) (bool, error) {
if rule.Threshold == nil {
return false, nil
}
thresholdWei, err := wei.FromEth(*rule.Threshold)
if err != nil {
return false, err
}
return wei.GreaterThanOrEqual(obs.Value, thresholdWei)
}
// matchesBalanceBelow only triggers after outgoing transactions, since incoming
// transactions increase balance and can't cause it to drop below threshold.
func (s *EvaluatorService) matchesBalanceBelow(ctx context.Context, rule domain.AlertRule, obs domain.ObservedTx) (bool, error) {
if rule.Threshold == nil {
return false, nil
}
if obs.Direction != domain.DirectionOutgoing {
return false, nil
}
addr, err := s.addresses.FindByID(ctx, obs.AddressID, nil)
if err != nil {
return false, err
}
if addr == nil {
log.Printf("Address ID %d not found", obs.AddressID)
return false, nil
}
balanceWei, err := s.eth.GetBalance(ctx, addr.Address)
if err != nil {
return false, err
}
thresholdWei, err := wei.FromEth(*rule.Threshold)
if err != nil {
return false, err
}
return wei.LessThan(balanceWei, thresholdWei)
}
func (s *EvaluatorService) fireAlert(ctx context.Context, rule domain.AlertRule, obs domain.ObservedTx) error {
addr, err := s.addresses.FindByID(ctx, obs.AddressID, nil)
if err != nil {
return err
}
addressLabel := "Unknown"
if addr != nil {
if addr.Label != nil {
addressLabel = *addr.Label
} else {
addressLabel = addr.Address
}
}
message := s.buildMessage(rule, obs)
txHash := &obs.Hash
event, err := s.alertEvents.Create(ctx, rule.ID, message, &addressLabel, txHash)
if err != nil {
return err
}
if event == nil {
log.Printf("[ALERT DEDUP] Rule %d (%s) - duplicate event skipped for TX: %s", rule.ID, rule.Type, obs.Hash)
return nil
}
log.Printf("[ALERT FIRED] Rule %d (%s) - %s - TX: %s", rule.ID, rule.Type, message, obs.Hash)
if addr != nil {
userID := addr.UserID
address := addr.Address
if err := s.notifSem.Acquire(ctx, 1); err != nil {
log.Printf("Failed to acquire notification semaphore for rule %d: %v", rule.ID, err)
return nil
}
s.notifWg.Add(1)
go func() {
defer s.notifSem.Release(1)
defer s.notifWg.Done()
notifCtx, cancel := context.WithTimeout(context.Background(), notificationTimeout)
defer cancel()
s.sendNotification(notifCtx, userID, message, obs, addressLabel, rule, address)
}()
}
return nil
}
// WaitForNotifications blocks until all in-flight notification goroutines finish.
func (s *EvaluatorService) WaitForNotifications() {
s.notifWg.Wait()
}
func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig) []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)
if err != nil {
log.Printf("Failed to get notification config: %v", err)
return
}
if notifConfig == nil || !notifConfig.NotificationEnabled {
return
}
meta := notifications.AlertMetadata{
TxHash: obs.Hash,
AddressLabel: addressLabel,
AlertType: string(rule.Type),
Address: address,
}
for _, n := range s.buildNotifiers(notifConfig) {
if err := sendWithRetry(ctx, n, message, meta); err != nil {
log.Printf("Notification channel failed for user %s after retries: %v", userID, err)
} else {
log.Printf("Notification sent to user %s via %T", userID, n)
}
}
}
func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.ObservedTx) string {
if obs.IsTokenTransfer() {
return s.buildTokenMessage(rule, obs)
}
switch rule.Type {
case domain.AlertIncomingTx:
ethStr, _ := wei.FormatAsEth(obs.Value, 4)
return fmt.Sprintf("Incoming transaction: %s received", ethStr)
case domain.AlertOutgoingTx:
ethStr, _ := wei.FormatAsEth(obs.Value, 4)
return fmt.Sprintf("Outgoing transaction: %s sent", ethStr)
case domain.AlertLargeTransfer:
ethStr, _ := wei.FormatAsEth(obs.Value, 4)
threshold := float64(0)
if rule.Threshold != nil {
threshold = *rule.Threshold
}
return fmt.Sprintf("Large transfer detected: %s (threshold: %g ETH)", ethStr, threshold)
case domain.AlertBalanceBelow:
threshold := float64(0)
if rule.Threshold != nil {
threshold = *rule.Threshold
}
return fmt.Sprintf("Balance dropped below threshold of %g ETH", threshold)
default:
return "Alert triggered"
}
}
const defaultTokenDecimals = 18
func (s *EvaluatorService) buildTokenMessage(rule domain.AlertRule, obs domain.ObservedTx) string {
symbol := "tokens"
if obs.TokenSymbol != nil {
symbol = *obs.TokenSymbol
}
amount := "unknown"
if obs.TokenValue != nil {
decimals := defaultTokenDecimals
if obs.TokenDecimals != nil {
decimals = *obs.TokenDecimals
}
amount = wei.FormatTokenAmount(*obs.TokenValue, decimals)
}
switch rule.Type {
case domain.AlertIncomingTx:
return fmt.Sprintf("Incoming transfer: %s %s received", amount, symbol)
case domain.AlertOutgoingTx:
return fmt.Sprintf("Outgoing transfer: %s %s sent", amount, symbol)
case domain.AlertLargeTransfer:
threshold := float64(0)
if rule.Threshold != nil {
threshold = *rule.Threshold
}
return fmt.Sprintf("Large token transfer: %s %s (threshold: %g)", amount, symbol, threshold)
default:
return fmt.Sprintf("Token transfer: %s %s", amount, symbol)
}
}

View File

@@ -5,9 +5,9 @@ import (
"log"
"strings"
"github.com/kjannette/koin-ping/backend-go/internal/domain"
"github.com/kjannette/koin-ping/backend-go/internal/models"
"github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
"github.com/kjannette/koin-ping/backend/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/models"
"github.com/kjannette/koin-ping/backend/internal/protocols/ethereum"
)
const maxBlocksPerRun = 100
@@ -80,6 +80,15 @@ func (s *ObserverService) observeAddress(ctx context.Context, addr domain.Addres
}
}
tokenTxs, err := s.eth.GetTokenTransfers(ctx, startBlock, endBlock, addr.Address)
if err != nil {
log.Printf("Error fetching token transfers for %s: %v", addr.Address, err)
} else {
for _, tx := range tokenTxs {
observations = append(observations, createObservedTx(tx, addr))
}
}
if _, err := s.checkpoint.UpdateLastCheckedBlock(ctx, addr.ID, endBlock); err != nil {
return nil, err
}

View File

@@ -90,6 +90,14 @@ func LessThan(weiA, weiB string) (bool, error) {
return cmp < 0, nil
}
func LessThanOrEqual(weiA, weiB string) (bool, error) {
cmp, err := Compare(weiA, weiB)
if err != nil {
return false, err
}
return cmp <= 0, nil
}
// FormatAsEth formats a Wei string as "X.XXXX ETH".
func FormatAsEth(weiString string, decimals int) (string, error) {
eth, err := ToEth(weiString)
@@ -98,3 +106,54 @@ func FormatAsEth(weiString string, decimals int) (string, error) {
}
return fmt.Sprintf("%.*f ETH", decimals, eth), nil
}
// FormatTokenAmount formats a raw token amount using the token's decimal places.
// For example, 1000000 USDT (6 decimals) becomes "1".
func FormatTokenAmount(rawValue string, tokenDecimals int) string {
if rawValue == "" || rawValue == "0" {
return "0"
}
n, ok := new(big.Int).SetString(rawValue, 10)
if !ok {
return "0"
}
divisor := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(tokenDecimals)), nil) //nolint:mnd
whole := new(big.Int).Div(n, divisor)
remainder := new(big.Int).Mod(n, divisor)
if remainder.Sign() == 0 {
return addThousandsSeparators(whole.String())
}
fracStr := fmt.Sprintf("%0*s", tokenDecimals, remainder.String())
fracStr = strings.TrimRight(fracStr, "0")
const maxDisplayDecimals = 4
if len(fracStr) > maxDisplayDecimals {
fracStr = fracStr[:maxDisplayDecimals]
}
return addThousandsSeparators(whole.String()) + "." + fracStr
}
func addThousandsSeparators(s string) string {
if len(s) <= 3 { //nolint:mnd
return s
}
var result strings.Builder
offset := len(s) % 3 //nolint:mnd
if offset > 0 {
result.WriteString(s[:offset])
}
for i := offset; i < len(s); i += 3 { //nolint:mnd
if result.Len() > 0 {
result.WriteByte(',')
}
result.WriteString(s[i : i+3]) //nolint:mnd
}
return result.String()
}

BIN
backend/poller Executable file

Binary file not shown.

View File

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

View File

@@ -18,6 +18,7 @@
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"prettier": "^3.8.1",
"typescript": "^5.9.3",
"vite": "^7.3.0"
}
@@ -2412,6 +2413,22 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/protobufjs": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",

View File

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

4
frontend/prettierrc Normal file
View File

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

Binary file not shown.

Binary file not shown.

BIN
frontend/public/ping.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -1,60 +1,38 @@
import { Routes, Route, Link, Navigate } from "react-router-dom";
import { Routes, Route, Navigate } from "react-router-dom";
import { useAuth } from "./contexts/AuthContext";
import Login from "./pages/Login";
import Navbar from "./components/Navbar";
import Login from "./pages/login/Login";
import Signup from "./pages/Signup";
import Addresses from "./pages/Addresses";
import Alerts from "./pages/Alerts";
import AlertHistory from "./pages/AlertHistory";
import Subscribe from "./pages/subscribe/Subscribe";
import Addresses from "./pages/addresses/Addresses";
import Alerts from "./pages/alerts/Alerts";
import AlertHistory from "./pages/alertHistory/AlertHistory";
import Account from "./pages/user_account/Account";
export default function App() {
const { currentUser, logout } = useAuth();
const { currentUser } = useAuth();
// Show login/signup routes if not authenticated
if (!currentUser) {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/subscribe" element={<Subscribe />} />
<Route path="*" element={<Navigate to="/login" />} />
</Routes>
);
}
// Show main app if authenticated
return (
<div style={{ padding: "1rem" }}>
<nav style={{ marginBottom: "1rem", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div>
<Link to="/addresses">Addresses</Link>{" | "}
<Link to="/alerts">Alerts</Link>{" | "}
<Link to="/history">History</Link>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<span style={{ fontSize: "0.9rem", color: "#999" }}>
{currentUser.email}
</span>
<button
onClick={logout}
style={{
padding: "0.5rem 1rem",
fontSize: "0.9rem",
backgroundColor: "#333",
color: "white",
border: "1px solid #555",
borderRadius: "4px",
cursor: "pointer"
}}
>
Logout
</button>
</div>
</nav>
<Navbar />
<Routes>
<Route path="/" element={<Addresses />} />
<Route path="/addresses" element={<Addresses />} />
<Route path="/alerts" element={<Alerts />} />
<Route path="/history" element={<AlertHistory />} />
<Route path="/alertevents" element={<AlertHistory />} />
<Route path="/account" element={<Account />} />
<Route path="/subscribe" element={<Subscribe />} />
<Route path="*" element={<Navigate to="/addresses" />} />
</Routes>
</div>

View File

@@ -0,0 +1,25 @@
import { getAuthHeaders } from "./authHeaders";
import { API_BASE } from "./config";
export async function getAccount() {
const headers = await getAuthHeaders();
const res = await fetch(`${API_BASE}/user/account`, { headers });
if (!res.ok) {
const data = await res.json();
throw new Error(data.message || "Failed to load account");
}
return res.json();
}
export async function createPortalSession() {
const headers = await getAuthHeaders();
const res = await fetch(`${API_BASE}/stripe/create-portal-session`, {
method: "POST",
headers,
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message || "Failed to create portal session");
}
return res.json();
}

View File

@@ -1,7 +1,7 @@
// API client for address management
import { getAuthHeaders, getAuthHeadersSimple } from './authHeaders';
import { API_BASE } from './config';
import { getAuthHeaders, getAuthHeadersSimple } from "./authHeaders";
import { API_BASE } from "./config";
/**
* Create a new blockchain address to monitor
@@ -14,14 +14,14 @@ export async function createAddress(data) {
try {
const headers = await getAuthHeaders();
const response = await fetch(`${API_BASE}/addresses`, {
method: 'POST',
method: "POST",
headers: headers,
body: JSON.stringify(data)
body: JSON.stringify(data),
});
if (!response.ok) {
// Try to parse error response, but handle if it's not JSON
let errorMessage = 'Failed to create address';
let errorMessage = "Failed to create address";
try {
const error = await response.json();
errorMessage = error.message || errorMessage;
@@ -35,8 +35,10 @@ export async function createAddress(data) {
return response.json();
} catch (error) {
// Handle network errors
if (error.message.includes('fetch')) {
throw new Error('Cannot connect to server. Is the backend running?');
if (error.message.includes("fetch")) {
throw new Error(
"Cannot connect to server. Is the backend running?",
);
}
throw error;
}
@@ -50,12 +52,12 @@ export async function getAddresses() {
try {
const headers = await getAuthHeadersSimple();
const response = await fetch(`${API_BASE}/addresses`, {
headers: headers
headers: headers,
});
if (!response.ok) {
// 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 {
const error = await response.json();
errorMessage = error.message || errorMessage;
@@ -69,8 +71,45 @@ export async function getAddresses() {
return response.json();
} catch (error) {
// Handle network errors or JSON parse errors
if (error.message.includes('fetch')) {
throw new Error('Cannot connect to server. Is the backend running?');
if (error.message.includes("fetch")) {
throw new Error(
"Cannot connect to server. Is the backend running?",
);
}
throw error;
}
}
/**
* Update an address (e.g. change its label)
* @param {number} addressId - Address ID to update
* @param {Object} data - Fields to update (e.g. { label: "New Label" })
* @returns {Promise<Object>} Updated address
*/
export async function updateAddress(addressId, data) {
try {
const headers = await getAuthHeaders();
const response = await fetch(`${API_BASE}/addresses/${addressId}`, {
method: "PATCH",
headers: headers,
body: JSON.stringify(data),
});
if (!response.ok) {
let errorMessage = "Failed to update address";
try {
const error = await response.json();
errorMessage = error.message || errorMessage;
} catch {
errorMessage = `Server error: ${response.status} ${response.statusText}`;
}
throw new Error(errorMessage);
}
return response.json();
} catch (error) {
if (error.message.includes("fetch")) {
throw new Error("Cannot connect to server. Is the backend running?");
}
throw error;
}
@@ -85,13 +124,13 @@ export async function deleteAddress(addressId) {
try {
const headers = await getAuthHeadersSimple();
const response = await fetch(`${API_BASE}/addresses/${addressId}`, {
method: 'DELETE',
headers: headers
method: "DELETE",
headers: headers,
});
if (!response.ok && response.status !== 204) {
// Try to parse error response, but handle if it's not JSON
let errorMessage = 'Failed to delete address';
let errorMessage = "Failed to delete address";
try {
const error = await response.json();
errorMessage = error.message || errorMessage;
@@ -103,10 +142,11 @@ export async function deleteAddress(addressId) {
}
} catch (error) {
// Handle network errors
if (error.message.includes('fetch')) {
throw new Error('Cannot connect to server. Is the backend running?');
if (error.message.includes("fetch")) {
throw new Error(
"Cannot connect to server. Is the backend running?",
);
}
throw error;
}
}

View File

@@ -1,7 +1,7 @@
// API client for alert event history
import { getAuthHeadersSimple } from './authHeaders';
import { API_BASE } from './config';
import { getAuthHeadersSimple } from "./authHeaders";
import { API_BASE } from "./config";
/**
* Get all alert events (history)
@@ -11,11 +11,11 @@ export async function getAlertEvents() {
try {
const headers = await getAuthHeadersSimple();
const response = await fetch(`${API_BASE}/alert-events`, {
headers: headers
headers: headers,
});
if (!response.ok) {
let errorMessage = 'Failed to fetch alert events';
let errorMessage = "Failed to fetch alert events";
try {
const error = await response.json();
errorMessage = error.message || errorMessage;
@@ -27,8 +27,10 @@ export async function getAlertEvents() {
return response.json();
} catch (error) {
if (error.message.includes('fetch')) {
throw new Error('Cannot connect to server. Is the backend running?');
if (error.message.includes("fetch")) {
throw new Error(
"Cannot connect to server. Is the backend running?",
);
}
throw error;
}

View File

@@ -1,16 +1,16 @@
// API client for alert rule management
import { getAuthHeaders, getAuthHeadersSimple } from './authHeaders';
import { API_BASE } from './config';
import { getAuthHeaders, getAuthHeadersSimple } from "./authHeaders";
import { API_BASE } from "./config";
/**
* Alert types supported by the system
*/
export const ALERT_TYPES = {
INCOMING_TX: 'incoming_tx',
OUTGOING_TX: 'outgoing_tx',
LARGE_TRANSFER: 'large_transfer',
BALANCE_BELOW: 'balance_below'
INCOMING_TX: "incoming_tx",
OUTGOING_TX: "outgoing_tx",
LARGE_TRANSFER: "large_transfer",
BALANCE_BELOW: "balance_below",
};
/**
@@ -24,14 +24,17 @@ export const ALERT_TYPES = {
export async function createAlert(addressId, data) {
try {
const headers = await getAuthHeaders();
const response = await fetch(`${API_BASE}/addresses/${addressId}/alerts`, {
method: 'POST',
const response = await fetch(
`${API_BASE}/addresses/${addressId}/alerts`,
{
method: "POST",
headers: headers,
body: JSON.stringify(data)
});
body: JSON.stringify(data),
},
);
if (!response.ok) {
let errorMessage = 'Failed to create alert rule';
let errorMessage = "Failed to create alert rule";
try {
const error = await response.json();
errorMessage = error.message || errorMessage;
@@ -43,8 +46,10 @@ export async function createAlert(addressId, data) {
return response.json();
} catch (error) {
if (error.message.includes('fetch')) {
throw new Error('Cannot connect to server. Is the backend running?');
if (error.message.includes("fetch")) {
throw new Error(
"Cannot connect to server. Is the backend running?",
);
}
throw error;
}
@@ -58,12 +63,15 @@ export async function createAlert(addressId, data) {
export async function getAlerts(addressId) {
try {
const headers = await getAuthHeadersSimple();
const response = await fetch(`${API_BASE}/addresses/${addressId}/alerts`, {
headers: headers
});
const response = await fetch(
`${API_BASE}/addresses/${addressId}/alerts`,
{
headers: headers,
},
);
if (!response.ok) {
let errorMessage = 'Failed to fetch alert rules';
let errorMessage = "Failed to fetch alert rules";
try {
const error = await response.json();
errorMessage = error.message || errorMessage;
@@ -75,8 +83,10 @@ export async function getAlerts(addressId) {
return response.json();
} catch (error) {
if (error.message.includes('fetch')) {
throw new Error('Cannot connect to server. Is the backend running?');
if (error.message.includes("fetch")) {
throw new Error(
"Cannot connect to server. Is the backend running?",
);
}
throw error;
}
@@ -92,13 +102,13 @@ export async function updateAlertStatus(alertId, enabled) {
try {
const headers = await getAuthHeaders();
const response = await fetch(`${API_BASE}/alerts/${alertId}`, {
method: 'PATCH',
method: "PATCH",
headers: headers,
body: JSON.stringify({ enabled })
body: JSON.stringify({ enabled }),
});
if (!response.ok) {
let errorMessage = 'Failed to update alert rule';
let errorMessage = "Failed to update alert rule";
try {
const error = await response.json();
errorMessage = error.message || errorMessage;
@@ -110,8 +120,52 @@ export async function updateAlertStatus(alertId, enabled) {
return response.json();
} catch (error) {
if (error.message.includes('fetch')) {
throw new Error('Cannot connect to server. Is the backend running?');
if (error.message.includes("fetch")) {
throw new Error(
"Cannot connect to server. Is the backend running?",
);
}
throw error;
}
}
/**
* Update the min/max thresholds on an alert rule
* @param {number} alertId - Alert rule ID
* @param {number|null} minimum - Minimum amount (null to clear)
* @param {number|null} maximum - Maximum amount (null to clear)
* @returns {Promise<Object>} Updated alert rule
*/
export async function updateAlertThresholds(alertId, minimum, maximum) {
try {
const headers = await getAuthHeaders();
const response = await fetch(`${API_BASE}/alerts/${alertId}`, {
method: "PATCH",
headers: headers,
body: JSON.stringify({
update_min_max: true,
minimum: minimum ?? null,
maximum: maximum ?? null,
}),
});
if (!response.ok) {
let errorMessage = "Failed to update alert thresholds";
try {
const error = await response.json();
errorMessage = error.message || errorMessage;
} catch {
errorMessage = `Server error: ${response.status} ${response.statusText}`;
}
throw new Error(errorMessage);
}
return response.json();
} catch (error) {
if (error.message.includes("fetch")) {
throw new Error(
"Cannot connect to server. Is the backend running?",
);
}
throw error;
}
@@ -126,12 +180,12 @@ export async function deleteAlert(alertId) {
try {
const headers = await getAuthHeadersSimple();
const response = await fetch(`${API_BASE}/alerts/${alertId}`, {
method: 'DELETE',
headers: headers
method: "DELETE",
headers: headers,
});
if (!response.ok && response.status !== 204) {
let errorMessage = 'Failed to delete alert rule';
let errorMessage = "Failed to delete alert rule";
try {
const error = await response.json();
errorMessage = error.message || errorMessage;
@@ -141,8 +195,10 @@ export async function deleteAlert(alertId) {
throw new Error(errorMessage);
}
} catch (error) {
if (error.message.includes('fetch')) {
throw new Error('Cannot connect to server. Is the backend running?');
if (error.message.includes("fetch")) {
throw new Error(
"Cannot connect to server. Is the backend running?",
);
}
throw error;
}

View File

@@ -5,7 +5,7 @@
* Includes Firebase ID token in Authorization header
*/
import { auth } from '../firebase/config';
import { auth } from "../firebase/config";
/**
* Get headers with authentication token
@@ -15,15 +15,15 @@ export async function getAuthHeaders() {
const currentUser = auth.currentUser;
if (!currentUser) {
throw new Error('No authenticated user');
throw new Error("No authenticated user");
}
// Get Firebase ID token
const token = await currentUser.getIdToken();
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
};
}
@@ -35,13 +35,12 @@ export async function getAuthHeadersSimple() {
const currentUser = auth.currentUser;
if (!currentUser) {
throw new Error('No authenticated user');
throw new Error("No authenticated user");
}
const token = await currentUser.getIdToken();
return {
'Authorization': `Bearer ${token}`
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,7 +1,5 @@
// API client for notification configuration
import { getAuthHeaders } from './authHeaders';
import { API_BASE } from './config';
import { getAuthHeaders } from "./authHeaders";
import { API_BASE } from "./config";
/**
* Get notification configuration for current user
@@ -11,11 +9,11 @@ export async function getNotificationConfig() {
try {
const headers = await getAuthHeaders();
const response = await fetch(`${API_BASE}/notification-config`, {
headers: headers
headers: headers,
});
if (!response.ok) {
let errorMessage = 'Failed to fetch notification config';
let errorMessage = "Failed to fetch notification config";
try {
const error = await response.json();
errorMessage = error.message || errorMessage;
@@ -27,8 +25,10 @@ export async function getNotificationConfig() {
return response.json();
} catch (error) {
if (error.message.includes('fetch')) {
throw new Error('Cannot connect to server. Is the backend running?');
if (error.message.includes("fetch")) {
throw new Error(
"Cannot connect to server. Is the backend running?",
);
}
throw error;
}
@@ -36,24 +36,26 @@ export async function getNotificationConfig() {
/**
* Update notification configuration
* @param {Object} config - Configuration to update
* @param {string} [config.discord_webhook_url] - Discord webhook URL
* @param {string} [config.telegram_chat_id] - Telegram chat ID
* @param {string} [config.email] - Email address
* @param {boolean} [config.notification_enabled] - Enable/disable notifications
* @param {Object} config
* @param {string} [config.discord_webhook_url]
* @param {string} [config.telegram_chat_id]
* @param {string} [config.telegram_bot_token]
* @param {string} [config.email]
* @param {string} [config.slack_webhook_url]
* @param {boolean} [config.notification_enabled]
* @returns {Promise<Object>} Updated config
*/
export async function updateNotificationConfig(config) {
try {
const headers = await getAuthHeaders();
const response = await fetch(`${API_BASE}/notification-config`, {
method: 'PUT',
method: "PUT",
headers: headers,
body: JSON.stringify(config)
body: JSON.stringify(config),
});
if (!response.ok) {
let errorMessage = 'Failed to update notification config';
let errorMessage = "Failed to update notification config";
try {
const error = await response.json();
errorMessage = error.message || errorMessage;
@@ -65,35 +67,117 @@ export async function updateNotificationConfig(config) {
return response.json();
} catch (error) {
if (error.message.includes('fetch')) {
throw new Error('Cannot connect to server. Is the backend running?');
if (error.message.includes("fetch")) {
throw new Error(
"Cannot connect to server. Is the backend running?",
);
}
throw error;
}
}
/**
* Test a Discord webhook URL
* Sends a test message to verify the webhook works
* @param {string} webhookUrl - Discord webhook URL to test
* @returns {Promise<boolean>} True if test successful
* Set up email notifications via Resend.
* Reads the user's saved email from their config and sends a confirmation.
* @returns {Promise<Object>} Setup result
*/
export async function testDiscordWebhook(webhookUrl) {
export async function setupEmail() {
try {
const payload = {
content: 'Koin Ping test notification - Your Discord webhook is configured correctly!',
};
const response = await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
const headers = await getAuthHeaders();
const response = await fetch(`${API_BASE}/email/setup`, {
method: "POST",
headers: headers,
});
return response.ok;
if (!response.ok) {
let errorMessage = "Failed to set up email";
try {
const error = await response.json();
errorMessage = error.message || errorMessage;
} catch {
errorMessage = `Server error: ${response.status} ${response.statusText}`;
}
throw new Error(errorMessage);
}
return response.json();
} catch (error) {
console.error('Discord webhook test failed:', error);
return false;
if (error.message.includes("fetch")) {
throw new Error(
"Cannot connect to server. Is the backend running?",
);
}
throw error;
}
}
/**
* Send an alert digest email to the user's configured email address.
* @returns {Promise<Object>} Digest send result
*/
export async function sendEmailDigest() {
try {
const headers = await getAuthHeaders();
const response = await fetch(`${API_BASE}/email/digest`, {
method: "POST",
headers: headers,
});
if (!response.ok) {
let errorMessage = "Failed to send digest";
try {
const error = await response.json();
errorMessage = error.message || errorMessage;
} catch {
errorMessage = `Server error: ${response.status} ${response.statusText}`;
}
throw new Error(errorMessage);
}
return response.json();
} catch (error) {
if (error.message.includes("fetch")) {
throw new Error(
"Cannot connect to server. Is the backend running?",
);
}
throw error;
}
}
/**
* Test all configured notification channels via the backend
* @returns {Promise<Object>} Results per channel
*/
export async function testNotificationChannels() {
try {
const headers = await getAuthHeaders();
const response = await fetch(
`${API_BASE}/notification-config/test`,
{
method: "POST",
headers: headers,
},
);
if (!response.ok) {
let errorMessage = "Failed to test notification channels";
try {
const error = await response.json();
errorMessage = error.message || errorMessage;
} catch {
errorMessage = `Server error: ${response.status} ${response.statusText}`;
}
throw new Error(errorMessage);
}
return response.json();
} catch (error) {
if (error.message.includes("fetch")) {
throw new Error(
"Cannot connect to server. Is the backend running?",
);
}
throw error;
}
}

View File

@@ -1,6 +1,6 @@
// API client for system status
import { API_BASE } from './config';
import { API_BASE } from "./config";
/**
* Get system status including latest processed block and health
@@ -11,7 +11,7 @@ export async function getSystemStatus() {
if (!response.ok) {
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();

View File

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

@@ -37,7 +37,7 @@ export default function AddressForm({ onSubmit }) {
placeholder="Treasury, Cold Wallet, etc."
/>
<Button type="submit" disabled={!canSubmit}>
<Button type="submit" disabled={!canSubmit} className="mt-md">
Add Address
</Button>
</form>

View File

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

View File

@@ -1,6 +1,7 @@
import { useState } from "react";
import Input from "./Input";
import Button from "./Button";
import "./AlertForm.css";
const ALERT_TYPES = [
{ value: "incoming_tx", label: "Incoming transaction" },
@@ -36,17 +37,17 @@ export default function AlertForm({ onSubmit }) {
return (
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: "1rem" }}>
<div className="alert-form__options">
{ALERT_TYPES.map((opt) => (
<label key={opt.value} style={{ display: "block" }}>
<label key={opt.value} className="alert-form__radio">
<input
type="radio"
name="alertType"
value={opt.value}
checked={type === opt.value}
onChange={() => setType(opt.value)}
/>
{" "}{opt.label}
/>{" "}
{opt.label}
</label>
))}
</div>

View File

@@ -0,0 +1,14 @@
.btn {
padding: 0.5rem 1rem;
font-size: 1rem;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
color: var(--color-text);
transition: opacity 0.15s ease;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}

View File

@@ -1,23 +1,21 @@
import "./Button.css";
export default function Button({
children,
onClick,
disabled = false,
type = "button",
}) {
variant = "primary",
className = "",
}) {
return (
<button
type={type}
onClick={onClick}
disabled={disabled}
style={{
padding: "0.5rem 1rem",
fontSize: "1rem",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.6 : 1,
}}
className={`btn btn--${variant} ${className}`}
>
{children}
</button>
);
}
}

View File

@@ -0,0 +1,21 @@
.input__label {
display: block;
margin: 0.8rem 0rem 0.4rem 0rem;
font-size: 0.9rem;
}
.input__control {
width: 100%;
padding: 0.5rem;
font-size: 1rem;
background-color: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
}
.input__control:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.25);
}

View File

@@ -1,3 +1,5 @@
import "./Input.css";
export default function Input({
label,
type = "text",
@@ -7,12 +9,12 @@ export default function Input({
step,
min,
disabled = false,
}) {
required = false,
className = "",
}) {
return (
<label style={{ display: "block", marginBottom: "1rem" }}>
<div style={{ marginBottom: "0.25rem", fontSize: "0.9rem" }}>
{label}
</div>
<label className={`form-field ${className}`}>
<div className="input__label">{label}</div>
<input
type={type}
value={value}
@@ -20,14 +22,10 @@ export default function Input({
step={step}
min={min}
disabled={disabled}
required={required}
onChange={(e) => onChange(e.target.value)}
style={{
width: "100%",
padding: "0.5rem",
fontSize: "1rem",
}}
className="input__control"
/>
</label>
);
}
}

View File

@@ -0,0 +1,167 @@
.navbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
margin-bottom: 1.5rem;
background-color: var(--color-bg);
border-bottom: 1px solid var(--color-border-light);
}
/* ── Left Group: Brand + Links ───────────────────────────── */
.navbar__left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.navbar__brand-group {
display: flex;
align-items: center;
}
.navbar__brand {
font-weight: 700;
font-size: 1.4rem;
color: var(--color-brand);
margin-right: 2rem;
letter-spacing: 0.5px;
}
.navbar__links {
display: flex;
gap: 0.25rem;
}
.navbar__link {
padding: 0.5rem 1.25rem;
border-radius: var(--radius-lg);
text-decoration: none;
font-size: 1.425rem;
font-weight: 200;
color: var(--color-text-subtle);
background-color: transparent;
transition: all 0.15s ease;
}
.navbar__link--active {
font-weight: 600;
color: var(--color-text);
background-color: var(--color-bg-card);
}
/* ── Right Group: Email + Logout + Hamburger ─────────────── */
.navbar__right {
display: flex;
align-items: center;
gap: 1rem;
}
.navbar__user {
font-size: 1.275rem;
color: #777;
}
.navbar__user-link {
color: var(--color-primary);
text-decoration: none;
transition: color 0.15s ease;
}
.navbar__user-link:hover {
color: var(--color-primary-hover);
}
.navbar__user-link--mobile {
display: none;
}
.navbar__logout {
padding: 0.4rem 1rem;
font-size: 1.275rem;
font-weight: 400;
background-color: transparent;
color: var(--color-text-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
cursor: pointer;
transition: all 0.15s ease;
}
/* ── Hamburger Icon (hidden on desktop) ──────────────────── */
.navbar__hamburger {
display: none;
flex-direction: column;
justify-content: center;
gap: 5px;
background: none;
border: none;
cursor: pointer;
padding: 4px;
}
.navbar__hamburger-bar {
display: block;
width: 28px;
height: 3px;
background-color: var(--color-brand);
border-radius: 2px;
}
/* ── Responsive: Tablet & below (≤ 768px) ────────────────── */
@media (max-width: 768px) {
.navbar {
padding: 0.75rem 1rem;
}
.navbar__brand-group {
flex-direction: column;
align-items: flex-start;
}
.navbar__brand {
margin-right: 0;
font-size: 1.25rem;
}
.navbar__links {
display: none;
}
.navbar__user-link--mobile {
display: block;
font-size: 0.95rem;
}
.navbar__user-link--desktop {
display: none;
}
.navbar__logout {
display: none;
}
.navbar__hamburger {
display: flex;
}
}
/* ── Responsive: Mobile (≤ 480px) ────────────────────────── */
@media (max-width: 480px) {
.navbar {
padding: 0.75rem;
}
.navbar__brand {
font-size: 1.15rem;
}
.navbar__user-link--mobile {
font-size: 0.85rem;
}
}

View File

@@ -0,0 +1,71 @@
import { useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import NavPanel from "./navPanel/NavPanel";
import "./Navbar.css";
const navLinks = [
{ to: "/addresses", label: "Addresses" },
{ to: "/alerts", label: "Configure Alerts" },
{ to: "/alertevents", label: "Alert Event History" },
];
export default function Navbar() {
const { currentUser, logout } = useAuth();
const location = useLocation();
const [isNavPanelOpen, setIsNavPanelOpen] = useState(false);
if (!currentUser) return null;
return (
<>
<nav className="navbar">
<div className="navbar__left">
<div className="navbar__brand-group">
<span className="navbar__brand">Koin Ping</span>
<Link to="/account" className="navbar__user-link navbar__user-link--mobile">
{currentUser.email}
</Link>
</div>
<div className="navbar__links">
{navLinks.map(({ to, label }) => {
const isActive = location.pathname === to;
return (
<Link
key={to}
to={to}
className={`navbar__link ${isActive ? "navbar__link--active" : ""}`}
>
{label}
</Link>
);
})}
</div>
</div>
<div className="navbar__right">
<Link to="/account" className="navbar__user navbar__user-link navbar__user-link--desktop">
{currentUser.email}
</Link>
<button onClick={logout} className="navbar__logout">
Logout
</button>
<button
className="navbar__hamburger"
onClick={() => setIsNavPanelOpen(true)}
aria-label="Open navigation menu"
>
<span className="navbar__hamburger-bar" />
<span className="navbar__hamburger-bar" />
<span className="navbar__hamburger-bar" />
</button>
</div>
</nav>
<NavPanel
isOpen={isNavPanelOpen}
onClose={() => setIsNavPanelOpen(false)}
/>
</>
);
}

View File

@@ -0,0 +1,78 @@
/* ── Overlay Backdrop ─────────────────────────────────────── */
.nav-panel__overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.45);
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
z-index: 900;
}
.nav-panel__overlay--visible {
opacity: 1;
pointer-events: auto;
}
/* ── Slide-out Panel ─────────────────────────────────────── */
.nav-panel {
position: fixed;
top: 0;
left: 0;
width: 260px;
height: 100%;
background-color: var(--color-bg-elevated);
border-right: 1px solid var(--color-border);
transform: translateX(-100%);
transition: transform 0.3s ease;
z-index: 950;
display: flex;
flex-direction: column;
padding: 1.5rem 0;
}
.nav-panel--open {
transform: translateX(0);
}
/* ── Nav Links ───────────────────────────────────────────── */
.nav-panel__nav {
display: flex;
flex-direction: column;
flex: 1;
}
.nav-panel__link {
padding: 0.9rem 1.5rem;
text-decoration: none;
font-size: 1.3rem;
font-weight: 200;
color: var(--color-text-subtle);
border-left: 3px solid transparent;
transition: background-color 0.15s ease, color 0.15s ease;
}
.nav-panel__link--active {
font-weight: 600;
color: var(--color-text);
background-color: var(--color-bg-card);
border-left-color: var(--color-brand);
}
/* ── Logout Button ───────────────────────────────────────── */
.nav-panel__logout {
margin: 1rem 1.5rem;
padding: 0.5rem 1rem;
font-size: 1.15rem;
font-weight: 400;
background-color: transparent;
color: var(--color-text-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
cursor: pointer;
transition: all 0.15s ease;
}

View File

@@ -0,0 +1,52 @@
import { Link, useLocation } from "react-router-dom";
import { useAuth } from "../../contexts/AuthContext";
import "./NavPanel.css";
const navLinks = [
{ to: "/addresses", label: "Addresses" },
{ to: "/alerts", label: "Configure Alerts" },
{ to: "/alertevents", label: "Alert Event History" },
];
export default function NavPanel({ isOpen, onClose }) {
const { logout } = useAuth();
const location = useLocation();
const handleNavClick = () => {
onClose();
};
const handleLogout = () => {
onClose();
logout();
};
return (
<>
<div
className={`nav-panel__overlay${isOpen ? " nav-panel__overlay--visible" : ""}`}
onClick={onClose}
/>
<aside className={`nav-panel${isOpen ? " nav-panel--open" : ""}`}>
<nav className="nav-panel__nav">
{navLinks.map(({ to, label }) => {
const isActive = location.pathname === to;
return (
<Link
key={to}
to={to}
className={`nav-panel__link${isActive ? " nav-panel__link--active" : ""}`}
onClick={handleNavClick}
>
{label}
</Link>
);
})}
</nav>
<button onClick={handleLogout} className="nav-panel__logout">
Logout
</button>
</aside>
</>
);
}

View File

@@ -4,14 +4,14 @@
* Provides authentication state and methods throughout the app
*/
import { createContext, useContext, useEffect, useState } from 'react';
import { createContext, useContext, useEffect, useState } from "react";
import {
createUserWithEmailAndPassword,
signInWithEmailAndPassword,
signOut,
onAuthStateChanged
} from 'firebase/auth';
import { auth } from '../firebase/config';
onAuthStateChanged,
} from "firebase/auth";
import { auth } from "../firebase/config";
const AuthContext = createContext();
@@ -22,7 +22,7 @@ const AuthContext = createContext();
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
throw new Error("useAuth must be used within AuthProvider");
}
return context;
}
@@ -41,7 +41,11 @@ export function AuthProvider({ children }) {
async function signup(email, password) {
try {
setError(null);
const result = await createUserWithEmailAndPassword(auth, email, password);
const result = await createUserWithEmailAndPassword(
auth,
email,
password,
);
return result.user;
} catch (err) {
setError(err.message);
@@ -55,7 +59,11 @@ export function AuthProvider({ children }) {
async function login(email, password) {
try {
setError(null);
const result = await signInWithEmailAndPassword(auth, email, password);
const result = await signInWithEmailAndPassword(
auth,
email,
password,
);
return result.user;
} catch (err) {
setError(err.message);
@@ -95,7 +103,7 @@ export function AuthProvider({ children }) {
login,
logout,
error,
loading
loading,
};
return (
@@ -104,4 +112,3 @@ export function AuthProvider({ children }) {
</AuthContext.Provider>
);
}

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