Compare commits

..

37 Commits

Author SHA1 Message Date
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
89 changed files with 8047 additions and 5895 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

View File

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

45
.gitignore vendored 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-go/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-go/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

21
LICENSE Normal file
View File

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

208
README.md
View File

@@ -1,95 +1,155 @@
# Koin Ping # Koin Ping
A lightweight on-chain monitoring and alerting system designed to give users situational awareness over blockchain addresses they care about. A lightweight on-chain monitoring and alerting system designed to give users situational awareness over blockchain addresses they care about.
## Overview # Overview
Koin Ping 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. 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.
## Project Structure
```
koin_ping/
├── backend/ # Node.js + Express + PostgreSQL backend
│ ├── api/ # API endpoints and server configuration
│ ├── poller/ # Blockchain polling logic
│ ├── alerts/ # Alert evaluation and management
│ ├── notifications/ # Notification delivery system
│ ├── domain/ # Domain models and business logic
│ ├── infra/ # Infrastructure (database, external services)
│ └── shared/ # Shared utilities and helpers
└── frontend/ # React + Vite frontend
├── public/ # Static assets
└── src/
├── api/ # Frontend API calls to backend
├── components/ # Reusable UI components
├── pages/ # Top-level pages (views)
└── utils/ # Utility functions
```
## Tech Stack
### Backend
- **Runtime:** Node.js
- **Framework:** Express
- **Database:** PostgreSQL
- **Key Dependencies:**
- `pg` - PostgreSQL client
- `dotenv` - Environment variable management
- `cors` - CORS middleware
- `nodemon` - Development auto-reload
### Frontend
- **Framework:** React 19
- **Build Tool:** Vite
- **Language:** JavaScript/TypeScript (mixed)
- **Type Checking:** TypeScript
## Getting Started ## Getting Started
### Prerequisites ### Prerequisites
- Node.js (v18 or higher recommended)
- PostgreSQL database
- npm or yarn
### 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: ### Setup
```bash
cd backend ```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-go/.env.example backend-go/.env
# edit backend-go/.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 && 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.
## Rationale
Crypto users who hold or actively monitor addresses need a lightweight, reliable
way to know when on-chain activity occurs without polling block explorers
manually. Koin Ping fills that gap: it watches a set of Ethereum addresses,
evaluates configurable alert rules (incoming transactions, outgoing
transactions, large transfers, balance thresholds), and notifies the user
through Discord webhooks.
## Design
The system is split into two independently deployable processes and one
frontend:
```
koin_ping_0.2.0/
├── backend-go/ # 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): 4. In the JSON response, find the `"chat"` object — the `"id"` field is your **Chat ID** (a numeric value).
```bash
npm install
```
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: #### 3. Save in Koin Ping
```bash
npm run dev
```
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: *Guides for Discord, Slack, and Email coming soon.*
```bash
cd frontend
```
2. Install dependencies (already done): ## License
```bash
npm install
```
3. Start the development server: MIT. See [LICENSE](LICENSE).
```bash
npm run dev
```
The frontend will run on `http://localhost:3000` ## Author
Steven Jannette

188
REPO_POLICIES.md Normal file
View File

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

View File

@@ -14,3 +14,7 @@ POLL_INTERVAL_MS=60000
# Firebase # Firebase
FIREBASE_PROJECT_ID=koin-ping FIREBASE_PROJECT_ID=koin-ping
# Email notifications (Resend — https://resend.com)
# RESEND_API_KEY=re_xxxxxxxxxxxx
# EMAIL_FROM=Koin Ping <alerts@yourdomain.com>

32
backend-go/.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 ## 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)** **Option 1: Use Firebase Project ID (easiest for development)**
- Just set `FIREBASE_PROJECT_ID` in .env - Just set `FIREBASE_PROJECT_ID` in .env
- Firebase Admin will use Application Default Credentials - Firebase Admin will use Application Default Credentials
**Option 2: Use Service Account Key (production)** **Option 2: Use Service Account Key (production)**
1. Go to Firebase Console > Project Settings > Service Accounts 1. Go to Firebase Console > Project Settings > Service Accounts
2. Click "Generate new private key" 2. Click "Generate new private key"
3. Download the JSON file 3. Download the JSON file
4. Either: 4. Either:
- Set `GOOGLE_APPLICATION_CREDENTIALS=/path/to/serviceAccountKey.json` in .env - Set `GOOGLE_APPLICATION_CREDENTIALS=/path/to/serviceAccountKey.json` in
.env
- Or keep it in backend/ and add to .gitignore - Or keep it in backend/ and add to .gitignore

View File

@@ -1,4 +1,15 @@
Run Backend: Start DB:
cd /Users/kjannette/workspace/koin_ping/backend-go brew services start postgresql@15
go run ./cmd/api
From the backend-go directory, you have a few options:
Option 1: Single command (both API + poller)
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gomake dev-all
Option 2: Two separate terminals
Terminal 1 (API server):
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gogo run ./cmd/api
Terminal 2 (Poller):
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gogo run ./cmd/poller

BIN
backend-go/api Executable file

Binary file not shown.

View File

@@ -1,10 +1,11 @@
// Package main is the entry point for the API server.
package main package main
import ( import (
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"os" "time"
"github.com/joho/godotenv" "github.com/joho/godotenv"
"github.com/kjannette/koin-ping/backend-go/internal/config" "github.com/kjannette/koin-ping/backend-go/internal/config"
@@ -13,11 +14,19 @@ import (
"github.com/kjannette/koin-ping/backend-go/internal/handlers" "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/middleware"
"github.com/kjannette/koin-ping/backend-go/internal/models" "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"
) )
const (
// max duration to read a request.
serverReadTimeoutSeconds = 5
// maxiduration to write a response.
serverWriteTimeoutSeconds = 10
)
//nolint:funlen
func main() { func main() {
_ = godotenv.Load() _ = godotenv.Load() // .env is optional; env vars can also be set externally
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
@@ -28,53 +37,76 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("Failed to connect to database: %v", err) log.Fatalf("Failed to connect to database: %v", err)
} }
defer database.Close()
if err := firebase.Init(cfg.FirebaseProjectID); err != nil { if err := firebase.Init(cfg.FirebaseProjectID); err != nil {
log.Fatalf("Failed to initialize Firebase: %v", err) log.Fatalf("Failed to initialize Firebase: %v", err)
} }
var eth ethereum.EthereumObserver defer database.Close()
if cfg.EthRPCURL != "" {
eth, err = ethereum.NewJsonRpcEthereum(cfg.EthRPCURL)
if err != nil {
log.Printf("Warning: Failed to create Ethereum observer: %v", err)
}
}
addressModel := models.NewAddressModel(pool) addressModel := models.NewAddressModel(pool)
alertRuleModel := models.NewAlertRuleModel(pool) alertRuleModel := models.NewAlertRuleModel(pool)
alertEventModel := models.NewAlertEventModel(pool) alertEventModel := models.NewAlertEventModel(pool)
notifConfigModel := models.NewNotificationConfigModel(pool)
checkpointModel := models.NewCheckpointModel(pool) checkpointModel := models.NewCheckpointModel(pool)
notifConfigModel := models.NewNotificationConfigModel(pool)
emailDigestSvc := services.NewEmailDigestService(
cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel,
)
addressHandler := handlers.NewAddressHandler(addressModel) addressHandler := handlers.NewAddressHandler(addressModel)
alertRuleHandler := handlers.NewAlertRuleHandler(alertRuleModel, addressModel) alertRuleHandler := handlers.NewAlertRuleHandler(alertRuleModel, addressModel)
alertEventHandler := handlers.NewAlertEventHandler(alertEventModel) alertEventHandler := handlers.NewAlertEventHandler(alertEventModel)
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel) notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, cfg)
statusHandler := handlers.NewStatusHandler(eth, checkpointModel) emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel)
statusHandler := handlers.NewStatusHandler(checkpointModel)
mux := http.NewServeMux() mux := http.NewServeMux()
b := cfg.APIBasePath b := cfg.APIBasePath // e.g. "/v1"
// Public routes
mux.HandleFunc("GET "+b+"/health", handlers.HealthCheck) mux.HandleFunc("GET "+b+"/health", handlers.HealthCheck)
mux.HandleFunc("GET "+b+"/status", statusHandler.SystemStatus) mux.HandleFunc("GET "+b+"/status", statusHandler.GetStatus)
mux.Handle("POST "+b+"/addresses", middleware.Authenticate(http.HandlerFunc(addressHandler.Create))) // Authenticated routes — addresses
mux.Handle("GET "+b+"/addresses", middleware.Authenticate(http.HandlerFunc(addressHandler.List))) mux.Handle("POST "+b+"/addresses",
mux.Handle("DELETE "+b+"/addresses/{addressId}", middleware.Authenticate(http.HandlerFunc(addressHandler.Remove))) middleware.Authenticate(http.HandlerFunc(addressHandler.Create)))
mux.Handle("GET "+b+"/addresses",
middleware.Authenticate(http.HandlerFunc(addressHandler.List)))
mux.Handle("DELETE "+b+"/addresses/{addressId}",
middleware.Authenticate(http.HandlerFunc(addressHandler.Remove)))
mux.Handle("PATCH "+b+"/addresses/{addressId}",
middleware.Authenticate(http.HandlerFunc(addressHandler.UpdateLabel)))
mux.Handle("POST "+b+"/addresses/{addressId}/alerts", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Create))) // Authenticated routes for alert rules
mux.Handle("GET "+b+"/addresses/{addressId}/alerts", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.ListByAddress))) mux.Handle("POST "+b+"/addresses/{addressId}/alerts",
mux.Handle("PATCH "+b+"/alerts/{alertId}", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.UpdateStatus))) middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Create)))
mux.Handle("DELETE "+b+"/alerts/{alertId}", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Remove))) mux.Handle("GET "+b+"/addresses/{addressId}/alerts",
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.ListByAddress)))
mux.Handle("PATCH "+b+"/alerts/{alertId}",
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.UpdateStatus)))
mux.Handle("DELETE "+b+"/alerts/{alertId}",
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Remove)))
mux.Handle("GET "+b+"/alert-events", middleware.Authenticate(http.HandlerFunc(alertEventHandler.List))) // Authenticated routes — alert events
mux.Handle("GET "+b+"/alert-events",
middleware.Authenticate(http.HandlerFunc(alertEventHandler.List)))
mux.Handle("GET "+b+"/notification-config", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.GetConfig))) // Authenticated routes — notification config
mux.Handle("PUT "+b+"/notification-config", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.UpdateConfig))) mux.Handle("GET "+b+"/notification-config",
mux.Handle("DELETE "+b+"/notification-config", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.DeleteConfig))) middleware.Authenticate(http.HandlerFunc(notifConfigHandler.GetConfig)))
mux.Handle("POST "+b+"/notification-config/test", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.TestWebhook))) mux.Handle("PUT "+b+"/notification-config",
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.UpdateConfig)))
mux.Handle("DELETE "+b+"/notification-config",
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.DeleteConfig)))
mux.Handle("POST "+b+"/notification-config/test",
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.TestChannels)))
// Authenticated routes — email digest
mux.Handle("POST "+b+"/email/setup",
middleware.Authenticate(http.HandlerFunc(emailDigestHandler.SetupEmail)))
mux.Handle("POST "+b+"/email/digest",
middleware.Authenticate(http.HandlerFunc(emailDigestHandler.SendDigest)))
handler := corsMiddleware(mux) handler := corsMiddleware(mux)
@@ -83,9 +115,14 @@ func main() {
log.Printf("API base path: %s", cfg.APIBasePath) log.Printf("API base path: %s", cfg.APIBasePath)
log.Printf("Environment: %s", cfg.NodeEnv) log.Printf("Environment: %s", cfg.NodeEnv)
if err := http.ListenAndServe(addr, handler); err != nil { 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) log.Fatalf("Server failed: %v", err)
os.Exit(1)
} }
} }
@@ -97,6 +134,7 @@ func corsMiddleware(next http.Handler) http.Handler {
if r.Method == http.MethodOptions { if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
return return
} }

View File

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

View File

@@ -42,6 +42,7 @@ require (
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/joho/godotenv v1.5.1 // indirect github.com/joho/godotenv v1.5.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/resend/resend-go/v3 v3.1.1 // indirect
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect

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.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/resend/resend-go/v3 v3.1.1 h1:Uwpf/tZU+O/r/3nMWE6zUAMIG9dX/vTBS3wlQzYJKSw=
github.com/resend/resend-go/v3 v3.1.1/go.mod h1:iI7VA0NoGjWvsNii5iNC5Dy0llsI3HncXPejhniYzwE=
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=

View File

@@ -1,4 +1,2 @@
ALTER TABLE user_notification_configs ALTER TABLE user_notification_configs
DROP COLUMN IF EXISTS telegram_chat_id,
DROP COLUMN IF EXISTS telegram_bot_token,
ADD COLUMN IF NOT EXISTS slack_webhook_url TEXT; ADD COLUMN IF NOT EXISTS slack_webhook_url TEXT;

View File

@@ -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

@@ -53,8 +53,10 @@ CREATE TABLE address_checkpoints (
CREATE TABLE user_notification_configs ( CREATE TABLE user_notification_configs (
user_id VARCHAR(128) PRIMARY KEY, user_id VARCHAR(128) PRIMARY KEY,
discord_webhook_url TEXT, -- Discord webhook URL (nullable) discord_webhook_url TEXT, -- Discord webhook URL (nullable)
slack_webhook_url TEXT, -- Slack incoming webhook URL (nullable) telegram_chat_id VARCHAR(128), -- Telegram chat ID (nullable)
telegram_bot_token VARCHAR(255), -- Telegram bot token (nullable)
email VARCHAR(255), -- Email for notifications (nullable) 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 notification_enabled BOOLEAN DEFAULT TRUE, -- Master on/off switch
created_at TIMESTAMP DEFAULT NOW(), created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW() updated_at TIMESTAMP DEFAULT NOW()

View File

@@ -1,3 +1,4 @@
//loads environment-based configuration.
package config package config
import ( import (
@@ -7,6 +8,14 @@ import (
"strings" "strings"
) )
const (
defaultPort = 3001
defaultDBPort = 5432
defaultPollIntervalMS = 60000
minPollIntervalMS = 1000
defaultDigestIntervalHours = 24
)
type Config struct { type Config struct {
Port int Port int
APIBasePath string APIBasePath string
@@ -20,38 +29,33 @@ type Config struct {
EthRPCURL string EthRPCURL string
PollIntervalMS int PollIntervalMS int
NodeEnv string NodeEnv string
ResendAPIKey string
SMTPHost string EmailFrom string
SMTPPort int DigestIntervalHours int
SMTPUser string
SMTPPassword string
SMTPFrom string
} }
// Load reads configuration from environment variables and returns a Config.
func Load() (*Config, error) { func Load() (*Config, error) {
cfg := &Config{ cfg := &Config{
Port: getEnvInt("PORT", 3001), Port: getEnvInt("PORT", defaultPort),
APIBasePath: getEnv("API_BASE_PATH", "/v1"), APIBasePath: getEnv("API_BASE_PATH", "/v1"),
DatabaseURL: os.Getenv("DATABASE_URL"), DatabaseURL: os.Getenv("DATABASE_URL"),
DBHost: getEnv("DB_HOST", "localhost"), DBHost: getEnv("DB_HOST", "localhost"),
DBPort: getEnvInt("DB_PORT", 5432), DBPort: getEnvInt("DB_PORT", defaultDBPort),
DBUser: os.Getenv("DB_USER"), DBUser: os.Getenv("DB_USER"),
DBPassword: os.Getenv("DB_PASSWORD"), DBPassword: os.Getenv("DB_PASSWORD"),
DBName: os.Getenv("DB_NAME"), DBName: os.Getenv("DB_NAME"),
FirebaseProjectID: os.Getenv("FIREBASE_PROJECT_ID"), FirebaseProjectID: os.Getenv("FIREBASE_PROJECT_ID"),
EthRPCURL: os.Getenv("ETH_RPC_URL"), EthRPCURL: os.Getenv("ETH_RPC_URL"),
PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", 60000), PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", defaultPollIntervalMS),
NodeEnv: getEnv("NODE_ENV", "development"), NodeEnv: getEnv("NODE_ENV", "development"),
ResendAPIKey: os.Getenv("RESEND_API_KEY"),
SMTPHost: os.Getenv("SMTP_HOST"), EmailFrom: getEnv("EMAIL_FROM", "Koin Ping <alerts@koinping.com>"),
SMTPPort: getEnvInt("SMTP_PORT", 587), DigestIntervalHours: getEnvInt("DIGEST_INTERVAL_HOURS", defaultDigestIntervalHours),
SMTPUser: os.Getenv("SMTP_USER"),
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
SMTPFrom: os.Getenv("SMTP_FROM"),
} }
if cfg.PollIntervalMS < 1000 { if cfg.PollIntervalMS < minPollIntervalMS {
return nil, fmt.Errorf("POLL_INTERVAL_MS must be >= 1000, got %d", cfg.PollIntervalMS) return nil, fmt.Errorf("POLL_INTERVAL_MS must be >= 1000, got %d", cfg.PollIntervalMS) //nolint:err113
} }
return cfg, nil return cfg, nil
@@ -59,17 +63,19 @@ func Load() (*Config, error) {
func (c *Config) DSN() string { func (c *Config) DSN() string {
if c.DatabaseURL != "" { if c.DatabaseURL != "" {
// pgx defaults to sslmode=prefer, which fails against local Postgres.
// Append sslmode=disable if not already specified. // Append sslmode=disable if not already specified.
if !strings.Contains(c.DatabaseURL, "sslmode=") { if !strings.Contains(c.DatabaseURL, "sslmode=") {
sep := "?" sep := "?"
if strings.Contains(c.DatabaseURL, "?") { if strings.Contains(c.DatabaseURL, "?") {
sep = "&" sep = "&"
} }
return c.DatabaseURL + sep + "sslmode=disable" return c.DatabaseURL + sep + "sslmode=disable"
} }
return c.DatabaseURL return c.DatabaseURL
} }
return fmt.Sprintf( return fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", "host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
c.DBHost, c.DBPort, c.DBUser, c.DBPassword, c.DBName, c.DBHost, c.DBPort, c.DBUser, c.DBPassword, c.DBName,
@@ -80,6 +86,7 @@ func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" { if v := os.Getenv(key); v != "" {
return v return v
} }
return fallback return fallback
} }
@@ -89,5 +96,6 @@ func getEnvInt(key string, fallback int) int {
return n return n
} }
} }
return fallback return fallback
} }

View File

@@ -1,115 +0,0 @@
package config
import (
"os"
"strings"
"testing"
)
func TestLoad_Defaults(t *testing.T) {
for _, key := range []string{"PORT", "API_BASE_PATH", "POLL_INTERVAL_MS", "NODE_ENV", "SMTP_PORT"} {
os.Unsetenv(key)
}
cfg, err := Load()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.Port != 3001 {
t.Errorf("Port = %d, want 3001", cfg.Port)
}
if cfg.APIBasePath != "/v1" {
t.Errorf("APIBasePath = %q, want /v1", cfg.APIBasePath)
}
if cfg.PollIntervalMS != 60000 {
t.Errorf("PollIntervalMS = %d, want 60000", cfg.PollIntervalMS)
}
if cfg.NodeEnv != "development" {
t.Errorf("NodeEnv = %q, want development", cfg.NodeEnv)
}
if cfg.SMTPPort != 587 {
t.Errorf("SMTPPort = %d, want 587", cfg.SMTPPort)
}
}
func TestLoad_InvalidPollInterval(t *testing.T) {
os.Setenv("POLL_INTERVAL_MS", "500")
defer os.Unsetenv("POLL_INTERVAL_MS")
_, err := Load()
if err == nil {
t.Fatal("expected error for poll interval < 1000")
}
}
func TestDSN_DatabaseURL(t *testing.T) {
cfg := &Config{DatabaseURL: "postgres://user:pass@localhost/db"}
dsn := cfg.DSN()
if !strings.Contains(dsn, "sslmode=disable") {
t.Error("DSN should append sslmode=disable")
}
if !strings.HasPrefix(dsn, "postgres://user:pass@localhost/db") {
t.Error("DSN should preserve original URL")
}
}
func TestDSN_DatabaseURL_WithSSLMode(t *testing.T) {
cfg := &Config{DatabaseURL: "postgres://user:pass@localhost/db?sslmode=require"}
dsn := cfg.DSN()
if strings.Count(dsn, "sslmode=") != 1 {
t.Error("DSN should not duplicate sslmode")
}
}
func TestDSN_Components(t *testing.T) {
cfg := &Config{
DBHost: "myhost",
DBPort: 5433,
DBUser: "myuser",
DBPassword: "mypass",
DBName: "mydb",
}
dsn := cfg.DSN()
if !strings.Contains(dsn, "host=myhost") {
t.Error("DSN should contain host")
}
if !strings.Contains(dsn, "port=5433") {
t.Error("DSN should contain port")
}
if !strings.Contains(dsn, "user=myuser") {
t.Error("DSN should contain user")
}
if !strings.Contains(dsn, "dbname=mydb") {
t.Error("DSN should contain dbname")
}
}
func TestGetEnv(t *testing.T) {
os.Setenv("TEST_KEY_KOINPING", "value")
defer os.Unsetenv("TEST_KEY_KOINPING")
if got := getEnv("TEST_KEY_KOINPING", "fallback"); got != "value" {
t.Errorf("got %q, want %q", got, "value")
}
if got := getEnv("NONEXISTENT_KEY_KOINPING", "fallback"); got != "fallback" {
t.Errorf("got %q, want %q", got, "fallback")
}
}
func TestGetEnvInt(t *testing.T) {
os.Setenv("TEST_INT_KOINPING", "42")
defer os.Unsetenv("TEST_INT_KOINPING")
if got := getEnvInt("TEST_INT_KOINPING", 0); got != 42 {
t.Errorf("got %d, want 42", got)
}
if got := getEnvInt("NONEXISTENT_INT_KOINPING", 99); got != 99 {
t.Errorf("got %d, want 99", got)
}
os.Setenv("TEST_INT_KOINPING", "notanumber")
if got := getEnvInt("TEST_INT_KOINPING", 99); got != 99 {
t.Errorf("got %d, want 99 for invalid int", got)
}
}

View File

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

View File

@@ -4,14 +4,16 @@ import "time"
type Address struct { type Address struct {
ID int `json:"id"` ID int `json:"id"`
UserID string `json:"user_id"` UserID string `json:"user_id"` //nolint:tagliatelle
Address string `json:"address"` Address string `json:"address"`
Label *string `json:"label"` Label *string `json:"label"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
} }
type AlertType string type AlertType string
func (a AlertType) String() string { return string(a) }
const ( const (
AlertIncomingTx AlertType = "incoming_tx" AlertIncomingTx AlertType = "incoming_tx"
AlertOutgoingTx AlertType = "outgoing_tx" AlertOutgoingTx AlertType = "outgoing_tx"
@@ -19,89 +21,113 @@ const (
AlertBalanceBelow AlertType = "balance_below" AlertBalanceBelow AlertType = "balance_below"
) )
var ValidAlertTypes = []AlertType{ var ValidAlertTypes = []AlertType{ //nolint:gochecknoglobals
AlertIncomingTx, AlertIncomingTx,
AlertOutgoingTx, AlertOutgoingTx,
AlertLargeTransfer, AlertLargeTransfer,
AlertBalanceBelow, AlertBalanceBelow,
} }
var ThresholdRequiredTypes = []AlertType{ // ThresholdRequiredTypes lists alert types that require a threshold value.
var ThresholdRequiredTypes = []AlertType{ //nolint:gochecknoglobals
AlertLargeTransfer, AlertLargeTransfer,
AlertBalanceBelow, AlertBalanceBelow,
} }
// IsValidAlertType returns true if the given string matches a known AlertType.
func IsValidAlertType(t string) bool { func IsValidAlertType(t string) bool {
for _, v := range ValidAlertTypes { for _, v := range ValidAlertTypes {
if string(v) == t { if string(v) == t {
return true return true
} }
} }
return false return false
} }
// IsThresholdRequired returns true if the given AlertType requires a threshold.
func IsThresholdRequired(t AlertType) bool { func IsThresholdRequired(t AlertType) bool {
for _, v := range ThresholdRequiredTypes { for _, v := range ThresholdRequiredTypes {
if v == t { if v == t {
return true return true
} }
} }
return false return false
} }
type AlertRule struct { type AlertRule struct {
ID int `json:"id"` ID int `json:"id"`
AddressID int `json:"address_id"` AddressID int `json:"address_id"` //nolint:tagliatelle
Type AlertType `json:"type"` Type AlertType `json:"type"`
Threshold *float64 `json:"threshold"` Threshold *float64 `json:"threshold"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
} }
type AlertEvent struct { type AlertEvent struct {
ID int `json:"id"` ID int `json:"id"`
AlertRuleID int `json:"alert_rule_id"` AlertRuleID int `json:"alert_rule_id"` //nolint:tagliatelle
Message string `json:"message"` Message string `json:"message"`
AddressLabel *string `json:"address_label"` AddressLabel *string `json:"address_label"` //nolint:tagliatelle
TxHash *string `json:"tx_hash"` TxHash *string `json:"tx_hash"` //nolint:tagliatelle
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
} }
type AddressCheckpoint struct { type AddressCheckpoint struct {
AddressID int `json:"address_id"` AddressID int `json:"address_id"` //nolint:tagliatelle
LastCheckedBlock int `json:"last_checked_block"` LastCheckedBlock int `json:"last_checked_block"` //nolint:tagliatelle
LastCheckedAt time.Time `json:"last_checked_at"` LastCheckedAt time.Time `json:"last_checked_at"` //nolint:tagliatelle
} }
// CheckpointDetail combines checkpoint and address info for reporting.
type CheckpointDetail struct { type CheckpointDetail struct {
AddressID int `json:"address_id"` AddressID int `json:"address_id"` //nolint:tagliatelle
Address string `json:"address"` Address string `json:"address"`
Label *string `json:"label"` Label *string `json:"label"`
LastCheckedBlock int `json:"last_checked_block"` LastCheckedBlock int `json:"last_checked_block"` //nolint:tagliatelle
LastCheckedAt time.Time `json:"last_checked_at"` LastCheckedAt time.Time `json:"last_checked_at"` //nolint:tagliatelle
} }
// NotificationConfig holds a user's notification preferences.
type NotificationConfig struct { type NotificationConfig struct {
UserID string `json:"user_id"` UserID string `json:"user_id"` //nolint:tagliatelle
DiscordWebhookURL *string `json:"discord_webhook_url"` DiscordWebhookURL *string `json:"discord_webhook_url"` //nolint:tagliatelle
SlackWebhookURL *string `json:"slack_webhook_url"` TelegramChatID *string `json:"telegram_chat_id"` //nolint:tagliatelle
TelegramBotToken *string `json:"telegram_bot_token"` //nolint:tagliatelle
Email *string `json:"email"` Email *string `json:"email"`
NotificationEnabled bool `json:"notification_enabled"` SlackWebhookURL *string `json:"slack_webhook_url"` //nolint:tagliatelle
CreatedAt *time.Time `json:"created_at,omitempty"` NotificationEnabled bool `json:"notification_enabled"` //nolint:tagliatelle
UpdatedAt *time.Time `json:"updated_at,omitempty"` 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 { type NormalizedTx struct {
Hash string `json:"hash"` Hash string `json:"hash"`
From string `json:"from"` From string `json:"from"`
To *string `json:"to"` To *string `json:"to"`
Value string `json:"value"` // Wei as string for precision Value string `json:"value"` // Wei as string for precision
BlockNumber int `json:"block_number"` BlockNumber int `json:"block_number"` //nolint:tagliatelle
BlockTimestamp int64 `json:"block_timestamp"` 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
}
// IsTokenTransfer returns true if this transaction represents an ERC-20 token transfer.
func (tx NormalizedTx) IsTokenTransfer() bool {
return tx.TokenContract != nil
} }
type Direction string type Direction string
// String implements fmt.Stringer.
func (d Direction) String() string { return string(d) }
const ( const (
DirectionIncoming Direction = "incoming" DirectionIncoming Direction = "incoming"
DirectionOutgoing Direction = "outgoing" DirectionOutgoing Direction = "outgoing"
@@ -109,6 +135,6 @@ const (
type ObservedTx struct { type ObservedTx struct {
NormalizedTx NormalizedTx
AddressID int `json:"address_id"` AddressID int `json:"address_id"` //nolint:tagliatelle
Direction Direction `json:"direction"` Direction Direction `json:"direction"`
} }

View File

@@ -1,35 +0,0 @@
package domain
import "testing"
func TestIsValidAlertType(t *testing.T) {
valid := []string{"incoming_tx", "outgoing_tx", "large_transfer", "balance_below"}
for _, v := range valid {
if !IsValidAlertType(v) {
t.Errorf("IsValidAlertType(%q) = false, want true", v)
}
}
invalid := []string{"", "invalid", "INCOMING_TX", "send", "receive"}
for _, v := range invalid {
if IsValidAlertType(v) {
t.Errorf("IsValidAlertType(%q) = true, want false", v)
}
}
}
func TestIsThresholdRequired(t *testing.T) {
required := []AlertType{AlertLargeTransfer, AlertBalanceBelow}
for _, at := range required {
if !IsThresholdRequired(at) {
t.Errorf("IsThresholdRequired(%q) = false, want true", at)
}
}
notRequired := []AlertType{AlertIncomingTx, AlertOutgoingTx}
for _, at := range notRequired {
if IsThresholdRequired(at) {
t.Errorf("IsThresholdRequired(%q) = true, want false", at)
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,101 @@
package handlers
import (
"log"
"net/http"
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
"github.com/kjannette/koin-ping/backend-go/internal/models"
"github.com/kjannette/koin-ping/backend-go/internal/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,6 +7,7 @@ import (
"regexp" "regexp"
"strings" "strings"
"github.com/kjannette/koin-ping/backend-go/internal/config"
"github.com/kjannette/koin-ping/backend-go/internal/domain" "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/middleware"
"github.com/kjannette/koin-ping/backend-go/internal/models" "github.com/kjannette/koin-ping/backend-go/internal/models"
@@ -17,10 +18,11 @@ var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
type NotificationConfigHandler struct { type NotificationConfigHandler struct {
configs *models.NotificationConfigModel configs *models.NotificationConfigModel
cfg *config.Config
} }
func NewNotificationConfigHandler(configs *models.NotificationConfigModel) *NotificationConfigHandler { func NewNotificationConfigHandler(configs *models.NotificationConfigModel, cfg *config.Config) *NotificationConfigHandler {
return &NotificationConfigHandler{configs: configs} return &NotificationConfigHandler{configs: configs, cfg: cfg}
} }
func (h *NotificationConfigHandler) GetConfig(w http.ResponseWriter, r *http.Request) { func (h *NotificationConfigHandler) GetConfig(w http.ResponseWriter, r *http.Request) {
@@ -51,8 +53,10 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
var body struct { var body struct {
DiscordWebhookURL *string `json:"discord_webhook_url"` DiscordWebhookURL *string `json:"discord_webhook_url"`
SlackWebhookURL *string `json:"slack_webhook_url"` TelegramChatID *string `json:"telegram_chat_id"`
TelegramBotToken *string `json:"telegram_bot_token"`
Email *string `json:"email"` Email *string `json:"email"`
SlackWebhookURL *string `json:"slack_webhook_url"`
NotificationEnabled *bool `json:"notification_enabled"` NotificationEnabled *bool `json:"notification_enabled"`
} }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
@@ -63,8 +67,9 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
log.Printf("User %s updating notification config", userID) log.Printf("User %s updating notification config", userID)
if body.DiscordWebhookURL == nil && body.SlackWebhookURL == nil && 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", writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
"At least one configuration field must be provided") "At least one configuration field must be provided")
return return
@@ -97,8 +102,10 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
cfg := domain.NotificationConfig{ cfg := domain.NotificationConfig{
DiscordWebhookURL: body.DiscordWebhookURL, DiscordWebhookURL: body.DiscordWebhookURL,
SlackWebhookURL: body.SlackWebhookURL, TelegramChatID: body.TelegramChatID,
TelegramBotToken: body.TelegramBotToken,
Email: body.Email, Email: body.Email,
SlackWebhookURL: body.SlackWebhookURL,
NotificationEnabled: enabled, NotificationEnabled: enabled,
} }
@@ -135,49 +142,75 @@ func (h *NotificationConfigHandler) DeleteConfig(w http.ResponseWriter, r *http.
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
func (h *NotificationConfigHandler) TestWebhook(w http.ResponseWriter, r *http.Request) { // TestChannels sends a test message to all configured notification channels.
_ = middleware.GetUserID(r.Context()) func (h *NotificationConfigHandler) TestChannels(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
var body struct { log.Printf("User %s testing notification channels", userID)
Type string `json:"type"`
URL string `json:"url"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body")
return
}
if body.URL == "" {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "URL is required")
return
}
var ok bool
var err error
switch body.Type {
case "slack":
if !strings.HasPrefix(body.URL, "https://hooks.slack.com/") {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid Slack webhook URL")
return
}
ok, err = notifications.TestSlackWebhook(body.URL)
case "discord":
if !strings.HasPrefix(body.URL, "https://discord.com/api/webhooks/") {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid Discord webhook URL")
return
}
ok, err = notifications.TestDiscordWebhook(body.URL)
default:
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Type must be 'slack' or 'discord'")
return
}
cfg, err := h.configs.GetConfig(r.Context(), userID)
if err != nil { if err != nil {
log.Printf("Webhook test failed: %v", err) log.Printf("Error getting notification config for test: %v", err)
writeJSON(w, http.StatusOK, map[string]interface{}{"success": false, "error": err.Error()}) writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to get notification config")
return return
} }
writeJSON(w, http.StatusOK, map[string]interface{}{"success": ok}) if cfg == nil {
writeError(w, http.StatusNotFound, "NOT_FOUND", "No notification configuration found")
return
}
type channelResult struct {
Channel string `json:"channel"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
var results []channelResult
if cfg.DiscordWebhookURL != nil && *cfg.DiscordWebhookURL != "" {
ok, testErr := notifications.TestDiscordWebhook(*cfg.DiscordWebhookURL)
res := channelResult{Channel: "discord", Success: ok}
if testErr != nil {
res.Error = testErr.Error()
}
results = append(results, res)
}
if cfg.TelegramBotToken != nil && *cfg.TelegramBotToken != "" &&
cfg.TelegramChatID != nil && *cfg.TelegramChatID != "" {
ok, testErr := notifications.TestTelegramWebhook(*cfg.TelegramBotToken, *cfg.TelegramChatID)
res := channelResult{Channel: "telegram", Success: ok}
if testErr != nil {
res.Error = testErr.Error()
}
results = append(results, res)
}
if cfg.SlackWebhookURL != nil && *cfg.SlackWebhookURL != "" {
ok, testErr := notifications.TestSlackWebhook(*cfg.SlackWebhookURL)
res := channelResult{Channel: "slack", Success: ok}
if testErr != nil {
res.Error = testErr.Error()
}
results = append(results, res)
}
if cfg.Email != nil && *cfg.Email != "" {
ok, testErr := notifications.TestEmailNotification(
h.cfg.ResendAPIKey, h.cfg.EmailFrom, *cfg.Email,
)
res := channelResult{Channel: "email", Success: ok}
if testErr != nil {
res.Error = testErr.Error()
}
results = append(results, res)
}
if len(results) == 0 {
writeError(w, http.StatusBadRequest, "NO_CHANNELS",
"No notification channels are configured")
return
}
writeJSON(w, http.StatusOK, map[string]any{"results": results})
} }

View File

@@ -1,15 +1,67 @@
package handlers package handlers
import ( import (
"context"
"log" "log"
"net/http" "net/http"
"time" "time"
"github.com/kjannette/koin-ping/backend-go/internal/models" "github.com/kjannette/koin-ping/backend-go/internal/models"
"github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
) )
// 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) { func HealthCheck(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{ writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "ok", "status": "ok",
@@ -17,63 +69,3 @@ func HealthCheck(w http.ResponseWriter, r *http.Request) {
"service": "koin-ping-backend", "service": "koin-ping-backend",
}) })
} }
type StatusHandler struct {
eth ethereum.EthereumObserver
checkpoints *models.CheckpointModel
}
func NewStatusHandler(eth ethereum.EthereumObserver, checkpoints *models.CheckpointModel) *StatusHandler {
return &StatusHandler{eth: eth, checkpoints: checkpoints}
}
func (h *StatusHandler) SystemStatus(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
status := "healthy"
if h.eth == nil {
writeJSON(w, http.StatusOK, map[string]interface{}{
"latestBlock": 0,
"lag": 0,
"trackedAddresses": 0,
"status": "no_rpc",
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
return
}
latestBlock, err := h.eth.GetLatestBlockNumber(ctx)
if err != nil {
log.Printf("Failed to get latest block: %v", err)
status = "degraded"
latestBlock = 0
}
lag := 0
checkpoints, err := h.checkpoints.ListAll(ctx)
if err != nil {
log.Printf("Failed to list checkpoints: %v", err)
} else if len(checkpoints) > 0 && latestBlock > 0 {
minChecked := checkpoints[0].LastCheckedBlock
for _, cp := range checkpoints[1:] {
if cp.LastCheckedBlock < minChecked {
minChecked = cp.LastCheckedBlock
}
}
lag = latestBlock - minChecked
}
if lag > 50 {
status = "syncing"
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"latestBlock": latestBlock,
"lag": lag,
"trackedAddresses": len(checkpoints),
"status": status,
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}

View File

@@ -1,3 +1,4 @@
// Package middleware provides HTTP middleware for authentication and context injection.
package middleware package middleware
import ( import (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,99 +0,0 @@
package notifications
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestSendDiscordNotification(t *testing.T) {
var receivedPayload discordPayload
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
body, _ := io.ReadAll(r.Body)
json.Unmarshal(body, &receivedPayload)
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
meta := AlertMetadata{
TxHash: "0xabc123",
AddressLabel: "Treasury",
AlertType: "incoming_tx",
Address: "0x1234567890abcdef1234567890abcdef12345678",
}
sent, err := SendDiscordNotification(server.URL, "Incoming transaction: 5.5 ETH received", meta)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !sent {
t.Fatal("expected sent to be true")
}
if len(receivedPayload.Embeds) != 1 {
t.Fatalf("expected 1 embed, got %d", len(receivedPayload.Embeds))
}
embed := receivedPayload.Embeds[0]
if embed.Title != "Koin Ping Alert" {
t.Errorf("title = %q, want %q", embed.Title, "Koin Ping Alert")
}
if embed.Color != 0x00ff00 {
t.Errorf("color = %x, want %x (green for incoming_tx)", embed.Color, 0x00ff00)
}
if len(embed.Fields) != 3 {
t.Errorf("expected 3 fields (address, blockchain address, tx), got %d", len(embed.Fields))
}
}
func TestSendDiscordNotification_ServerError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
}))
defer server.Close()
sent, err := SendDiscordNotification(server.URL, "test", AlertMetadata{})
if err == nil {
t.Fatal("expected error for bad response")
}
if sent {
t.Fatal("expected sent to be false")
}
}
func TestTestDiscordWebhook(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
ok, err := TestDiscordWebhook(server.URL)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !ok {
t.Fatal("expected ok to be true")
}
}
func TestColorForAlertType(t *testing.T) {
tests := map[string]int{
"incoming_tx": 0x00ff00,
"outgoing_tx": 0xff9900,
"large_transfer": 0xff0000,
"balance_below": 0xff0000,
"unknown": 0x0099ff,
}
for alertType, want := range tests {
if got := colorForAlertType(alertType); got != want {
t.Errorf("colorForAlertType(%q) = %x, want %x", alertType, got, want)
}
}
}

View File

@@ -1,101 +1,146 @@
package notifications package notifications
import ( import (
"bytes"
"context"
"encoding/json"
"fmt" "fmt"
"log" "log"
"net/smtp" "net/http"
"strings" "time"
) )
type SMTPConfig struct { // EmailNotifier sends alert notifications via email (Resend).
Host string type EmailNotifier struct {
Port int APIKey string
Username string
Password string
From string From string
To string
} }
func (c *SMTPConfig) IsConfigured() bool { // Send implements Notifier for email.
return c.Host != "" && c.From != "" func (e *EmailNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
_, err := SendEmailNotification(e.APIKey, e.From, e.To, message, meta)
return err
} }
func (c *SMTPConfig) addr() string { const emailHTTPTimeoutSeconds = 10
return fmt.Sprintf("%s:%d", c.Host, c.Port)
var emailHTTPClient = &http.Client{ //nolint:gochecknoglobals
Timeout: emailHTTPTimeoutSeconds * time.Second,
} }
func SendEmailNotification(cfg SMTPConfig, toEmail, message string, meta AlertMetadata) (bool, error) { type resendPayload struct {
if !cfg.IsConfigured() { From string `json:"from"`
return false, fmt.Errorf("SMTP is not configured") 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", humanAlertType(meta.AlertType)) subject := fmt.Sprintf("Koin Ping Alert: %s", alertTypeLabel(meta.AlertType))
body := buildEmailBody(message, meta) txLink := ""
if meta.TxHash != "" {
msg := strings.Join([]string{ txLink = fmt.Sprintf(
fmt.Sprintf("From: %s", cfg.From), `<p><a href="https://etherscan.io/tx/%s">View on Etherscan</a></p>`,
fmt.Sprintf("To: %s", toEmail), meta.TxHash,
fmt.Sprintf("Subject: %s", subject), )
"MIME-Version: 1.0",
"Content-Type: text/html; charset=UTF-8",
"",
body,
}, "\r\n")
var auth smtp.Auth
if cfg.Username != "" {
auth = smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
} }
err := smtp.SendMail(cfg.addr(), auth, cfg.From, []string{toEmail}, []byte(msg)) 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 { if err != nil {
log.Printf("Failed to send email notification to %s: %v", toEmail, err) 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 return false, err
} }
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
log.Printf("Resend API failed: HTTP %d", resp.StatusCode)
return false, fmt.Errorf("resend API failed: HTTP %d", resp.StatusCode)
}
return true, nil return true, nil
} }
func TestEmailNotification(cfg SMTPConfig, toEmail string) (bool, error) { func TestEmailNotification(apiKey, fromAddress, toAddress string) (bool, error) {
meta := AlertMetadata{AlertType: "test"} if apiKey == "" {
return SendEmailNotification(cfg, toEmail, return false, fmt.Errorf("email not configured: RESEND_API_KEY not set") //nolint:err113
"This is a test notification from Koin Ping. Your email is configured correctly!", meta)
}
func buildEmailBody(message string, meta AlertMetadata) string {
txSection := ""
if meta.TxHash != "" {
txSection = fmt.Sprintf(
`<tr><td style="padding:8px 0;color:#666">Transaction</td>`+
`<td style="padding:8px 0"><a href="https://etherscan.io/tx/%s" style="color:#4f46e5">View on Etherscan</a></td></tr>`,
meta.TxHash,
)
} }
return fmt.Sprintf(`<!DOCTYPE html> payload := resendPayload{
<html> From: fromAddress,
<body style="margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f5f5f5"> To: toAddress,
<div style="max-width:560px;margin:24px auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.1)"> Subject: "Koin Ping — Test Notification",
<div style="background:#4f46e5;padding:20px 24px"> HTML: `<p>Your email alerts are configured correctly!</p><p style="font-size:12px;color:#999;">Sent by Koin Ping</p>`,
<h1 style="margin:0;color:#fff;font-size:18px">Koin Ping Alert</h1> }
</div>
<div style="padding:24px"> body, err := json.Marshal(payload)
<p style="margin:0 0 16px;font-size:15px;color:#333">%s</p> if err != nil {
<table style="width:100%%;border-collapse:collapse;font-size:14px"> return false, err
<tr><td style="padding:8px 0;color:#666">Address</td><td style="padding:8px 0">%s</td></tr> }
<tr><td style="padding:8px 0;color:#666">Blockchain Address</td><td style="padding:8px 0;font-family:monospace;font-size:12px">%s</td></tr>
%s req, err := http.NewRequest(http.MethodPost, "https://api.resend.com/emails", bytes.NewReader(body))
</table> if err != nil {
</div> return false, err
<div style="padding:16px 24px;background:#fafafa;font-size:12px;color:#999;text-align:center"> }
Sent by Koin Ping req.Header.Set("Content-Type", "application/json")
</div> req.Header.Set("Authorization", "Bearer "+apiKey)
</div>
</body> resp, err := emailHTTPClient.Do(req)
</html>`, message, meta.AddressLabel, meta.Address, txSection) 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 humanAlertType(alertType string) string { func alertTypeLabel(alertType string) string {
switch alertType { switch alertType {
case "incoming_tx": case "incoming_tx":
return "Incoming Transaction" return "Incoming Transaction"

View File

@@ -1,102 +0,0 @@
package notifications
import (
"strings"
"testing"
)
func TestSMTPConfig_IsConfigured(t *testing.T) {
tests := []struct {
name string
cfg SMTPConfig
want bool
}{
{"fully configured", SMTPConfig{Host: "smtp.example.com", Port: 587, From: "noreply@example.com"}, true},
{"missing host", SMTPConfig{Port: 587, From: "noreply@example.com"}, false},
{"missing from", SMTPConfig{Host: "smtp.example.com", Port: 587}, false},
{"empty", SMTPConfig{}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.cfg.IsConfigured(); got != tt.want {
t.Errorf("IsConfigured() = %v, want %v", got, tt.want)
}
})
}
}
func TestSMTPConfig_Addr(t *testing.T) {
cfg := SMTPConfig{Host: "smtp.example.com", Port: 587}
if got := cfg.addr(); got != "smtp.example.com:587" {
t.Errorf("addr() = %q, want %q", got, "smtp.example.com:587")
}
}
func TestBuildEmailBody(t *testing.T) {
meta := AlertMetadata{
TxHash: "0xabc123",
AddressLabel: "Treasury",
AlertType: "incoming_tx",
Address: "0x1234567890abcdef1234567890abcdef12345678",
}
body := buildEmailBody("Incoming transaction: 5.5 ETH received", meta)
if !strings.Contains(body, "Koin Ping Alert") {
t.Error("body should contain title")
}
if !strings.Contains(body, "Incoming transaction: 5.5 ETH received") {
t.Error("body should contain message")
}
if !strings.Contains(body, "Treasury") {
t.Error("body should contain address label")
}
if !strings.Contains(body, "0x1234567890abcdef") {
t.Error("body should contain blockchain address")
}
if !strings.Contains(body, "etherscan.io/tx/0xabc123") {
t.Error("body should contain etherscan link")
}
}
func TestBuildEmailBody_NoTxHash(t *testing.T) {
meta := AlertMetadata{
AddressLabel: "Cold Storage",
AlertType: "balance_below",
Address: "0xabcdef",
}
body := buildEmailBody("Balance dropped below threshold", meta)
if strings.Contains(body, "etherscan.io") {
t.Error("body should not contain etherscan link when no tx hash")
}
}
func TestHumanAlertType(t *testing.T) {
tests := map[string]string{
"incoming_tx": "Incoming Transaction",
"outgoing_tx": "Outgoing Transaction",
"large_transfer": "Large Transfer",
"balance_below": "Balance Below Threshold",
"unknown": "Alert",
"test": "Alert",
}
for alertType, want := range tests {
if got := humanAlertType(alertType); got != want {
t.Errorf("humanAlertType(%q) = %q, want %q", alertType, got, want)
}
}
}
func TestSendEmailNotification_NotConfigured(t *testing.T) {
cfg := SMTPConfig{}
sent, err := SendEmailNotification(cfg, "user@example.com", "test", AlertMetadata{})
if err == nil {
t.Fatal("expected error for unconfigured SMTP")
}
if sent {
t.Fatal("expected sent to be false")
}
}

View File

@@ -0,0 +1,16 @@
package notifications
import "context"
// AlertMetadata holds context about the alert being sent.
type AlertMetadata struct {
TxHash string
AddressLabel string
AlertType string
Address string
}
// Notifier is the interface implemented by all notification channels.
type Notifier interface {
Send(ctx context.Context, message string, meta AlertMetadata) error
}

View File

@@ -2,6 +2,7 @@ package notifications
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
@@ -9,67 +10,76 @@ import (
"time" "time"
) )
type slackBlock struct { // SlackNotifier sends alert notifications via a Slack webhook.
Type string `json:"type"` type SlackNotifier struct {
Text *slackText `json:"text,omitempty"` WebhookURL string
Fields []slackText `json:"fields,omitempty"`
Elements []slackText `json:"elements,omitempty"`
} }
type slackText struct { // Send implements Notifier for Slack.
Type string `json:"type"` 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"` 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 { type slackPayload struct {
Blocks []slackBlock `json:"blocks"` Text string `json:"text,omitempty"`
Attachments []slackAttachment `json:"attachments,omitempty"`
} }
func SendSlackNotification(webhookURL, message string, meta AlertMetadata) (bool, error) { func SendSlackNotification(webhookURL, message string, meta AlertMetadata) (bool, error) {
etherscanLink := "" fields := []slackField{
{Title: "Address", Value: meta.AddressLabel, Short: true},
{Title: "Blockchain Address", Value: fmt.Sprintf("`%s`", meta.Address), Short: false},
}
if meta.TxHash != "" { if meta.TxHash != "" {
etherscanLink = fmt.Sprintf("<%s|View on Etherscan>", "https://etherscan.io/tx/"+meta.TxHash) fields = append(fields, slackField{
} Title: "Transaction",
Value: fmt.Sprintf("<https://etherscan.io/tx/%s|View on Etherscan>", meta.TxHash),
blocks := []slackBlock{ Short: false,
{
Type: "header",
Text: &slackText{Type: "plain_text", Text: emojiForAlertType(meta.AlertType) + " Koin Ping Alert"},
},
{
Type: "section",
Text: &slackText{Type: "mrkdwn", Text: message},
},
{
Type: "section",
Fields: []slackText{
{Type: "mrkdwn", Text: fmt.Sprintf("*Address:*\n%s", meta.AddressLabel)},
{Type: "mrkdwn", Text: fmt.Sprintf("*Blockchain Address:*\n`%s`", meta.Address)},
},
},
}
if etherscanLink != "" {
blocks = append(blocks, slackBlock{
Type: "section",
Text: &slackText{Type: "mrkdwn", Text: fmt.Sprintf("*Transaction:* %s", etherscanLink)},
}) })
} }
blocks = append(blocks, slackBlock{ payload := slackPayload{
Type: "context", Attachments: []slackAttachment{
Elements: []slackText{ {
{Type: "mrkdwn", Text: fmt.Sprintf("Koin Ping | %s", time.Now().UTC().Format(time.RFC3339))}, Color: slackColorForAlertType(meta.AlertType),
Title: "Koin Ping Alert",
Text: message,
Fields: fields,
Footer: "Koin Ping",
Ts: time.Now().Unix(),
}, },
}) },
}
payload := slackPayload{Blocks: blocks}
body, err := json.Marshal(payload) body, err := json.Marshal(payload)
if err != nil { if err != nil {
return false, fmt.Errorf("marshal slack payload: %w", err) return false, fmt.Errorf("marshal slack payload: %w", err)
} }
resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body)) resp, err := slackHTTPClient.Post(webhookURL, "application/json", bytes.NewReader(body))
if err != nil { if err != nil {
log.Printf("Failed to send Slack notification: %v", err) log.Printf("Failed to send Slack notification: %v", err)
return false, err return false, err
@@ -86,15 +96,7 @@ func SendSlackNotification(webhookURL, message string, meta AlertMetadata) (bool
func TestSlackWebhook(webhookURL string) (bool, error) { func TestSlackWebhook(webhookURL string) (bool, error) {
payload := slackPayload{ payload := slackPayload{
Blocks: []slackBlock{ Text: "Koin Ping test notification — Your Slack alerts are configured correctly!",
{
Type: "section",
Text: &slackText{
Type: "mrkdwn",
Text: "Koin Ping test notification — Your Slack webhook is configured correctly!",
},
},
},
} }
body, err := json.Marshal(payload) body, err := json.Marshal(payload)
@@ -102,7 +104,7 @@ func TestSlackWebhook(webhookURL string) (bool, error) {
return false, err return false, err
} }
resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body)) resp, err := slackHTTPClient.Post(webhookURL, "application/json", bytes.NewReader(body))
if err != nil { if err != nil {
log.Printf("Slack webhook test failed: %v", err) log.Printf("Slack webhook test failed: %v", err)
return false, err return false, err
@@ -112,17 +114,15 @@ func TestSlackWebhook(webhookURL string) (bool, error) {
return resp.StatusCode >= 200 && resp.StatusCode < 300, nil return resp.StatusCode >= 200 && resp.StatusCode < 300, nil
} }
func emojiForAlertType(alertType string) string { func slackColorForAlertType(alertType string) string {
switch alertType { switch alertType {
case "incoming_tx": case "incoming_tx":
return "📥" return "#00ff00"
case "outgoing_tx": case "outgoing_tx":
return "📤" return "#ff9900"
case "large_transfer": case "large_transfer", "balance_below":
return "🚨" return "#ff0000"
case "balance_below":
return "⚠️"
default: default:
return "🔔" return "#0099ff"
} }
} }

View File

@@ -1,128 +0,0 @@
package notifications
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestSendSlackNotification(t *testing.T) {
var receivedPayload slackPayload
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
if r.Header.Get("Content-Type") != "application/json" {
t.Errorf("expected application/json content type")
}
body, _ := io.ReadAll(r.Body)
json.Unmarshal(body, &receivedPayload)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
meta := AlertMetadata{
TxHash: "0xabc123",
AddressLabel: "Treasury",
AlertType: "incoming_tx",
Address: "0x1234567890abcdef1234567890abcdef12345678",
}
sent, err := SendSlackNotification(server.URL, "Incoming transaction: 5.5 ETH received", meta)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !sent {
t.Fatal("expected sent to be true")
}
if len(receivedPayload.Blocks) < 3 {
t.Fatalf("expected at least 3 blocks, got %d", len(receivedPayload.Blocks))
}
if receivedPayload.Blocks[0].Type != "header" {
t.Errorf("first block should be header, got %s", receivedPayload.Blocks[0].Type)
}
}
func TestSendSlackNotification_ServerError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
sent, err := SendSlackNotification(server.URL, "test", AlertMetadata{})
if err == nil {
t.Fatal("expected error for server error response")
}
if sent {
t.Fatal("expected sent to be false")
}
}
func TestSendSlackNotification_NoTxHash(t *testing.T) {
var receivedPayload slackPayload
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
json.Unmarshal(body, &receivedPayload)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
meta := AlertMetadata{
AddressLabel: "Treasury",
AlertType: "balance_below",
Address: "0x1234567890abcdef1234567890abcdef12345678",
}
sent, err := SendSlackNotification(server.URL, "Balance dropped below threshold", meta)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !sent {
t.Fatal("expected sent to be true")
}
for _, block := range receivedPayload.Blocks {
if block.Text != nil && block.Type == "section" {
if block.Text.Text == "*Transaction:*" {
t.Error("should not include transaction block when TxHash is empty")
}
}
}
}
func TestTestSlackWebhook(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
ok, err := TestSlackWebhook(server.URL)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !ok {
t.Fatal("expected ok to be true")
}
}
func TestEmojiForAlertType(t *testing.T) {
tests := map[string]string{
"incoming_tx": "📥",
"outgoing_tx": "📤",
"large_transfer": "🚨",
"balance_below": "⚠️",
"unknown": "🔔",
}
for alertType, want := range tests {
if got := emojiForAlertType(alertType); got != want {
t.Errorf("emojiForAlertType(%q) = %q, want %q", alertType, got, want)
}
}
}

View File

@@ -0,0 +1,122 @@
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 {
log.Printf("Telegram API failed: HTTP %d", resp.StatusCode)
return false, fmt.Errorf("telegram API failed: HTTP %d", resp.StatusCode)
}
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

@@ -4,7 +4,9 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log"
"math/big" "math/big"
"net/http" "net/http"
"strings" "strings"
@@ -13,7 +15,11 @@ import (
"github.com/kjannette/koin-ping/backend-go/internal/domain" "github.com/kjannette/koin-ping/backend-go/internal/domain"
) )
const rpcTimeoutMS = 30000 const (
rpcTimeoutMS = 30000
rpcMaxRetries = 3
rpcRetryBaseMS = 1000
)
type JsonRpcEthereum struct { type JsonRpcEthereum struct {
rpcURL string rpcURL string
@@ -66,6 +72,44 @@ func (j *JsonRpcEthereum) callRPC(ctx context.Context, method string, params ...
return nil, fmt.Errorf("marshal RPC request: %w", err) return nil, fmt.Errorf("marshal RPC request: %w", err)
} }
return j.callWithRetry(ctx, method, body)
}
// 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)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, j.rpcURL, bytes.NewReader(body))
if err != nil { if err != nil {
return nil, fmt.Errorf("create RPC request: %w", err) return nil, fmt.Errorf("create RPC request: %w", err)
@@ -78,8 +122,13 @@ func (j *JsonRpcEthereum) callRPC(ctx context.Context, method string, params ...
} }
defer resp.Body.Close() defer resp.Body.Close()
// 429 and 5xx are transient; other non-200 are permanent.
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d: %s for %s", resp.StatusCode, resp.Status, method) 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 var rpcResp rpcResponse
@@ -88,12 +137,25 @@ func (j *JsonRpcEthereum) callRPC(ctx context.Context, method string, params ...
} }
if rpcResp.Error != nil { if rpcResp.Error != nil {
return nil, fmt.Errorf("RPC Error [%s]: %s (code: %d)", method, rpcResp.Error.Message, rpcResp.Error.Code) // 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 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) { func (j *JsonRpcEthereum) GetLatestBlockNumber(ctx context.Context) (int, error) {
result, err := j.callRPC(ctx, "eth_blockNumber") result, err := j.callRPC(ctx, "eth_blockNumber")
if err != nil { if err != nil {
@@ -180,6 +242,132 @@ func (j *JsonRpcEthereum) GetBalance(ctx context.Context, address string) (strin
return hexToDecimalString(hexBalance), nil 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) { func hexToInt(hex string) (int, error) {
hex = strings.TrimPrefix(hex, "0x") hex = strings.TrimPrefix(hex, "0x")
n, ok := new(big.Int).SetString(hex, 16) n, ok := new(big.Int).SetString(hex, 16)

View File

@@ -1,96 +0,0 @@
package ethereum
import (
"math/big"
"testing"
)
func TestHexToInt(t *testing.T) {
tests := []struct {
hex string
want int
}{
{"0x0", 0},
{"0x1", 1},
{"0xa", 10},
{"0xff", 255},
{"0x100", 256},
{"0x1234", 4660},
}
for _, tt := range tests {
got, err := hexToInt(tt.hex)
if err != nil {
t.Fatalf("hexToInt(%q) error: %v", tt.hex, err)
}
if got != tt.want {
t.Errorf("hexToInt(%q) = %d, want %d", tt.hex, got, tt.want)
}
}
}
func TestHexToInt_Invalid(t *testing.T) {
_, err := hexToInt("xyz")
if err == nil {
t.Fatal("expected error for invalid hex")
}
}
func TestHexToInt64(t *testing.T) {
got, err := hexToInt64("0x5f5e100")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != 100000000 {
t.Errorf("got %d, want 100000000", got)
}
}
func TestHexToDecimalString(t *testing.T) {
tests := []struct {
hex string
want string
}{
{"0x0", "0"},
{"0x", "0"},
{"", "0"},
{"0x1", "1"},
{"0xde0b6b3a7640000", "1000000000000000000"},
}
for _, tt := range tests {
got := hexToDecimalString(tt.hex)
if got != tt.want {
t.Errorf("hexToDecimalString(%q) = %q, want %q", tt.hex, got, tt.want)
}
}
}
func TestHexToDecimalString_LargeValues(t *testing.T) {
expected := new(big.Int).Mul(
big.NewInt(100),
new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil),
)
hex := "0x" + expected.Text(16)
got := hexToDecimalString(hex)
if got != expected.String() {
t.Errorf("got %q, want %q", got, expected.String())
}
}
func TestNewJsonRpcEthereum_EmptyURL(t *testing.T) {
_, err := NewJsonRpcEthereum("")
if err == nil {
t.Fatal("expected error for empty URL")
}
}
func TestNewJsonRpcEthereum_ValidURL(t *testing.T) {
eth, err := NewJsonRpcEthereum("https://example.com/rpc")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if eth == nil {
t.Fatal("expected non-nil client")
}
}

View File

@@ -12,4 +12,5 @@ type EthereumObserver interface {
GetLatestBlockNumber(ctx context.Context) (int, error) GetLatestBlockNumber(ctx context.Context) (int, error)
GetBlockTransactions(ctx context.Context, blockNumber int) ([]domain.NormalizedTx, error) GetBlockTransactions(ctx context.Context, blockNumber int) ([]domain.NormalizedTx, error)
GetBalance(ctx context.Context, address string) (string, error) GetBalance(ctx context.Context, address string) (string, error)
GetTokenTransfers(ctx context.Context, fromBlock, toBlock int, address string) ([]domain.NormalizedTx, error)
} }

View File

@@ -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,199 @@
package services
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"github.com/kjannette/koin-ping/backend-go/internal/models"
)
const (
resendAPIURL = "https://api.resend.com/emails"
emailHTTPTimeout = 10 * time.Second
defaultDigestMaxItems = 50
)
var digestHTTPClient = &http.Client{Timeout: emailHTTPTimeout} //nolint:gochecknoglobals
// EmailDigestService 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)
}
// SendDigest compiles recent alert events for a user and sends a digest email.
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)
}
// SendDigestsForAllUsers sends a digest email to every user that has
// notifications enabled and an email configured.
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

@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"log" "log"
"time"
"github.com/kjannette/koin-ping/backend-go/internal/domain" "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/models"
@@ -12,13 +13,20 @@ import (
"github.com/kjannette/koin-ping/backend-go/internal/wei" "github.com/kjannette/koin-ping/backend-go/internal/wei"
) )
const (
notificationTimeout = 30 * time.Second
notificationMaxRetries = 3
notificationRetryBase = time.Second
)
type EvaluatorService struct { type EvaluatorService struct {
eth ethereum.EthereumObserver eth ethereum.EthereumObserver
alertRules *models.AlertRuleModel alertRules *models.AlertRuleModel
alertEvents *models.AlertEventModel alertEvents *models.AlertEventModel
addresses *models.AddressModel addresses *models.AddressModel
notifConfigs *models.NotificationConfigModel notifConfigs *models.NotificationConfigModel
smtpConfig notifications.SMTPConfig resendAPIKey string
emailFrom string
} }
func NewEvaluatorService( func NewEvaluatorService(
@@ -27,7 +35,8 @@ func NewEvaluatorService(
alertEvents *models.AlertEventModel, alertEvents *models.AlertEventModel,
addresses *models.AddressModel, addresses *models.AddressModel,
notifConfigs *models.NotificationConfigModel, notifConfigs *models.NotificationConfigModel,
smtpCfg notifications.SMTPConfig, resendAPIKey string,
emailFrom string,
) *EvaluatorService { ) *EvaluatorService {
return &EvaluatorService{ return &EvaluatorService{
eth: eth, eth: eth,
@@ -35,7 +44,8 @@ func NewEvaluatorService(
alertEvents: alertEvents, alertEvents: alertEvents,
addresses: addresses, addresses: addresses,
notifConfigs: notifConfigs, notifConfigs: notifConfigs,
smtpConfig: smtpCfg, resendAPIKey: resendAPIKey,
emailFrom: emailFrom,
} }
} }
@@ -164,21 +174,86 @@ func (s *EvaluatorService) fireAlert(ctx context.Context, rule domain.AlertRule,
message := s.buildMessage(rule, obs) message := s.buildMessage(rule, obs)
txHash := &obs.Hash txHash := &obs.Hash
_, err = s.alertEvents.Create(ctx, rule.ID, message, &addressLabel, txHash) event, err := s.alertEvents.Create(ctx, rule.ID, message, &addressLabel, txHash)
if err != nil { if err != nil {
return err return err
} }
if event == nil {
log.Printf("[ALERT DEDUP] Rule %d (%s) - duplicate event skipped for TX: %s", rule.ID, rule.Type, obs.Hash)
return nil
}
log.Printf("[ALERT FIRED] Rule %d (%s) - %s - TX: %s", rule.ID, rule.Type, message, obs.Hash) log.Printf("[ALERT FIRED] Rule %d (%s) - %s - TX: %s", rule.ID, rule.Type, message, obs.Hash)
if addr != nil { if addr != nil {
go s.sendNotifications(ctx, addr.UserID, message, obs, addressLabel, rule, addr.Address) userID := addr.UserID
address := addr.Address
go func() {
notifCtx, cancel := context.WithTimeout(context.Background(), notificationTimeout)
defer cancel()
s.sendNotification(notifCtx, userID, message, obs, addressLabel, rule, address)
}()
} }
return nil return nil
} }
func (s *EvaluatorService) sendNotifications(ctx context.Context, userID, message string, obs domain.ObservedTx, addressLabel string, rule domain.AlertRule, address string) { 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 {
log.Printf("Notification attempt %d/%d failed: %v", attempt+1, notificationMaxRetries, err)
lastErr = err
continue
}
return nil
}
return lastErr
}
func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message string, obs domain.ObservedTx, addressLabel string, rule domain.AlertRule, address string) {
notifConfig, err := s.notifConfigs.GetConfig(ctx, userID) notifConfig, err := s.notifConfigs.GetConfig(ctx, userID)
if err != nil { if err != nil {
log.Printf("Failed to get notification config: %v", err) log.Printf("Failed to get notification config: %v", err)
@@ -196,35 +271,20 @@ func (s *EvaluatorService) sendNotifications(ctx context.Context, userID, messag
Address: address, Address: address,
} }
if notifConfig.DiscordWebhookURL != nil && *notifConfig.DiscordWebhookURL != "" { for _, n := range s.buildNotifiers(notifConfig) {
sent, err := notifications.SendDiscordNotification(*notifConfig.DiscordWebhookURL, message, meta) if err := sendWithRetry(ctx, n, message, meta); err != nil {
if err != nil || !sent { log.Printf("Notification channel failed for user %s after retries: %v", userID, err)
log.Printf("Discord notification failed for user %s: %v", userID, err)
} else { } else {
log.Printf("Discord notification sent to user %s", userID) log.Printf("Notification sent to user %s via %T", userID, n)
}
}
if notifConfig.SlackWebhookURL != nil && *notifConfig.SlackWebhookURL != "" {
sent, err := notifications.SendSlackNotification(*notifConfig.SlackWebhookURL, message, meta)
if err != nil || !sent {
log.Printf("Slack notification failed for user %s: %v", userID, err)
} else {
log.Printf("Slack notification sent to user %s", userID)
}
}
if notifConfig.Email != nil && *notifConfig.Email != "" && s.smtpConfig.IsConfigured() {
sent, err := notifications.SendEmailNotification(s.smtpConfig, *notifConfig.Email, message, meta)
if err != nil || !sent {
log.Printf("Email notification failed for user %s: %v", userID, err)
} else {
log.Printf("Email notification sent to user %s (%s)", userID, *notifConfig.Email)
} }
} }
} }
func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.ObservedTx) string { func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.ObservedTx) string {
if obs.IsTokenTransfer() {
return s.buildTokenMessage(rule, obs)
}
switch rule.Type { switch rule.Type {
case domain.AlertIncomingTx: case domain.AlertIncomingTx:
ethStr, _ := wei.FormatAsEth(obs.Value, 4) ethStr, _ := wei.FormatAsEth(obs.Value, 4)
@@ -249,3 +309,36 @@ func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.Observ
return "Alert triggered" return "Alert triggered"
} }
} }
const defaultTokenDecimals = 18
func (s *EvaluatorService) buildTokenMessage(rule domain.AlertRule, obs domain.ObservedTx) string {
symbol := "tokens"
if obs.TokenSymbol != nil {
symbol = *obs.TokenSymbol
}
amount := "unknown"
if obs.TokenValue != nil {
decimals := defaultTokenDecimals
if obs.TokenDecimals != nil {
decimals = *obs.TokenDecimals
}
amount = wei.FormatTokenAmount(*obs.TokenValue, decimals)
}
switch rule.Type {
case domain.AlertIncomingTx:
return fmt.Sprintf("Incoming transfer: %s %s received", amount, symbol)
case domain.AlertOutgoingTx:
return fmt.Sprintf("Outgoing transfer: %s %s sent", amount, symbol)
case domain.AlertLargeTransfer:
threshold := float64(0)
if rule.Threshold != nil {
threshold = *rule.Threshold
}
return fmt.Sprintf("Large token transfer: %s %s (threshold: %g)", amount, symbol, threshold)
default:
return fmt.Sprintf("Token transfer: %s %s", amount, symbol)
}
}

View File

@@ -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 { if _, err := s.checkpoint.UpdateLastCheckedBlock(ctx, addr.ID, endBlock); err != nil {
return nil, err return nil, err
} }

View File

@@ -1,113 +0,0 @@
package services
import (
"testing"
"github.com/kjannette/koin-ping/backend-go/internal/domain"
)
func TestFilterRelevantTransactions(t *testing.T) {
to1 := "0xaaaa"
to2 := "0xbbbb"
to3 := "0xcccc"
txs := []domain.NormalizedTx{
{Hash: "0x1", From: "0xaaaa", To: &to2, Value: "1000"},
{Hash: "0x2", From: "0xbbbb", To: &to1, Value: "2000"},
{Hash: "0x3", From: "0xcccc", To: &to3, Value: "3000"},
{Hash: "0x4", From: "0xdddd", To: nil, Value: "0"},
}
t.Run("finds outgoing", func(t *testing.T) {
result := filterRelevantTransactions(txs, "0xAAAA")
if len(result) != 2 {
t.Fatalf("expected 2 relevant txs (1 from, 1 to), got %d", len(result))
}
})
t.Run("finds incoming", func(t *testing.T) {
result := filterRelevantTransactions(txs, "0xBBBB")
if len(result) != 2 {
t.Fatalf("expected 2 relevant txs, got %d", len(result))
}
})
t.Run("no match", func(t *testing.T) {
result := filterRelevantTransactions(txs, "0x9999")
if len(result) != 0 {
t.Fatalf("expected 0 relevant txs, got %d", len(result))
}
})
t.Run("case insensitive", func(t *testing.T) {
result := filterRelevantTransactions(txs, "0xAAAA")
if len(result) == 0 {
t.Fatal("should match case-insensitively")
}
})
}
func TestCreateObservedTx(t *testing.T) {
to := "0xaaaa"
tx := domain.NormalizedTx{
Hash: "0xhash",
From: "0xbbbb",
To: &to,
Value: "1000",
}
t.Run("incoming direction", func(t *testing.T) {
addr := domain.Address{ID: 1, Address: "0xaaaa"}
obs := createObservedTx(tx, addr)
if obs.Direction != domain.DirectionIncoming {
t.Errorf("expected incoming, got %s", obs.Direction)
}
if obs.AddressID != 1 {
t.Errorf("expected address ID 1, got %d", obs.AddressID)
}
})
t.Run("outgoing direction", func(t *testing.T) {
addr := domain.Address{ID: 2, Address: "0xbbbb"}
obs := createObservedTx(tx, addr)
if obs.Direction != domain.DirectionOutgoing {
t.Errorf("expected outgoing, got %s", obs.Direction)
}
})
}
func TestGetStartBlock(t *testing.T) {
s := &ObserverService{}
t.Run("no checkpoint uses latest", func(t *testing.T) {
got := s.getStartBlock(0, false, 1000)
if got != 1000 {
t.Errorf("expected 1000, got %d", got)
}
})
t.Run("with checkpoint uses next block", func(t *testing.T) {
got := s.getStartBlock(999, true, 1000)
if got != 1000 {
t.Errorf("expected 1000, got %d", got)
}
})
}
func TestGetEndBlock(t *testing.T) {
s := &ObserverService{}
t.Run("caps at latest", func(t *testing.T) {
got := s.getEndBlock(990, 1000)
if got != 1000 {
t.Errorf("expected 1000, got %d", got)
}
})
t.Run("caps at maxBlocksPerRun", func(t *testing.T) {
got := s.getEndBlock(0, 200)
if got != maxBlocksPerRun-1 {
t.Errorf("expected %d, got %d", maxBlocksPerRun-1, got)
}
})
}

View File

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

View File

@@ -1,179 +0,0 @@
package wei
import (
"math"
"testing"
)
func TestToEth(t *testing.T) {
tests := []struct {
name string
wei string
want float64
wantErr bool
}{
{"zero string", "0", 0, false},
{"empty string", "", 0, false},
{"1 ETH", "1000000000000000000", 1.0, false},
{"0.5 ETH", "500000000000000000", 0.5, false},
{"10 ETH", "10000000000000000000", 10.0, false},
{"small amount", "1000000000000000", 0.001, false},
{"1 Wei", "1", 1e-18, false},
{"invalid", "notanumber", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ToEth(tt.wei)
if tt.wantErr {
if err == nil {
t.Fatal("expected error")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if math.Abs(got-tt.want) > 1e-10 {
t.Fatalf("ToEth(%q) = %v, want %v", tt.wei, got, tt.want)
}
})
}
}
func TestFromEth(t *testing.T) {
tests := []struct {
name string
eth float64
want string
wantErr bool
}{
{"zero", 0, "0", false},
{"1 ETH", 1.0, "1000000000000000000", false},
{"0.5 ETH", 0.5, "500000000000000000", false},
{"10 ETH", 10.0, "10000000000000000000", false},
{"0.001 ETH", 0.001, "1000000000000000", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := FromEth(tt.eth)
if tt.wantErr {
if err == nil {
t.Fatal("expected error")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Fatalf("FromEth(%v) = %q, want %q", tt.eth, got, tt.want)
}
})
}
}
func TestRoundTrip(t *testing.T) {
values := []float64{0.001, 0.1, 1.0, 5.5, 10.0, 100.0}
for _, eth := range values {
weiStr, err := FromEth(eth)
if err != nil {
t.Fatalf("FromEth(%v) error: %v", eth, err)
}
back, err := ToEth(weiStr)
if err != nil {
t.Fatalf("ToEth(%q) error: %v", weiStr, err)
}
if math.Abs(back-eth) > 1e-10 {
t.Fatalf("round-trip %v → %q → %v (diff: %v)", eth, weiStr, back, back-eth)
}
}
}
func TestCompare(t *testing.T) {
tests := []struct {
name string
a, b string
want int
}{
{"equal", "1000", "1000", 0},
{"a > b", "2000", "1000", 1},
{"a < b", "1000", "2000", -1},
{"zero equal", "0", "0", 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Compare(tt.a, tt.b)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Fatalf("Compare(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
}
})
}
}
func TestGreaterThanOrEqual(t *testing.T) {
ok, err := GreaterThanOrEqual("2000", "1000")
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatal("expected true")
}
ok, err = GreaterThanOrEqual("1000", "1000")
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatal("expected true for equal")
}
ok, err = GreaterThanOrEqual("500", "1000")
if err != nil {
t.Fatal(err)
}
if ok {
t.Fatal("expected false")
}
}
func TestLessThan(t *testing.T) {
ok, err := LessThan("500", "1000")
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatal("expected true")
}
ok, err = LessThan("1000", "1000")
if err != nil {
t.Fatal(err)
}
if ok {
t.Fatal("expected false for equal")
}
}
func TestFormatAsEth(t *testing.T) {
got, err := FormatAsEth("1000000000000000000", 4)
if err != nil {
t.Fatal(err)
}
if got != "1.0000 ETH" {
t.Fatalf("got %q, want %q", got, "1.0000 ETH")
}
got, err = FormatAsEth("500000000000000000", 2)
if err != nil {
t.Fatal(err)
}
if got != "0.50 ETH" {
t.Fatalf("got %q, want %q", got, "0.50 ETH")
}
}

View File

@@ -5,16 +5,19 @@ React + Vite frontend for the on-chain monitoring and alerting system.
## Getting Started ## Getting Started
1. Install dependencies: 1. Install dependencies:
```bash ```bash
npm install npm install
``` ```
2. Run the development server: 2. Run the development server:
```bash ```bash
npm run dev npm run dev
``` ```
3. Build for production: 3. Build for production:
```bash ```bash
npm run build npm run build
``` ```
@@ -34,5 +37,5 @@ React + Vite frontend for the on-chain monitoring and alerting system.
## Development ## Development
The frontend runs on port 3000 and proxies API requests to the backend running 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": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2", "@vitejs/plugin-react": "^5.1.2",
"prettier": "^3.8.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.3.0" "vite": "^7.3.0"
} }
@@ -2412,6 +2413,22 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/protobufjs": {
"version": "7.5.4", "version": "7.5.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",

View File

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

4
frontend/prettierrc Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,16 @@
// API client for alert rule management // API client for alert rule management
import { getAuthHeaders, getAuthHeadersSimple } from './authHeaders'; import { getAuthHeaders, getAuthHeadersSimple } from "./authHeaders";
import { API_BASE } from './config'; import { API_BASE } from "./config";
/** /**
* Alert types supported by the system * Alert types supported by the system
*/ */
export const ALERT_TYPES = { export const ALERT_TYPES = {
INCOMING_TX: 'incoming_tx', INCOMING_TX: "incoming_tx",
OUTGOING_TX: 'outgoing_tx', OUTGOING_TX: "outgoing_tx",
LARGE_TRANSFER: 'large_transfer', LARGE_TRANSFER: "large_transfer",
BALANCE_BELOW: 'balance_below' BALANCE_BELOW: "balance_below",
}; };
/** /**
@@ -24,14 +24,17 @@ export const ALERT_TYPES = {
export async function createAlert(addressId, data) { export async function createAlert(addressId, data) {
try { try {
const headers = await getAuthHeaders(); const headers = await getAuthHeaders();
const response = await fetch(`${API_BASE}/addresses/${addressId}/alerts`, { const response = await fetch(
method: 'POST', `${API_BASE}/addresses/${addressId}/alerts`,
{
method: "POST",
headers: headers, headers: headers,
body: JSON.stringify(data) body: JSON.stringify(data),
}); },
);
if (!response.ok) { if (!response.ok) {
let errorMessage = 'Failed to create alert rule'; let errorMessage = "Failed to create alert rule";
try { try {
const error = await response.json(); const error = await response.json();
errorMessage = error.message || errorMessage; errorMessage = error.message || errorMessage;
@@ -43,8 +46,10 @@ export async function createAlert(addressId, data) {
return response.json(); return response.json();
} catch (error) { } catch (error) {
if (error.message.includes('fetch')) { if (error.message.includes("fetch")) {
throw new Error('Cannot connect to server. Is the backend running?'); throw new Error(
"Cannot connect to server. Is the backend running?",
);
} }
throw error; throw error;
} }
@@ -58,12 +63,15 @@ export async function createAlert(addressId, data) {
export async function getAlerts(addressId) { export async function getAlerts(addressId) {
try { try {
const headers = await getAuthHeadersSimple(); const headers = await getAuthHeadersSimple();
const response = await fetch(`${API_BASE}/addresses/${addressId}/alerts`, { const response = await fetch(
headers: headers `${API_BASE}/addresses/${addressId}/alerts`,
}); {
headers: headers,
},
);
if (!response.ok) { if (!response.ok) {
let errorMessage = 'Failed to fetch alert rules'; let errorMessage = "Failed to fetch alert rules";
try { try {
const error = await response.json(); const error = await response.json();
errorMessage = error.message || errorMessage; errorMessage = error.message || errorMessage;
@@ -75,8 +83,10 @@ export async function getAlerts(addressId) {
return response.json(); return response.json();
} catch (error) { } catch (error) {
if (error.message.includes('fetch')) { if (error.message.includes("fetch")) {
throw new Error('Cannot connect to server. Is the backend running?'); throw new Error(
"Cannot connect to server. Is the backend running?",
);
} }
throw error; throw error;
} }
@@ -92,13 +102,13 @@ export async function updateAlertStatus(alertId, enabled) {
try { try {
const headers = await getAuthHeaders(); const headers = await getAuthHeaders();
const response = await fetch(`${API_BASE}/alerts/${alertId}`, { const response = await fetch(`${API_BASE}/alerts/${alertId}`, {
method: 'PATCH', method: "PATCH",
headers: headers, headers: headers,
body: JSON.stringify({ enabled }) body: JSON.stringify({ enabled }),
}); });
if (!response.ok) { if (!response.ok) {
let errorMessage = 'Failed to update alert rule'; let errorMessage = "Failed to update alert rule";
try { try {
const error = await response.json(); const error = await response.json();
errorMessage = error.message || errorMessage; errorMessage = error.message || errorMessage;
@@ -110,8 +120,10 @@ export async function updateAlertStatus(alertId, enabled) {
return response.json(); return response.json();
} catch (error) { } catch (error) {
if (error.message.includes('fetch')) { if (error.message.includes("fetch")) {
throw new Error('Cannot connect to server. Is the backend running?'); throw new Error(
"Cannot connect to server. Is the backend running?",
);
} }
throw error; throw error;
} }
@@ -126,12 +138,12 @@ export async function deleteAlert(alertId) {
try { try {
const headers = await getAuthHeadersSimple(); const headers = await getAuthHeadersSimple();
const response = await fetch(`${API_BASE}/alerts/${alertId}`, { const response = await fetch(`${API_BASE}/alerts/${alertId}`, {
method: 'DELETE', method: "DELETE",
headers: headers headers: headers,
}); });
if (!response.ok && response.status !== 204) { if (!response.ok && response.status !== 204) {
let errorMessage = 'Failed to delete alert rule'; let errorMessage = "Failed to delete alert rule";
try { try {
const error = await response.json(); const error = await response.json();
errorMessage = error.message || errorMessage; errorMessage = error.message || errorMessage;
@@ -141,8 +153,10 @@ export async function deleteAlert(alertId) {
throw new Error(errorMessage); throw new Error(errorMessage);
} }
} catch (error) { } catch (error) {
if (error.message.includes('fetch')) { if (error.message.includes("fetch")) {
throw new Error('Cannot connect to server. Is the backend running?'); throw new Error(
"Cannot connect to server. Is the backend running?",
);
} }
throw error; throw error;
} }

View File

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

View File

@@ -45,8 +45,8 @@ export default function AlertForm({ onSubmit }) {
value={opt.value} value={opt.value}
checked={type === opt.value} checked={type === opt.value}
onChange={() => setType(opt.value)} onChange={() => setType(opt.value)}
/> />{" "}
{" "}{opt.label} {opt.label}
</label> </label>
))} ))}
</div> </div>

View File

@@ -3,7 +3,7 @@ export default function Button({
onClick, onClick,
disabled = false, disabled = false,
type = "button", type = "button",
}) { }) {
return ( return (
<button <button
type={type} type={type}
@@ -19,5 +19,4 @@ export default function Button({
{children} {children}
</button> </button>
); );
} }

View File

@@ -7,7 +7,7 @@ export default function Input({
step, step,
min, min,
disabled = false, disabled = false,
}) { }) {
return ( return (
<label style={{ display: "block", marginBottom: "1rem" }}> <label style={{ display: "block", marginBottom: "1rem" }}>
<div style={{ marginBottom: "0.25rem", fontSize: "0.9rem" }}> <div style={{ marginBottom: "0.25rem", fontSize: "0.9rem" }}>
@@ -29,5 +29,4 @@ export default function Input({
/> />
</label> </label>
); );
} }

View File

@@ -0,0 +1,85 @@
import { Link, useLocation } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
const navLinks = [
{ to: "/addresses", label: "Addresses" },
{ to: "/alerts", label: "Configure Alerts" },
{ to: "/alertevents", label: "Alert Events" },
];
export default function Navbar() {
const { currentUser, logout } = useAuth();
const location = useLocation();
if (!currentUser) return null;
return (
<nav style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "1rem 2rem",
marginBottom: "1.5rem",
backgroundColor: "#1a1a1a",
borderBottom: "1px solid #333",
}}>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<span style={{
fontWeight: 700,
fontSize: "1.1rem",
color: "#fff",
marginRight: "2rem",
letterSpacing: "0.5px",
}}>
Koin Ping
</span>
<div style={{ display: "flex", gap: "0.25rem" }}>
{navLinks.map(({ to, label }) => {
const isActive = location.pathname === to;
return (
<Link
key={to}
to={to}
style={{
padding: "0.5rem 1.25rem",
borderRadius: "6px",
textDecoration: "none",
fontSize: "1.425rem",
fontWeight: isActive ? 600 : 200,
color: isActive ? "#fff" : "#999",
backgroundColor: isActive ? "#333" : "transparent",
transition: "all 0.15s ease",
}}
>
{label}
</Link>
);
})}
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "1.25rem" }}>
<span style={{ fontSize: "1.275rem", color: "#777" }}>
{currentUser.email}
</span>
<button
onClick={logout}
style={{
padding: "0.4rem 1rem",
fontSize: "1.275rem",
fontWeight: 400,
backgroundColor: "transparent",
color: "#999",
border: "1px solid #444",
borderRadius: "6px",
cursor: "pointer",
transition: "all 0.15s ease",
}}
>
Logout
</button>
</div>
</nav>
);
}

View File

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

View File

@@ -1,6 +1,5 @@
import { initializeApp } from "firebase/app";
import { initializeApp } from 'firebase/app'; import { getAuth } from "firebase/auth";
import { getAuth } from 'firebase/auth';
import { import {
firebaseApiKey, firebaseApiKey,
firebaseAuthDomain, firebaseAuthDomain,
@@ -8,9 +7,8 @@ import {
firebaseStorageBucket, firebaseStorageBucket,
firebaseMessagingSenderId, firebaseMessagingSenderId,
firebaseAppId, firebaseAppId,
firebaseMeasurementId firebaseMeasurementId,
} from '../secrets.js'; } from "../secrets.js";
const firebaseConfig = { const firebaseConfig = {
apiKey: firebaseApiKey, apiKey: firebaseApiKey,
@@ -19,12 +17,11 @@ const firebaseConfig = {
storageBucket: firebaseStorageBucket, storageBucket: firebaseStorageBucket,
messagingSenderId: firebaseMessagingSenderId, messagingSenderId: firebaseMessagingSenderId,
appId: firebaseAppId, appId: firebaseAppId,
measurementId: firebaseMeasurementId measurementId: firebaseMeasurementId,
}; };
const app = initializeApp(firebaseConfig); const app = initializeApp(firebaseConfig);
export const auth = getAuth(app); export const auth = getAuth(app);
export default app; export default app;

33
frontend/src/index.css Normal file
View File

@@ -0,0 +1,33 @@
@import url('https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap');
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 14px;
}
body {
font-family: 'Nunito', sans-serif;
font-size: 1.5rem;
font-weight: 200;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3, h4, h5, h6 {
font-family: 'Nunito', sans-serif;
font-weight: 200;
}
input, select, textarea, button {
font-family: 'Nunito', sans-serif;
font-size: 1rem;
font-weight: 200;
}

View File

@@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import { AuthProvider } from "./contexts/AuthContext"; import { AuthProvider } from "./contexts/AuthContext";
import App from "./App"; import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")).render( ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode> <React.StrictMode>
@@ -11,5 +12,5 @@ ReactDOM.createRoot(document.getElementById("root")).render(
<App /> <App />
</AuthProvider> </AuthProvider>
</BrowserRouter> </BrowserRouter>
</React.StrictMode> </React.StrictMode>,
); );

View File

@@ -1,14 +1,15 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import AddressForm from "../components/AddressForm"; import AddressForm from "../components/AddressForm";
import Button from "../components/Button"; import { getAddresses, createAddress, deleteAddress, updateAddress } from "../api/addresses";
import { getAddresses, createAddress, deleteAddress } from "../api/addresses";
export default function Addresses() { export default function Addresses() {
const [addresses, setAddresses] = useState([]); const [addresses, setAddresses] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [deletingId, setDeletingId] = useState(null); const [editingId, setEditingId] = useState(null);
const [editLabel, setEditLabel] = useState("");
// Load addresses on mount
useEffect(() => { useEffect(() => {
async function fetchAddresses() { async function fetchAddresses() {
try { try {
@@ -26,6 +27,7 @@ export default function Addresses() {
fetchAddresses(); fetchAddresses();
}, []); }, []);
// Handle new address submission
async function handleAddressSubmit(data) { async function handleAddressSubmit(data) {
try { try {
const newAddress = await createAddress(data); const newAddress = await createAddress(data);
@@ -37,24 +39,44 @@ export default function Addresses() {
} }
} }
async function handleDelete(addressId) { async function handleDelete(id, label) {
if (!window.confirm("Delete this address? All associated alert rules and events will also be removed.")) { const displayName = label || "this address";
if (!window.confirm(`Remove "${displayName}"? This will also delete all associated alert rules.`)) {
return; return;
} }
try { try {
setDeletingId(addressId); await deleteAddress(id);
await deleteAddress(addressId); setAddresses((prev) => prev.filter((a) => a.id !== id));
setAddresses((prev) => prev.filter((a) => a.id !== addressId));
setError(null); setError(null);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
console.error("Failed to delete address:", err); console.error("Failed to delete address:", err);
} finally {
setDeletingId(null);
} }
} }
function handleEditStart(addr) {
setEditingId(addr.id);
setEditLabel(addr.label ?? "");
}
async function handleEditSave(id) {
try {
const updated = await updateAddress(id, { label: editLabel || null });
setAddresses((prev) => prev.map((a) => (a.id === id ? updated : a)));
setEditingId(null);
setEditLabel("");
setError(null);
} catch (err) {
setError(err.message);
console.error("Failed to update address:", err);
}
}
function handleEditCancel() {
setEditingId(null);
setEditLabel("");
}
return ( return (
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}> <div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}>
<h1>Tracked Addresses</h1> <h1>Tracked Addresses</h1>
@@ -68,7 +90,7 @@ export default function Addresses() {
{loading && <p>Loading addresses...</p>} {loading && <p>Loading addresses...</p>}
{error && <p style={{ color: "red" }}>Error: {error}</p>} {error && <p style={{ color: "red" }}>Error: {error}</p>}
{!loading && !error && addresses.length === 0 && ( {!loading && !error && addresses.length === 0 && (
<p style={{ color: "#666" }}> <p style={{ color: "#808080" }}>
No addresses tracked yet. Add one above to get started. No addresses tracked yet. Add one above to get started.
</p> </p>
)} )}
@@ -82,32 +104,92 @@ export default function Addresses() {
marginBottom: "0.5rem", marginBottom: "0.5rem",
border: "1px solid #444", border: "1px solid #444",
borderRadius: "4px", borderRadius: "4px",
backgroundColor: "#2a2a2a", backgroundColor: "#333",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}} }}
> >
<div> <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div style={{ fontWeight: "bold", marginBottom: "0.25rem" }}> <div style={{ flex: 1 }}>
{addr.label || "Unlabeled"} {editingId === addr.id ? (
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", marginBottom: "0.25rem" }}>
<input
value={editLabel}
onChange={(e) => setEditLabel(e.target.value)}
placeholder="Label (optional)"
style={{
background: "#444",
border: "1px solid #666",
borderRadius: "3px",
color: "#fff",
padding: "0.25rem 0.5rem",
fontSize: "0.9rem",
}}
onKeyDown={(e) => {
if (e.key === "Enter") handleEditSave(addr.id);
if (e.key === "Escape") handleEditCancel();
}}
autoFocus
/>
<button
onClick={() => handleEditSave(addr.id)}
style={{ cursor: "pointer", padding: "0.25rem 0.6rem", fontSize: "0.85rem" }}
>
Save
</button>
<button
onClick={handleEditCancel}
style={{ cursor: "pointer", padding: "0.25rem 0.6rem", fontSize: "0.85rem", background: "transparent", color: "#aaa", border: "1px solid #555" }}
>
Cancel
</button>
</div> </div>
<div style={{ fontFamily: "monospace", fontSize: "0.9rem", color: "#999" }}> ) : (
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.25rem" }}>
<span style={{ fontWeight: "bold" }}>
{addr.label || "Unlabeled"}
</span>
<button
onClick={() => handleEditStart(addr)}
style={{
cursor: "pointer",
background: "transparent",
border: "none",
color: "#6699cc",
fontSize: "0.8rem",
padding: "0",
textDecoration: "underline",
}}
>
Edit
</button>
</div>
)}
<div
style={{
fontFamily: "monospace",
fontSize: "1.035rem",
color: "#b3b3b3",
}}
>
{addr.address} {addr.address}
</div> </div>
</div> </div>
<Button <button
onClick={() => handleDelete(addr.id)} onClick={() => handleDelete(addr.id, addr.label)}
disabled={deletingId === addr.id}
style={{ style={{
backgroundColor: deletingId === addr.id ? "#333" : "#dc3545", cursor: "pointer",
color: "white", background: "transparent",
border: "none", border: "1px solid #884444",
cursor: deletingId === addr.id ? "not-allowed" : "pointer", color: "#cc6666",
borderRadius: "3px",
padding: "0.3rem 0.7rem",
fontSize: "0.85rem",
marginLeft: "1rem",
flexShrink: 0,
}} }}
> >
{deletingId === addr.id ? "Deleting..." : "Delete"} Remove
</Button> </button>
</div>
</li> </li>
))} ))}
</ul> </ul>

View File

@@ -29,15 +29,17 @@ export default function AlertHistory() {
} }
if (error) { if (error) {
return <div style={{ padding: "2rem", color: "red" }}>Error: {error}</div>; return (
<div style={{ padding: "2rem", color: "red" }}>Error: {error}</div>
);
} }
return ( return (
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}> <div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}>
<h1>Recent Alerts</h1> <h1>Recent Alert Events</h1>
{alertEvents.length === 0 ? ( {alertEvents.length === 0 ? (
<p style={{ color: "#666" }}>No alerts yet</p> <p style={{ color: "#808080" }}>No alerts yet</p>
) : ( ) : (
<ul style={{ listStyle: "none", padding: 0 }}> <ul style={{ listStyle: "none", padding: 0 }}>
{alertEvents.map((event) => ( {alertEvents.map((event) => (
@@ -46,17 +48,26 @@ export default function AlertHistory() {
style={{ style={{
padding: "1rem", padding: "1rem",
marginBottom: "0.75rem", marginBottom: "0.75rem",
border: "1px solid #ddd", border: "1px solid #444",
borderRadius: "4px", borderRadius: "4px",
backgroundColor: "#333",
}} }}
> >
<div style={{ marginBottom: "0.5rem" }}>{event.message}</div> <div style={{ marginBottom: "0.5rem" }}>
{event.message}
</div>
{event.address_label && ( {event.address_label && (
<div style={{ fontSize: "0.9rem", color: "#666", marginBottom: "0.25rem" }}> <div
style={{
fontSize: "1.035rem",
color: "#808080",
marginBottom: "0.25rem",
}}
>
Address: {event.address_label} Address: {event.address_label}
</div> </div>
)} )}
<small style={{ color: "#999" }}> <small style={{ color: "#b3b3b3" }}>
{formatTimestamp(event.timestamp)} {formatTimestamp(event.timestamp)}
</small> </small>
</li> </li>

View File

@@ -2,14 +2,49 @@ import { useState, useEffect } from "react";
import AlertForm from "../components/AlertForm"; import AlertForm from "../components/AlertForm";
import Button from "../components/Button"; import Button from "../components/Button";
import { getAddresses } from "../api/addresses"; import { getAddresses } from "../api/addresses";
import { getAlerts, createAlert, updateAlertStatus, deleteAlert } from "../api/alerts"; import {
getAlerts,
createAlert,
updateAlertStatus,
deleteAlert,
} from "../api/alerts";
import { import {
getNotificationConfig, getNotificationConfig,
updateNotificationConfig, updateNotificationConfig,
testDiscordWebhook, testNotificationChannels,
testSlackWebhook, setupEmail,
sendEmailDigest,
} from "../api/notificationConfig"; } from "../api/notificationConfig";
const inputStyle = {
width: "100%",
padding: "0.5rem",
fontSize: "0.9rem",
backgroundColor: "#1a1a1a",
border: "1px solid #444",
borderRadius: "4px",
color: "white",
fontFamily: "monospace",
boxSizing: "border-box",
};
const labelStyle = {
display: "block",
marginBottom: "0.25rem",
fontSize: "0.9rem",
color: "#ccc",
};
const helpLinkStyle = { color: "#0066cc", fontSize: "0.85rem" };
const sectionStyle = {
marginBottom: "1.5rem",
padding: "1rem",
backgroundColor: "#2a2a2a",
borderRadius: "6px",
border: "1px solid #3a3a3a",
};
export default function Alerts() { export default function Alerts() {
const [addresses, setAddresses] = useState([]); const [addresses, setAddresses] = useState([]);
const [selectedAddressId, setSelectedAddressId] = useState(null); const [selectedAddressId, setSelectedAddressId] = useState(null);
@@ -17,16 +52,20 @@ export default function Alerts() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [notificationConfig, setNotificationConfig] = useState(null); // Notification config state
const [discordWebhookUrl, setDiscordWebhookUrl] = useState(''); const [notificationEnabled, setNotificationEnabled] = useState(false);
const [slackWebhookUrl, setSlackWebhookUrl] = useState(''); const [discordWebhookUrl, setDiscordWebhookUrl] = useState("");
const [emailAddress, setEmailAddress] = useState(''); const [telegramBotToken, setTelegramBotToken] = useState("");
const [notificationEnabled, setNotificationEnabled] = useState(true); const [telegramChatId, setTelegramChatId] = useState("");
const [email, setEmail] = useState("");
const [slackWebhookUrl, setSlackWebhookUrl] = useState("");
const [notificationLoading, setNotificationLoading] = useState(false); const [notificationLoading, setNotificationLoading] = useState(false);
const [notificationError, setNotificationError] = useState(null); const [notificationError, setNotificationError] = useState(null);
const [notificationSuccess, setNotificationSuccess] = useState(null); const [notificationSuccess, setNotificationSuccess] = useState(null);
const [testingDiscord, setTestingDiscord] = useState(false); const [testingChannels, setTestingChannels] = useState(false);
const [testingSlack, setTestingSlack] = useState(false); const [settingUpEmail, setSettingUpEmail] = useState(false);
const [sendingDigest, setSendingDigest] = useState(false);
const [hasExistingConfig, setHasExistingConfig] = useState(false);
useEffect(() => { useEffect(() => {
async function fetchData() { async function fetchData() {
@@ -40,11 +79,22 @@ export default function Alerts() {
} }
const configData = await getNotificationConfig(); const configData = await getNotificationConfig();
setNotificationConfig(configData); setNotificationEnabled(
setDiscordWebhookUrl(configData.discord_webhook_url || ''); configData.notification_enabled !== false,
setSlackWebhookUrl(configData.slack_webhook_url || ''); );
setEmailAddress(configData.email || ''); setDiscordWebhookUrl(configData.discord_webhook_url || "");
setNotificationEnabled(configData.notification_enabled !== false); setTelegramBotToken(configData.telegram_bot_token || "");
setTelegramChatId(configData.telegram_chat_id || "");
setEmail(configData.email || "");
setSlackWebhookUrl(configData.slack_webhook_url || "");
const hasSaved =
!!configData.discord_webhook_url ||
!!configData.telegram_bot_token ||
!!configData.telegram_chat_id ||
!!configData.email ||
!!configData.slack_webhook_url;
setHasExistingConfig(hasSaved);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
console.error("Failed to fetch data:", err); console.error("Failed to fetch data:", err);
@@ -93,7 +143,7 @@ export default function Alerts() {
try { try {
const updated = await updateAlertStatus(alertId, !currentStatus); const updated = await updateAlertStatus(alertId, !currentStatus);
setAlerts((prev) => setAlerts((prev) =>
prev.map((alert) => (alert.id === alertId ? updated : alert)) prev.map((alert) => (alert.id === alertId ? updated : alert)),
); );
setError(null); setError(null);
} catch (err) { } catch (err) {
@@ -114,22 +164,30 @@ export default function Alerts() {
} }
async function handleSaveNotificationConfig() { async function handleSaveNotificationConfig() {
if (hasExistingConfig) {
const confirmed = window.confirm(
"This will overwrite your previously saved notification settings. Continue?",
);
if (!confirmed) return;
}
try { try {
setNotificationLoading(true); setNotificationLoading(true);
setNotificationError(null); setNotificationError(null);
setNotificationSuccess(null); setNotificationSuccess(null);
const config = { const config = {
discord_webhook_url: discordWebhookUrl || null,
slack_webhook_url: slackWebhookUrl || null,
email: emailAddress || null,
notification_enabled: notificationEnabled, notification_enabled: notificationEnabled,
discord_webhook_url: discordWebhookUrl || null,
telegram_bot_token: telegramBotToken || null,
telegram_chat_id: telegramChatId || null,
email: email || null,
slack_webhook_url: slackWebhookUrl || null,
}; };
const updated = await updateNotificationConfig(config); await updateNotificationConfig(config);
setNotificationConfig(updated); setHasExistingConfig(true);
setNotificationSuccess('Notification settings saved!'); setNotificationSuccess("Notification settings saved!");
setTimeout(() => setNotificationSuccess(null), 3000); setTimeout(() => setNotificationSuccess(null), 3000);
} catch (err) { } catch (err) {
setNotificationError(err.message); setNotificationError(err.message);
@@ -139,55 +197,76 @@ export default function Alerts() {
} }
} }
async function handleTestDiscord() { async function handleTestChannels() {
if (!discordWebhookUrl) {
setNotificationError('Please enter a Discord webhook URL first');
return;
}
try { try {
setTestingDiscord(true); setTestingChannels(true);
setNotificationError(null); setNotificationError(null);
setNotificationSuccess(null); setNotificationSuccess(null);
const success = await testDiscordWebhook(discordWebhookUrl); const data = await testNotificationChannels();
const results = data.results || [];
if (success) { const failed = results.filter((r) => !r.success);
setNotificationSuccess('Test notification sent! Check your Discord channel.'); const succeeded = results.filter((r) => r.success);
setTimeout(() => setNotificationSuccess(null), 5000);
} else { if (failed.length === 0 && succeeded.length > 0) {
setNotificationError('Test failed. Check your webhook URL.'); setNotificationSuccess(
`Test sent to: ${succeeded.map((r) => r.channel).join(", ")}`,
);
} else if (failed.length > 0 && succeeded.length > 0) {
setNotificationSuccess(
`Sent: ${succeeded.map((r) => r.channel).join(", ")}. Failed: ${failed.map((r) => `${r.channel} (${r.error})`).join(", ")}`,
);
} else if (failed.length > 0) {
setNotificationError(
`Test failed: ${failed.map((r) => `${r.channel} (${r.error})`).join(", ")}`,
);
} }
setTimeout(() => {
setNotificationSuccess(null);
setNotificationError(null);
}, 6000);
} catch (err) { } catch (err) {
setNotificationError('Test failed: ' + err.message); setNotificationError(err.message);
} finally { } finally {
setTestingDiscord(false); setTestingChannels(false);
} }
} }
async function handleTestSlack() { async function handleSetupEmail() {
if (!slackWebhookUrl) {
setNotificationError('Please enter a Slack webhook URL first');
return;
}
try { try {
setTestingSlack(true); setSettingUpEmail(true);
setNotificationError(null); setNotificationError(null);
setNotificationSuccess(null); setNotificationSuccess(null);
const success = await testSlackWebhook(slackWebhookUrl); await handleSaveNotificationConfig();
if (success) { const result = await setupEmail();
setNotificationSuccess('Test notification sent! Check your Slack channel.'); setNotificationSuccess(
result.message || "Confirmation email sent!",
);
setTimeout(() => setNotificationSuccess(null), 5000); setTimeout(() => setNotificationSuccess(null), 5000);
} else {
setNotificationError('Test failed. Check your webhook URL.');
}
} catch (err) { } catch (err) {
setNotificationError('Test failed: ' + err.message); setNotificationError(err.message);
} finally { } finally {
setTestingSlack(false); setSettingUpEmail(false);
}
}
async function handleSendDigest() {
try {
setSendingDigest(true);
setNotificationError(null);
setNotificationSuccess(null);
const result = await sendEmailDigest();
setNotificationSuccess(result.message || "Digest email sent!");
setTimeout(() => setNotificationSuccess(null), 5000);
} catch (err) {
setNotificationError(err.message);
} finally {
setSendingDigest(false);
} }
} }
@@ -200,28 +279,43 @@ export default function Alerts() {
if (addresses.length === 0) { if (addresses.length === 0) {
return ( return (
<div style={{ padding: "2rem" }}> <div style={{ padding: "2rem" }}>
<p>No addresses tracked yet. Add an address first to create alerts.</p> <p>
No addresses tracked yet. Add an address first to create
alerts.
</p>
</div> </div>
); );
} }
return ( return (
<div style={{ maxWidth: "1400px", margin: "0 auto", padding: "2rem" }}> <div style={{ maxWidth: "1400px", margin: "0 auto", padding: "2rem" }}>
<h1 style={{ marginBottom: "2rem" }}>Alert Rules & Notifications</h1> <h1 style={{ marginBottom: "2rem" }}>
Alert Rules & Notifications
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "2rem" }}> </h1>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "2rem",
alignItems: "start",
}}
>
{/* LEFT COLUMN: Alert Rules */} {/* LEFT COLUMN: Alert Rules */}
<div> <div>
<h2 style={{ marginTop: 0 }}>Alert Rules</h2> <h2 style={{ marginTop: 0 }}>Alert Rules</h2>
<div style={{ marginBottom: "2rem" }}> <div style={{ marginBottom: "2rem" }}>
<label style={{ display: "block", marginBottom: "0.5rem" }}> <label
style={{ display: "block", marginBottom: "0.5rem" }}
>
<strong>Select Address:</strong> <strong>Select Address:</strong>
</label> </label>
<select <select
value={selectedAddressId || ""} value={selectedAddressId || ""}
onChange={(e) => setSelectedAddressId(Number(e.target.value))} onChange={(e) =>
setSelectedAddressId(Number(e.target.value))
}
style={{ style={{
width: "100%", width: "100%",
padding: "0.5rem", padding: "0.5rem",
@@ -229,7 +323,7 @@ export default function Alerts() {
backgroundColor: "#1a1a1a", backgroundColor: "#1a1a1a",
border: "1px solid #444", border: "1px solid #444",
borderRadius: "4px", borderRadius: "4px",
color: "white" color: "white",
}} }}
> >
{addresses.map((addr) => ( {addresses.map((addr) => (
@@ -246,18 +340,34 @@ export default function Alerts() {
style={{ style={{
padding: "1rem", padding: "1rem",
marginBottom: "2rem", marginBottom: "2rem",
backgroundColor: "#2a2a2a", backgroundColor: "#333",
borderRadius: "4px", borderRadius: "4px",
border: "1px solid #444" border: "1px solid #444",
}}
>
<div
style={{
fontSize: "1.035rem",
color: "#b3b3b3",
}} }}
> >
<div style={{ fontSize: "0.9rem", color: "#999" }}>
Managing alerts for: Managing alerts for:
</div> </div>
<div style={{ fontWeight: "bold", marginTop: "0.25rem" }}> <div
style={{
fontWeight: "bold",
marginTop: "0.25rem",
}}
>
{selectedAddress.label || "Unlabeled"} {selectedAddress.label || "Unlabeled"}
</div> </div>
<div style={{ fontFamily: "monospace", fontSize: "0.9rem", color: "#999" }}> <div
style={{
fontFamily: "monospace",
fontSize: "1.035rem",
color: "#b3b3b3",
}}
>
{selectedAddress.address} {selectedAddress.address}
</div> </div>
</div> </div>
@@ -269,13 +379,23 @@ export default function Alerts() {
<div> <div>
<h3>Active Alert Rules</h3> <h3>Active Alert Rules</h3>
{error && <p style={{ color: "red" }}>Error: {error}</p>} {error && (
<p style={{ color: "red" }}>
Error: {error}
</p>
)}
{alerts.length === 0 ? ( {alerts.length === 0 ? (
<p style={{ color: "#666" }}> <p style={{ color: "#666" }}>
No alert rules defined yet. Create one above. No alert rules defined yet. Create one
above.
</p> </p>
) : ( ) : (
<ul style={{ listStyle: "none", padding: 0 }}> <ul
style={{
listStyle: "none",
padding: 0,
}}
>
{alerts.map((alert) => ( {alerts.map((alert) => (
<li <li
key={alert.id} key={alert.id}
@@ -284,37 +404,89 @@ export default function Alerts() {
marginBottom: "0.5rem", marginBottom: "0.5rem",
border: "1px solid #444", border: "1px solid #444",
borderRadius: "4px", borderRadius: "4px",
backgroundColor: "#2a2a2a", backgroundColor: "#333",
opacity: alert.enabled ? 1 : 0.6, opacity: alert.enabled
? 1
: 0.6,
}} }}
> >
<div <div
style={{ style={{
display: "flex", display: "flex",
justifyContent: "space-between", justifyContent:
alignItems: "flex-start", "space-between",
alignItems:
"flex-start",
}} }}
> >
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={{ fontWeight: "bold", marginBottom: "0.25rem" }}> <div
{formatAlertType(alert.type)} style={{
fontWeight:
"bold",
marginBottom:
"0.25rem",
}}
>
{formatAlertType(
alert.type,
)}
</div> </div>
{alert.threshold && ( {alert.threshold && (
<div style={{ fontSize: "0.9rem", color: "#999" }}> <div
Threshold: {alert.threshold} ETH style={{
fontSize:
"1.035rem",
color: "#b3b3b3",
}}
>
Threshold:{" "}
{
alert.threshold
}{" "}
ETH
</div> </div>
)} )}
<div style={{ fontSize: "0.85rem", color: "#666", marginTop: "0.25rem" }}> <div
Status: {alert.enabled ? "Enabled" : "Disabled"} style={{
</div> fontSize:
</div> "0.978rem",
<div style={{ display: "flex", gap: "0.5rem" }}> color: "#808080",
<Button marginTop:
onClick={() => handleToggleAlert(alert.id, alert.enabled)} "0.25rem",
}}
> >
{alert.enabled ? "Disable" : "Enable"} Status:{" "}
{alert.enabled
? "Enabled"
: "Disabled"}
</div>
</div>
<div
style={{
display: "flex",
gap: "0.5rem",
}}
>
<Button
onClick={() =>
handleToggleAlert(
alert.id,
alert.enabled,
)
}
>
{alert.enabled
? "Disable"
: "Enable"}
</Button> </Button>
<Button onClick={() => handleDeleteAlert(alert.id)}> <Button
onClick={() =>
handleDeleteAlert(
alert.id,
)
}
>
Delete Delete
</Button> </Button>
</div> </div>
@@ -333,201 +505,327 @@ export default function Alerts() {
<h2 style={{ marginTop: 0 }}>Notification Settings</h2> <h2 style={{ marginTop: 0 }}>Notification Settings</h2>
{notificationSuccess && ( {notificationSuccess && (
<div style={{ <div
padding: '0.75rem', style={{
marginBottom: '1rem', padding: "0.75rem",
backgroundColor: '#00ff0020', marginBottom: "1rem",
border: '1px solid #00ff00', backgroundColor: "#00ff0020",
borderRadius: '4px', border: "1px solid #00ff00",
color: '#00ff00' borderRadius: "4px",
}}> color: "#00ff00",
}}
>
{notificationSuccess} {notificationSuccess}
</div> </div>
)} )}
{notificationError && ( {notificationError && (
<div style={{ <div
padding: '0.75rem', style={{
marginBottom: '1rem', padding: "0.75rem",
backgroundColor: '#ff000020', marginBottom: "1rem",
border: '1px solid #ff0000', backgroundColor: "#ff000020",
borderRadius: '4px', border: "1px solid #ff0000",
color: '#ff6666' borderRadius: "4px",
}}> color: "#ff6666",
}}
>
{notificationError} {notificationError}
</div> </div>
)} )}
{/* Master toggle */} {/* Master toggle */}
<div style={{ marginBottom: '2rem', padding: '1rem', backgroundColor: '#1a1a1a', borderRadius: '4px', border: '1px solid #444' }}> <div
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}> style={{
marginBottom: "1.5rem",
padding: "1rem",
backgroundColor: "#333",
borderRadius: "4px",
}}
>
<label
style={{
display: "flex",
alignItems: "center",
cursor: "pointer",
}}
>
<input <input
type="checkbox" type="checkbox"
checked={notificationEnabled} checked={notificationEnabled}
onChange={(e) => setNotificationEnabled(e.target.checked)} onChange={(e) =>
style={{ marginRight: '0.5rem', width: '18px', height: '18px' }} setNotificationEnabled(e.target.checked)
}
style={{
marginRight: "0.5rem",
width: "18px",
height: "18px",
}}
/> />
<span style={{ fontWeight: 'bold' }}>Enable Notifications</span> <span style={{ fontWeight: "bold" }}>
Enable Notifications
</span>
</label> </label>
<div style={{ fontSize: '0.85rem', color: '#666', marginTop: '0.5rem', marginLeft: '26px' }}> <div
style={{
fontSize: "0.978rem",
color: "#808080",
marginTop: "0.5rem",
marginLeft: "26px",
}}
>
Master switch for all notification channels Master switch for all notification channels
</div> </div>
</div> </div>
{/* Discord Section */} {/* All channel settings — hidden when master toggle is off */}
<div style={{ marginBottom: '2rem' }}> <div
<h3 style={{ marginBottom: '1rem' }}>Discord</h3> style={{
display: notificationEnabled ? "block" : "none",
}}
>
{/* Telegram */}
<div style={sectionStyle}>
<h3 style={{ marginTop: 0, marginBottom: "1rem" }}>
Telegram
</h3>
<div style={{ marginBottom: '1rem' }}> <div style={{ marginBottom: "0.75rem" }}>
<label style={{ display: 'block', marginBottom: '0.5rem' }}> <label style={labelStyle}>Bot Token</label>
Webhook URL <input
type="text"
value={telegramBotToken}
onChange={(e) =>
setTelegramBotToken(e.target.value)
}
placeholder="123456789:ABCdefGHIjklMNOpqrSTUvwxYZ"
style={inputStyle}
/>
</div>
<div style={{ marginBottom: "0.5rem" }}>
<label style={labelStyle}>Chat ID</label>
<input
type="text"
value={telegramChatId}
onChange={(e) =>
setTelegramChatId(e.target.value)
}
placeholder="-1001234567890"
style={inputStyle}
/>
</div>
<a
href="https://core.telegram.org/bots#how-do-i-create-a-bot"
target="_blank"
rel="noopener noreferrer"
style={helpLinkStyle}
>
How to create a Telegram bot & get your Chat ID
</a>
</div>
{/* Email */}
<div style={sectionStyle}>
<h3 style={{ marginTop: 0, marginBottom: "1rem" }}>
Email
</h3>
<div style={{ marginBottom: "0.75rem" }}>
<label style={labelStyle}>Email Address</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
style={inputStyle}
/>
</div>
<div
style={{
fontSize: "0.85rem",
color: "#808080",
marginBottom: "0.75rem",
}}
>
Alert notifications and digests will be sent to
this address
</div>
<div
style={{
display: "flex",
gap: "0.5rem",
}}
>
<button
onClick={handleSetupEmail}
disabled={settingUpEmail || !email}
style={{
padding: "0.5rem 1rem",
fontSize: "0.85rem",
backgroundColor:
settingUpEmail || !email
? "#333"
: "#0066cc",
color: "white",
border: "none",
borderRadius: "4px",
cursor:
settingUpEmail || !email
? "not-allowed"
: "pointer",
}}
>
{settingUpEmail
? "Setting up..."
: "Verify Email"}
</button>
<button
onClick={handleSendDigest}
disabled={sendingDigest || !email}
style={{
padding: "0.5rem 1rem",
fontSize: "0.85rem",
backgroundColor:
sendingDigest || !email
? "#333"
: "#6c757d",
color: "white",
border: "none",
borderRadius: "4px",
cursor:
sendingDigest || !email
? "not-allowed"
: "pointer",
}}
>
{sendingDigest
? "Sending..."
: "Send Digest Now"}
</button>
</div>
</div>
{/* Discord */}
<div style={sectionStyle}>
<h3 style={{ marginTop: 0, marginBottom: "1rem" }}>
Discord
</h3>
<div style={{ marginBottom: "0.5rem" }}>
<label style={labelStyle}>
Discord Webhook URL
</label> </label>
<input <input
type="text" type="text"
value={discordWebhookUrl} value={discordWebhookUrl}
onChange={(e) => setDiscordWebhookUrl(e.target.value)} onChange={(e) =>
setDiscordWebhookUrl(e.target.value)
}
placeholder="https://discord.com/api/webhooks/..." placeholder="https://discord.com/api/webhooks/..."
style={{ style={inputStyle}
width: '100%',
padding: '0.5rem',
backgroundColor: '#1a1a1a',
border: '1px solid #444',
borderRadius: '4px',
color: 'white',
fontFamily: 'monospace',
fontSize: '0.9rem'
}}
/> />
<div style={{ fontSize: '0.85rem', color: '#999', marginTop: '0.5rem' }}> </div>
<a <a
href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks" href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
style={{ color: '#0066cc' }} style={helpLinkStyle}
> >
How to get a Discord webhook URL How to get a Discord webhook URL
</a> </a>
</div> </div>
</div>
<button {/* Slack */}
onClick={handleTestDiscord} <div style={sectionStyle}>
disabled={testingDiscord || !discordWebhookUrl} <h3 style={{ marginTop: 0, marginBottom: "1rem" }}>
style={{ Slack
padding: '0.5rem 1rem', </h3>
fontSize: '0.9rem',
backgroundColor: testingDiscord || !discordWebhookUrl ? '#333' : '#28a745',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: testingDiscord || !discordWebhookUrl ? 'not-allowed' : 'pointer'
}}
>
{testingDiscord ? 'Testing...' : 'Test Webhook'}
</button>
</div>
{/* Slack Section */} <div style={{ marginBottom: "0.5rem" }}>
<div style={{ marginBottom: '2rem' }}> <label style={labelStyle}>
<h3 style={{ marginBottom: '1rem' }}>Slack</h3> Slack Webhook URL
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
Incoming Webhook URL
</label> </label>
<input <input
type="text" type="text"
value={slackWebhookUrl} value={slackWebhookUrl}
onChange={(e) => setSlackWebhookUrl(e.target.value)} onChange={(e) =>
setSlackWebhookUrl(e.target.value)
}
placeholder="https://hooks.slack.com/services/..." placeholder="https://hooks.slack.com/services/..."
style={{ style={inputStyle}
width: '100%',
padding: '0.5rem',
backgroundColor: '#1a1a1a',
border: '1px solid #444',
borderRadius: '4px',
color: 'white',
fontFamily: 'monospace',
fontSize: '0.9rem'
}}
/> />
<div style={{ fontSize: '0.85rem', color: '#999', marginTop: '0.5rem' }}> </div>
<a <a
href="https://api.slack.com/messaging/webhooks" href="https://api.slack.com/messaging/webhooks"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
style={{ color: '#0066cc' }} style={helpLinkStyle}
> >
How to set up Slack Incoming Webhooks How to set up Slack Incoming Webhooks
</a> </a>
</div> </div>
</div>
<button {/* Save & Test buttons at the bottom */}
onClick={handleTestSlack} <div
disabled={testingSlack || !slackWebhookUrl}
style={{ style={{
padding: '0.5rem 1rem', display: "flex",
fontSize: '0.9rem', gap: "0.75rem",
backgroundColor: testingSlack || !slackWebhookUrl ? '#333' : '#28a745', marginTop: "1rem",
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: testingSlack || !slackWebhookUrl ? 'not-allowed' : 'pointer'
}} }}
> >
{testingSlack ? 'Testing...' : 'Test Webhook'}
</button>
</div>
{/* Email Section */}
<div style={{ marginBottom: '2rem' }}>
<h3 style={{ marginBottom: '1rem' }}>Email</h3>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
Email Address
</label>
<input
type="email"
value={emailAddress}
onChange={(e) => setEmailAddress(e.target.value)}
placeholder="you@example.com"
style={{
width: '100%',
padding: '0.5rem',
backgroundColor: '#1a1a1a',
border: '1px solid #444',
borderRadius: '4px',
color: 'white',
fontSize: '0.9rem'
}}
/>
<div style={{ fontSize: '0.85rem', color: '#999', marginTop: '0.5rem' }}>
Requires SMTP to be configured on the server
</div>
</div>
</div>
{/* Save Button */}
<button <button
onClick={handleSaveNotificationConfig} onClick={handleSaveNotificationConfig}
disabled={notificationLoading} disabled={notificationLoading}
style={{ style={{
padding: '0.75rem 1.5rem', padding: "0.75rem 1.5rem",
fontSize: '1rem', fontSize: "1rem",
backgroundColor: notificationLoading ? '#333' : '#0066cc', backgroundColor: notificationLoading
color: 'white', ? "#333"
border: 'none', : "#0066cc",
borderRadius: '4px', color: "white",
cursor: notificationLoading ? 'not-allowed' : 'pointer', border: "none",
width: '100%', borderRadius: "4px",
cursor: notificationLoading
? "not-allowed"
: "pointer",
}} }}
> >
{notificationLoading ? 'Saving...' : 'Save All Notification Settings'} {notificationLoading
? "Saving..."
: "Save Settings"}
</button>
<button
onClick={handleTestChannels}
disabled={testingChannels}
style={{
padding: "0.75rem 1.5rem",
fontSize: "1rem",
backgroundColor: testingChannels
? "#333"
: "#28a745",
color: "white",
border: "none",
borderRadius: "4px",
cursor: testingChannels
? "not-allowed"
: "pointer",
}}
>
{testingChannels
? "Testing..."
: "Test All Channels"}
</button> </button>
</div> </div>
</div>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -4,14 +4,14 @@
* Allows existing users to sign in with email and password * Allows existing users to sign in with email and password
*/ */
import { useState } from 'react'; import { useState } from "react";
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from "react-router-dom";
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from "../contexts/AuthContext";
export default function Login() { export default function Login() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState("");
const [password, setPassword] = useState(''); const [password, setPassword] = useState("");
const [error, setError] = useState(''); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { login } = useAuth(); const { login } = useAuth();
@@ -22,50 +22,54 @@ export default function Login() {
// Validation // Validation
if (!email || !password) { if (!email || !password) {
setError('Please fill in all fields'); setError("Please fill in all fields");
return; return;
} }
try { try {
setError(''); setError("");
setLoading(true); setLoading(true);
await login(email, password); await login(email, password);
navigate('/addresses'); // Redirect to main app navigate("/addresses"); // Redirect to main app
} catch (err) { } catch (err) {
setError('Failed to log in: ' + err.message); setError("Failed to log in: " + err.message);
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }
return ( return (
<div style={{ <div
maxWidth: '400px', style={{
margin: '4rem auto', maxWidth: "400px",
padding: '2rem', margin: "4rem auto",
border: '1px solid #333', padding: "2rem",
borderRadius: '8px' border: "1px solid #333",
}}> borderRadius: "8px",
<h1 style={{ marginBottom: '2rem', textAlign: 'center' }}> }}
>
<h1 style={{ marginBottom: "2rem", textAlign: "center" }}>
Koin Ping - Login Koin Ping - Login
</h1> </h1>
{error && ( {error && (
<div style={{ <div
padding: '0.75rem', style={{
marginBottom: '1rem', padding: "0.75rem",
backgroundColor: '#ff000020', marginBottom: "1rem",
border: '1px solid #ff0000', backgroundColor: "#ff000020",
borderRadius: '4px', border: "1px solid #ff0000",
color: '#ff6666' borderRadius: "4px",
}}> color: "#ff6666",
}}
>
{error} {error}
</div> </div>
)} )}
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div style={{ marginBottom: '1rem' }}> <div style={{ marginBottom: "1rem" }}>
<label style={{ display: 'block', marginBottom: '0.5rem' }}> <label style={{ display: "block", marginBottom: "0.5rem" }}>
Email Email
</label> </label>
<input <input
@@ -74,20 +78,20 @@ export default function Login() {
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
disabled={loading} disabled={loading}
style={{ style={{
width: '100%', width: "100%",
padding: '0.5rem', padding: "0.5rem",
fontSize: '1rem', fontSize: "1rem",
backgroundColor: '#1a1a1a', backgroundColor: "#242424",
border: '1px solid #444', border: "1px solid #444",
borderRadius: '4px', borderRadius: "4px",
color: 'white' color: "white",
}} }}
required required
/> />
</div> </div>
<div style={{ marginBottom: '1.5rem' }}> <div style={{ marginBottom: "1.5rem" }}>
<label style={{ display: 'block', marginBottom: '0.5rem' }}> <label style={{ display: "block", marginBottom: "0.5rem" }}>
Password Password
</label> </label>
<input <input
@@ -96,13 +100,13 @@ export default function Login() {
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
disabled={loading} disabled={loading}
style={{ style={{
width: '100%', width: "100%",
padding: '0.5rem', padding: "0.5rem",
fontSize: '1rem', fontSize: "1rem",
backgroundColor: '#1a1a1a', backgroundColor: "#242424",
border: '1px solid #444', border: "1px solid #444",
borderRadius: '4px', borderRadius: "4px",
color: 'white' color: "white",
}} }}
required required
/> />
@@ -112,26 +116,26 @@ export default function Login() {
type="submit" type="submit"
disabled={loading} disabled={loading}
style={{ style={{
width: '100%', width: "100%",
padding: '0.75rem', padding: "0.75rem",
fontSize: '1rem', fontSize: "1rem",
backgroundColor: loading ? '#333' : '#0066cc', backgroundColor: loading ? "#333" : "#0066cc",
color: 'white', color: "white",
border: 'none', border: "none",
borderRadius: '4px', borderRadius: "4px",
cursor: loading ? 'not-allowed' : 'pointer' cursor: loading ? "not-allowed" : "pointer",
}} }}
> >
{loading ? 'Logging in...' : 'Log In'} {loading ? "Logging in..." : "Log In"}
</button> </button>
</form> </form>
<div style={{ marginTop: '1.5rem', textAlign: 'center' }}> <div style={{ marginTop: "1.5rem", textAlign: "center" }}>
<p style={{ color: '#999' }}> <p style={{ color: "#b3b3b3" }}>
Don't have an account?{' '} Don't have an account?{" "}
<Link <Link
to="/signup" to="/signup"
style={{ color: '#0066cc', textDecoration: 'none' }} style={{ color: "#0066cc", textDecoration: "none" }}
> >
Sign up here Sign up here
</Link> </Link>
@@ -140,4 +144,3 @@ export default function Login() {
</div> </div>
); );
} }

View File

@@ -0,0 +1,834 @@
/**
* Onboarding Wizard
*
* 5-step guided flow: Create Account → Add Wallet → Alert Rules → Notifications → Done
*/
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import { createAddress, getAddresses } from "../api/addresses";
import { createAlert } from "../api/alerts";
import {
updateNotificationConfig,
testNotificationChannels,
} from "../api/notificationConfig";
const STEPS = [
"Create Account",
"Add Wallet",
"Alert Rules",
"Notifications",
"Done",
];
const inputStyle = {
width: "100%",
padding: "0.5rem",
fontSize: "1rem",
backgroundColor: "#2a2a2a",
border: "1px solid #444",
borderRadius: "4px",
color: "white",
boxSizing: "border-box",
};
const labelStyle = {
display: "block",
marginBottom: "0.4rem",
color: "#ccc",
fontSize: "0.9rem",
};
export default function Onboarding() {
const { currentUser, signup } = useAuth();
const navigate = useNavigate();
const [step, setStep] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [skipWarning, setSkipWarning] = useState("");
const [testResults, setTestResults] = useState(null);
const [testLoading, setTestLoading] = useState(false);
// Wizard state
const [data, setData] = useState({
email: "",
password: "",
confirmPassword: "",
walletAddress: "",
walletLabel: "",
createdAddressId: null,
alertIncomingTx: false,
alertOutgoingTx: false,
alertLargeTransfer: false,
largeTransferThreshold: "",
alertBalanceBelow: false,
balanceBelowThreshold: "",
discordWebhookUrl: "",
slackWebhookUrl: "",
notificationEmail: "",
// summary
alertsCreated: [],
notificationConfigured: false,
});
function set(field, value) {
setData((prev) => ({ ...prev, [field]: value }));
}
// On mount: if already fully onboarded, redirect away
useEffect(() => {
if (!currentUser) return;
getAddresses()
.then((addresses) => {
if (addresses.length > 0) {
navigate("/addresses", { replace: true });
}
})
.catch(() => {}); // ignore errors (e.g. mid-signup)
}, [currentUser, navigate]);
// ── Step handlers ─────────────────────────────────────────────────────────
async function handleStep1() {
setError("");
if (!data.email || !data.password || !data.confirmPassword) {
setError("Please fill in all fields");
return;
}
if (data.password !== data.confirmPassword) {
setError("Passwords do not match");
return;
}
if (data.password.length < 6) {
setError("Password must be at least 6 characters");
return;
}
// If user already exists (browser-close-mid-wizard), skip signup
if (!currentUser) {
try {
setLoading(true);
await signup(data.email, data.password);
} catch (err) {
if (err.code === "auth/email-already-in-use") {
setError("Email already in use. Try logging in instead.");
} else if (err.code === "auth/invalid-email") {
setError("Invalid email address");
} else if (err.code === "auth/weak-password") {
setError("Password is too weak");
} else {
setError("Failed to create account: " + err.message);
}
setLoading(false);
return;
} finally {
setLoading(false);
}
}
setStep(2);
}
async function handleStep2() {
setError("");
if (!data.walletAddress) {
setError("Please enter a wallet address");
return;
}
if (!/^0x[0-9a-fA-F]{40}$/.test(data.walletAddress)) {
setError("Invalid ETH address (must be 0x followed by 40 hex characters)");
return;
}
try {
setLoading(true);
const created = await createAddress({
address: data.walletAddress,
label: data.walletLabel || undefined,
});
set("createdAddressId", created.id);
setStep(3);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
async function handleStep3() {
setError("");
const rules = [];
if (data.alertIncomingTx) rules.push({ type: "incoming_tx" });
if (data.alertOutgoingTx) rules.push({ type: "outgoing_tx" });
if (data.alertLargeTransfer) {
if (!data.largeTransferThreshold) {
setError("Please enter a threshold for large transfers");
return;
}
rules.push({ type: "large_transfer", threshold: data.largeTransferThreshold });
}
if (data.alertBalanceBelow) {
if (!data.balanceBelowThreshold) {
setError("Please enter a threshold for balance below");
return;
}
rules.push({ type: "balance_below", threshold: data.balanceBelowThreshold });
}
if (rules.length === 0) {
setStep(4);
return;
}
try {
setLoading(true);
const created = [];
for (const rule of rules) {
const result = await createAlert(data.createdAddressId, rule);
created.push(result);
}
set("alertsCreated", created);
setStep(4);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
async function handleStep4() {
setError("");
const hasAny =
data.discordWebhookUrl || data.slackWebhookUrl || data.notificationEmail;
if (!hasAny) {
setStep(5);
return;
}
try {
setLoading(true);
await updateNotificationConfig({
notification_enabled: true,
discord_webhook_url: data.discordWebhookUrl || undefined,
slack_webhook_url: data.slackWebhookUrl || undefined,
email: data.notificationEmail || undefined,
});
set("notificationConfigured", true);
setStep(5);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
async function handleTestChannels() {
setTestLoading(true);
setTestResults(null);
try {
const results = await testNotificationChannels();
setTestResults(results);
} catch (err) {
setTestResults({ error: err.message });
} finally {
setTestLoading(false);
}
}
// ── Progress bar ──────────────────────────────────────────────────────────
function ProgressBar() {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: "2rem",
}}
>
{STEPS.map((label, i) => {
const stepNum = i + 1;
const done = step > stepNum;
const active = step === stepNum;
return (
<div
key={label}
style={{ display: "flex", alignItems: "center" }}
>
{i > 0 && (
<div
style={{
width: "40px",
height: "2px",
backgroundColor: done || active ? "#0066cc" : "#444",
margin: "0 4px",
}}
/>
)}
<div style={{ textAlign: "center" }}>
<div
style={{
width: "32px",
height: "32px",
borderRadius: "50%",
backgroundColor:
done ? "#0066cc" : active ? "#0066cc" : "#333",
border: active ? "2px solid #4499ff" : "2px solid transparent",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontWeight: "bold",
fontSize: "0.85rem",
color: "white",
margin: "0 auto 4px",
}}
>
{done ? "✓" : stepNum}
</div>
<div
style={{
fontSize: "0.7rem",
color: active ? "white" : "#888",
whiteSpace: "nowrap",
}}
>
{label}
</div>
</div>
</div>
);
})}
</div>
);
}
// ── Step content ──────────────────────────────────────────────────────────
function Step1() {
return (
<>
<h2 style={{ marginBottom: "1.5rem" }}>Create your account</h2>
<div style={{ marginBottom: "1rem" }}>
<label style={labelStyle}>Email</label>
<input
type="email"
value={data.email}
onChange={(e) => set("email", e.target.value)}
disabled={loading}
style={inputStyle}
placeholder="you@example.com"
/>
</div>
<div style={{ marginBottom: "1rem" }}>
<label style={labelStyle}>Password</label>
<input
type="password"
value={data.password}
onChange={(e) => set("password", e.target.value)}
disabled={loading}
style={inputStyle}
placeholder="At least 6 characters"
/>
</div>
<div style={{ marginBottom: "1.5rem" }}>
<label style={labelStyle}>Confirm Password</label>
<input
type="password"
value={data.confirmPassword}
onChange={(e) => set("confirmPassword", e.target.value)}
disabled={loading}
style={inputStyle}
placeholder="Repeat your password"
/>
</div>
</>
);
}
function Step2() {
return (
<>
<h2 style={{ marginBottom: "0.5rem" }}>Add a wallet address</h2>
<p style={{ color: "#aaa", marginBottom: "1.5rem", fontSize: "0.9rem" }}>
Enter the Ethereum address you want to monitor.
</p>
<div style={{ marginBottom: "1rem" }}>
<label style={labelStyle}>ETH Address</label>
<input
type="text"
value={data.walletAddress}
onChange={(e) => set("walletAddress", e.target.value)}
disabled={loading}
style={inputStyle}
placeholder="0x..."
/>
</div>
<div style={{ marginBottom: "1.5rem" }}>
<label style={labelStyle}>Label (optional)</label>
<input
type="text"
value={data.walletLabel}
onChange={(e) => set("walletLabel", e.target.value)}
disabled={loading}
style={inputStyle}
placeholder="e.g. My main wallet"
/>
</div>
</>
);
}
function Step3() {
return (
<>
<h2 style={{ marginBottom: "0.5rem" }}>Configure alert rules</h2>
<p style={{ color: "#aaa", marginBottom: "1.5rem", fontSize: "0.9rem" }}>
Choose which events trigger notifications. You can change these later.
</p>
<CheckboxRow
checked={data.alertIncomingTx}
onChange={(v) => set("alertIncomingTx", v)}
label="Incoming transaction"
/>
<CheckboxRow
checked={data.alertOutgoingTx}
onChange={(v) => set("alertOutgoingTx", v)}
label="Outgoing transaction"
/>
<CheckboxRow
checked={data.alertLargeTransfer}
onChange={(v) => set("alertLargeTransfer", v)}
label="Large transfer"
>
{data.alertLargeTransfer && (
<div style={{ marginTop: "0.5rem", marginLeft: "1.75rem" }}>
<input
type="number"
value={data.largeTransferThreshold}
onChange={(e) => set("largeTransferThreshold", e.target.value)}
style={{ ...inputStyle, width: "160px" }}
placeholder="Threshold (ETH)"
min="0"
step="0.01"
/>
</div>
)}
</CheckboxRow>
<CheckboxRow
checked={data.alertBalanceBelow}
onChange={(v) => set("alertBalanceBelow", v)}
label="Balance below"
>
{data.alertBalanceBelow && (
<div style={{ marginTop: "0.5rem", marginLeft: "1.75rem" }}>
<input
type="number"
value={data.balanceBelowThreshold}
onChange={(e) => set("balanceBelowThreshold", e.target.value)}
style={{ ...inputStyle, width: "160px" }}
placeholder="Threshold (ETH)"
min="0"
step="0.01"
/>
</div>
)}
</CheckboxRow>
</>
);
}
function Step4() {
return (
<>
<h2 style={{ marginBottom: "0.5rem" }}>Set up notifications</h2>
<p style={{ color: "#aaa", marginBottom: "1.5rem", fontSize: "0.9rem" }}>
Add at least one channel so you receive alerts. All fields are optional.
</p>
<div style={{ marginBottom: "1.25rem" }}>
<label style={labelStyle}>
Discord Webhook URL{" "}
<a
href="https://support.discord.com/hc/en-us/articles/228383668"
target="_blank"
rel="noreferrer"
style={{ color: "#4499ff", fontSize: "0.8rem" }}
>
(how to get one)
</a>
</label>
<input
type="url"
value={data.discordWebhookUrl}
onChange={(e) => set("discordWebhookUrl", e.target.value)}
disabled={loading}
style={inputStyle}
placeholder="https://discord.com/api/webhooks/..."
/>
</div>
<div style={{ marginBottom: "1.25rem" }}>
<label style={labelStyle}>
Slack Webhook URL{" "}
<a
href="https://api.slack.com/messaging/webhooks"
target="_blank"
rel="noreferrer"
style={{ color: "#4499ff", fontSize: "0.8rem" }}
>
(how to get one)
</a>
</label>
<input
type="url"
value={data.slackWebhookUrl}
onChange={(e) => set("slackWebhookUrl", e.target.value)}
disabled={loading}
style={inputStyle}
placeholder="https://hooks.slack.com/services/..."
/>
</div>
<div style={{ marginBottom: "1.5rem" }}>
<label style={labelStyle}>Email address for alerts</label>
<input
type="email"
value={data.notificationEmail}
onChange={(e) => set("notificationEmail", e.target.value)}
disabled={loading}
style={inputStyle}
placeholder="you@example.com"
/>
</div>
</>
);
}
function Step5() {
const alertCount = data.alertsCreated.length;
const hasNotif = data.notificationConfigured;
return (
<>
<h2 style={{ marginBottom: "1rem" }}>You're all set!</h2>
<div
style={{
backgroundColor: "#1e2e1e",
border: "1px solid #2d5a2d",
borderRadius: "6px",
padding: "1rem 1.25rem",
marginBottom: "1.5rem",
}}
>
<p style={{ margin: "0 0 0.5rem", color: "#90ee90", fontWeight: "bold" }}>
Summary
</p>
<ul style={{ margin: 0, paddingLeft: "1.25rem", color: "#ccc", lineHeight: "1.8" }}>
<li>
Wallet address added:{" "}
<span style={{ color: "white", fontFamily: "monospace", fontSize: "0.85rem" }}>
{data.walletAddress}
</span>
{data.walletLabel && ` (${data.walletLabel})`}
</li>
<li>
Alert rules configured:{" "}
<span style={{ color: "white" }}>
{alertCount > 0 ? `${alertCount} rule${alertCount !== 1 ? "s" : ""}` : "None (skipped)"}
</span>
</li>
<li>
Notification channels:{" "}
<span style={{ color: "white" }}>
{hasNotif ? "Configured" : "Not set up (skipped)"}
</span>
</li>
</ul>
</div>
{hasNotif && (
<div style={{ marginBottom: "1.5rem" }}>
<button
onClick={handleTestChannels}
disabled={testLoading}
style={{
padding: "0.6rem 1.25rem",
backgroundColor: testLoading ? "#333" : "#1a4d80",
color: "white",
border: "1px solid #0066cc",
borderRadius: "4px",
cursor: testLoading ? "not-allowed" : "pointer",
fontSize: "0.9rem",
}}
>
{testLoading ? "Testing..." : "Test All Channels"}
</button>
{testResults && (
<div style={{ marginTop: "0.75rem" }}>
{testResults.error ? (
<p style={{ color: "#ff6666" }}>{testResults.error}</p>
) : (
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
{Object.entries(testResults).map(([channel, result]) => (
<li
key={channel}
style={{
color: result.success ? "#90ee90" : "#ff6666",
fontSize: "0.9rem",
marginBottom: "0.25rem",
}}
>
{result.success ? "✓" : "✗"} {channel}:{" "}
{result.message || (result.success ? "OK" : "Failed")}
</li>
))}
</ul>
)}
</div>
)}
</div>
)}
<button
onClick={() => navigate("/addresses")}
style={{
padding: "0.75rem 2rem",
backgroundColor: "#0066cc",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "1rem",
fontWeight: "bold",
}}
>
Go to Dashboard
</button>
</>
);
}
// ── Shared helpers ────────────────────────────────────────────────────────
function CheckboxRow({ checked, onChange, label, children }) {
return (
<div style={{ marginBottom: "1rem" }}>
<label
style={{
display: "flex",
alignItems: "center",
gap: "0.6rem",
cursor: "pointer",
color: "#ddd",
}}
>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
style={{ width: "16px", height: "16px", accentColor: "#0066cc" }}
/>
{label}
</label>
{children}
</div>
);
}
// ── Footer navigation ─────────────────────────────────────────────────────
function Footer() {
if (step === 5) return null;
const canSkip = step === 3 || step === 4;
const canBack = step > 1;
async function handleNext() {
setSkipWarning("");
if (step === 1) await handleStep1();
else if (step === 2) await handleStep2();
else if (step === 3) await handleStep3();
else if (step === 4) await handleStep4();
}
function handleSkip() {
setError("");
setSkipWarning("");
setStep((s) => s + 1);
}
function handleBack() {
setError("");
setSkipWarning("");
setStep((s) => s - 1);
}
return (
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginTop: "1.5rem",
paddingTop: "1rem",
borderTop: "1px solid #333",
}}
>
<div>
{canBack && (
<button
onClick={handleBack}
disabled={loading}
style={{
padding: "0.5rem 1rem",
backgroundColor: "transparent",
color: "#aaa",
border: "1px solid #444",
borderRadius: "4px",
cursor: loading ? "not-allowed" : "pointer",
}}
>
Back
</button>
)}
</div>
<div style={{ display: "flex", gap: "0.75rem" }}>
{canSkip && (
<button
onClick={handleSkip}
disabled={loading}
style={{
padding: "0.5rem 1rem",
backgroundColor: "transparent",
color: "#aaa",
border: "1px solid #444",
borderRadius: "4px",
cursor: loading ? "not-allowed" : "pointer",
}}
>
Skip for now
</button>
)}
<button
onClick={handleNext}
disabled={loading}
style={{
padding: "0.5rem 1.25rem",
backgroundColor: loading ? "#333" : "#0066cc",
color: "white",
border: "none",
borderRadius: "4px",
cursor: loading ? "not-allowed" : "pointer",
fontWeight: "bold",
}}
>
{loading ? "Please wait..." : step === 4 ? "Finish" : "Next →"}
</button>
</div>
</div>
);
}
// ── Render ────────────────────────────────────────────────────────────────
const stepContent = {
1: <Step1 />,
2: <Step2 />,
3: <Step3 />,
4: <Step4 />,
5: <Step5 />,
};
return (
<div
style={{
minHeight: "100vh",
backgroundColor: "#1a1a1a",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "flex-start",
paddingTop: "3rem",
paddingBottom: "3rem",
}}
>
<div style={{ width: "100%", maxWidth: "540px", padding: "0 1rem" }}>
<h1
style={{
textAlign: "center",
marginBottom: "2rem",
color: "#0066cc",
letterSpacing: "0.5px",
}}
>
Koin Ping
</h1>
<ProgressBar />
{error && (
<div
style={{
padding: "0.75rem 1rem",
marginBottom: "1rem",
backgroundColor: "#3a1a1a",
border: "1px solid #cc3333",
borderRadius: "4px",
color: "#ff6666",
fontSize: "0.9rem",
}}
>
{error}
</div>
)}
{skipWarning && (
<div
style={{
padding: "0.75rem 1rem",
marginBottom: "1rem",
backgroundColor: "#3a2e00",
border: "1px solid #aa7700",
borderRadius: "4px",
color: "#ffcc44",
fontSize: "0.9rem",
}}
>
{skipWarning}
</div>
)}
<div
style={{
backgroundColor: "#242424",
border: "1px solid #333",
borderRadius: "8px",
padding: "2rem",
}}
>
{stepContent[step]}
<Footer />
</div>
{step === 1 && (
<p
style={{
textAlign: "center",
marginTop: "1.25rem",
color: "#888",
fontSize: "0.9rem",
}}
>
Already have an account?{" "}
<a href="/login" style={{ color: "#0066cc" }}>
Log in here
</a>
</p>
)}
</div>
</div>
);
}

View File

@@ -1,185 +1,5 @@
/** import { Navigate } from "react-router-dom";
* Signup Page
*
* Allows new users to create an account with email and password
*/
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
export default function Signup() { export default function Signup() {
const [email, setEmail] = useState(''); return <Navigate to="/onboarding" replace />;
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { signup } = useAuth();
const navigate = useNavigate();
async function handleSubmit(e) {
e.preventDefault();
// Validation
if (!email || !password || !confirmPassword) {
setError('Please fill in all fields');
return;
}
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (password.length < 6) {
setError('Password must be at least 6 characters');
return;
}
try {
setError('');
setLoading(true);
await signup(email, password);
navigate('/addresses'); // Auto-login and redirect
} catch (err) {
// Firebase-specific error messages
if (err.code === 'auth/email-already-in-use') {
setError('Email already in use. Try logging in instead.');
} else if (err.code === 'auth/invalid-email') {
setError('Invalid email address');
} else if (err.code === 'auth/weak-password') {
setError('Password is too weak');
} else {
setError('Failed to create account: ' + err.message);
}
} finally {
setLoading(false);
}
}
return (
<div style={{
maxWidth: '400px',
margin: '4rem auto',
padding: '2rem',
border: '1px solid #333',
borderRadius: '8px'
}}>
<h1 style={{ marginBottom: '2rem', textAlign: 'center' }}>
Koin Ping - Sign Up
</h1>
{error && (
<div style={{
padding: '0.75rem',
marginBottom: '1rem',
backgroundColor: '#ff000020',
border: '1px solid #ff0000',
borderRadius: '4px',
color: '#ff6666'
}}>
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={loading}
style={{
width: '100%',
padding: '0.5rem',
fontSize: '1rem',
backgroundColor: '#1a1a1a',
border: '1px solid #444',
borderRadius: '4px',
color: 'white'
}}
required
/>
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
style={{
width: '100%',
padding: '0.5rem',
fontSize: '1rem',
backgroundColor: '#1a1a1a',
border: '1px solid #444',
borderRadius: '4px',
color: 'white'
}}
required
/>
</div>
<div style={{ marginBottom: '1.5rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
Confirm Password
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={loading}
style={{
width: '100%',
padding: '0.5rem',
fontSize: '1rem',
backgroundColor: '#1a1a1a',
border: '1px solid #444',
borderRadius: '4px',
color: 'white'
}}
required
/>
</div>
<button
type="submit"
disabled={loading}
style={{
width: '100%',
padding: '0.75rem',
fontSize: '1rem',
backgroundColor: loading ? '#333' : '#0066cc',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: loading ? 'not-allowed' : 'pointer'
}}
>
{loading ? 'Creating account...' : 'Sign Up'}
</button>
</form>
<div style={{ marginTop: '1.5rem', textAlign: 'center' }}>
<p style={{ color: '#999' }}>
Already have an account?{' '}
<Link
to="/login"
style={{ color: '#0066cc', textDecoration: 'none' }}
>
Log in here
</Link>
</p>
</div>
</div>
);
} }

View File

@@ -22,4 +22,3 @@
}, },
"include": ["src"] "include": ["src"]
} }

View File

@@ -1,5 +1,5 @@
import { defineConfig } from 'vite' import { defineConfig } from "vite";
import react from '@vitejs/plugin-react' import react from "@vitejs/plugin-react";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
@@ -7,11 +7,10 @@ export default defineConfig({
server: { server: {
port: 3000, port: 3000,
proxy: { proxy: {
'/v1': { "/v1": {
target: 'http://localhost:3001', target: "http://localhost:3001",
changeOrigin: true, changeOrigin: true,
}, },
}, },
}, },
}) });