Compare commits

..

1 Commits

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

View File

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

View File

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

View File

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

44
.gitignore vendored
View File

@@ -1,44 +0,0 @@
# OS
.DS_Store
Thumbs.db
# Editors
*.swp
*.swo
*~
*.bak
.idea/
.vscode/
*.sublime-*
# Node
node_modules/
# Environment / secrets
.env
.env.*
*.pem
*.key
# Go build artifacts
backend-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

View File

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

View File

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

View File

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

21
LICENSE
View File

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

View File

@@ -1,90 +0,0 @@
.PHONY: test lint fmt fmt-check check docker hooks build run dev clean \
test-go test-js lint-go lint-js fmt-go fmt-js fmt-check-go fmt-check-js \
build-go build-js
GODIR := backend-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/

183
README.md
View File

@@ -1,122 +1,95 @@
# Koin Ping
Koin Ping is an MIT-licensed blockchain monitoring system by Steven Jannette
that polls Ethereum addresses for on-chain activity and delivers real-time
alerts to users via a Go REST API backend and a React single-page application
frontend.
A lightweight on-chain monitoring and alerting system designed to give users situational awareness over blockchain addresses they care about.
## Overview
Koin Ping is designed to reliably observe on-chain activity and notify users when predefined conditions are met. It does not execute transactions, manage wallets, or speculate on prices.
## Project Structure
```
koin_ping/
├── backend/ # Node.js + Express + PostgreSQL backend
│ ├── api/ # API endpoints and server configuration
│ ├── poller/ # Blockchain polling logic
│ ├── alerts/ # Alert evaluation and management
│ ├── notifications/ # Notification delivery system
│ ├── domain/ # Domain models and business logic
│ ├── infra/ # Infrastructure (database, external services)
│ └── shared/ # Shared utilities and helpers
└── frontend/ # React + Vite frontend
├── public/ # Static assets
└── src/
├── api/ # Frontend API calls to backend
├── components/ # Reusable UI components
├── pages/ # Top-level pages (views)
└── utils/ # Utility functions
```
## Tech Stack
### Backend
- **Runtime:** Node.js
- **Framework:** Express
- **Database:** PostgreSQL
- **Key Dependencies:**
- `pg` - PostgreSQL client
- `dotenv` - Environment variable management
- `cors` - CORS middleware
- `nodemon` - Development auto-reload
### Frontend
- **Framework:** React 19
- **Build Tool:** Vite
- **Language:** JavaScript/TypeScript (mixed)
- **Type Checking:** TypeScript
## Getting Started
### Prerequisites
- Node.js (v18 or higher recommended)
- PostgreSQL database
- npm or yarn
- Go 1.24+
- Node.js 18+ and npm (or yarn — preferred per repo policy)
- PostgreSQL 15+
- `golangci-lint` v2 (for `make lint`)
### Backend Setup
### Setup
1. Navigate to the backend directory:
```bash
cd backend
```
```bash
# Clone and enter the repo
git clone <repo-url>
cd koin_ping_0.2.0
2. Install dependencies (already done):
```bash
npm install
```
# Install pre-commit hook
make hooks
3. Create a `.env` file (see backend/README.md for required variables)
# Install frontend dependencies
cd frontend && npm install && cd ..
4. Start the development server:
```bash
npm run dev
```
# 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
The backend will run on `http://localhost:3001`
# Run checks (requires golangci-lint)
make check
### Frontend Setup
# Start the API server
make run
1. Navigate to the frontend directory:
```bash
cd frontend
```
# Start the poller (separate terminal)
cd backend-go && go run ./cmd/poller
2. Install dependencies (already done):
```bash
npm install
```
# Start the frontend dev server (separate terminal)
cd frontend && npm run dev
```
3. Start the development server:
```bash
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.
## TODO
- [ ] Switch frontend package manager from npm to yarn (per repo policy)
- [ ] Rename `go.mod` module path from `github.com/kjannette/koin-ping` to
`sneak.berlin/go/koin-ping` (per repo policy)
- [ ] Move database migrations to `internal/db/migrations/` and embed them in
the binary
- [ ] Pin all Docker base images by `@sha256` in Dockerfile
- [ ] Install `golangci-lint` locally and pass `make lint`
- [ ] Add vitest unit tests for the React frontend
- [ ] Add a `make db-reset` / `make migrate` target for schema management
- [ ] Set HTTP server read/write timeouts (currently unbounded)
- [ ] Replace `log.Printf` calls with structured `log/slog`
## License
MIT. See [LICENSE](LICENSE).
## Author
Steven Jannette
The frontend will run on `http://localhost:3000`

View File

@@ -1,188 +0,0 @@
---
title: Repository Policies
last_modified: 2026-02-22
---
This document covers repository structure, tooling, and workflow standards. Code
style conventions are in separate documents:
- [Code Styleguide](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

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

View File

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

View File

@@ -1,7 +1,4 @@
Start DB:
brew services start postgresql@15
Run Backend:
cd /Users/kjannette/workspace/koin_ping/backend-go go run ./cmd/api
cd /Users/kjannette/workspace/koin_ping/backend-go
go run ./cmd/api

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
// Package database manages PostgreSQL connection pools.
package database
import (
@@ -10,34 +9,20 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
)
const (
// maxConnIdleSeconds is the maximum idle time for a connection.
maxConnIdleSeconds = 30
// maxConnLifetimeMinutes is the maximum lifetime for a connection.
maxConnLifetimeMinutes = 5
// connectTimeoutSeconds is the timeout for initial connection.
connectTimeoutSeconds = 10
// maxConns is the maximum number of connections in the pool.
maxConns = 20
// minConns is the minimum number of connections in the pool.
minConns = 2
)
var pool *pgxpool.Pool
var pool *pgxpool.Pool //nolint:gochecknoglobals
// Connect establishes a PostgreSQL connection pool using the given DSN.
func Connect(dsn string) (*pgxpool.Pool, error) {
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, fmt.Errorf("parse database config: %w", err)
}
cfg.MaxConns = maxConns
cfg.MinConns = minConns
cfg.MaxConnIdleTime = maxConnIdleSeconds * time.Second
cfg.MaxConnLifetime = maxConnLifetimeMinutes * time.Minute
cfg.MaxConns = 20
cfg.MinConns = 2
cfg.MaxConnIdleTime = 30 * time.Second
cfg.MaxConnLifetime = 5 * time.Minute
ctx, cancel := context.WithTimeout(context.Background(), connectTimeoutSeconds*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
p, err := pgxpool.NewWithConfig(ctx, cfg)
@@ -47,22 +32,18 @@ func Connect(dsn string) (*pgxpool.Pool, error) {
if err := p.Ping(ctx); err != nil {
p.Close()
return nil, fmt.Errorf("ping database: %w", err)
}
log.Println("Connected to PostgreSQL database")
pool = p
return p, nil
}
// Pool returns the global connection pool.
func Pool() *pgxpool.Pool {
return pool
}
// Close closes the global connection pool.
func Close() {
if pool != nil {
pool.Close()

View File

@@ -1,24 +1,17 @@
// Package domain defines core domain types shared across the application.
package domain
import "time"
// Address represents a tracked Ethereum address.
type Address struct {
ID int `json:"id"`
UserID string `json:"user_id"` //nolint:tagliatelle
UserID string `json:"user_id"`
Address string `json:"address"`
Label *string `json:"label"`
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
CreatedAt time.Time `json:"created_at"`
}
// AlertType identifies the kind of alert rule.
type AlertType string
// String implements fmt.Stringer.
func (a AlertType) String() string { return string(a) }
// Alert type constants define the supported alert triggers.
const (
AlertIncomingTx AlertType = "incoming_tx"
AlertOutgoingTx AlertType = "outgoing_tx"
@@ -26,115 +19,96 @@ const (
AlertBalanceBelow AlertType = "balance_below"
)
// ValidAlertTypes lists all alert types accepted by the API.
var ValidAlertTypes = []AlertType{ //nolint:gochecknoglobals
var ValidAlertTypes = []AlertType{
AlertIncomingTx,
AlertOutgoingTx,
AlertLargeTransfer,
AlertBalanceBelow,
}
// ThresholdRequiredTypes lists alert types that require a threshold value.
var ThresholdRequiredTypes = []AlertType{ //nolint:gochecknoglobals
var ThresholdRequiredTypes = []AlertType{
AlertLargeTransfer,
AlertBalanceBelow,
}
// IsValidAlertType returns true if the given string matches a known AlertType.
func IsValidAlertType(t string) bool {
for _, v := range ValidAlertTypes {
if string(v) == t {
return true
}
}
return false
}
// IsThresholdRequired returns true if the given AlertType requires a threshold.
func IsThresholdRequired(t AlertType) bool {
for _, v := range ThresholdRequiredTypes {
if v == t {
return true
}
}
return false
}
// AlertRule represents a user-defined alert rule for an address.
type AlertRule struct {
ID int `json:"id"`
AddressID int `json:"address_id"` //nolint:tagliatelle
AddressID int `json:"address_id"`
Type AlertType `json:"type"`
Threshold *float64 `json:"threshold"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
CreatedAt time.Time `json:"created_at"`
}
// AlertEvent represents a fired alert event stored for history.
type AlertEvent struct {
ID int `json:"id"`
AlertRuleID int `json:"alert_rule_id"` //nolint:tagliatelle
Message string `json:"message"`
AddressLabel *string `json:"address_label"` //nolint:tagliatelle
TxHash *string `json:"tx_hash"` //nolint:tagliatelle
Timestamp time.Time `json:"timestamp"`
ID int `json:"id"`
AlertRuleID int `json:"alert_rule_id"`
Message string `json:"message"`
AddressLabel *string `json:"address_label"`
TxHash *string `json:"tx_hash"`
Timestamp time.Time `json:"timestamp"`
}
// AddressCheckpoint tracks the last block checked for an address.
type AddressCheckpoint struct {
AddressID int `json:"address_id"` //nolint:tagliatelle
LastCheckedBlock int `json:"last_checked_block"` //nolint:tagliatelle
LastCheckedAt time.Time `json:"last_checked_at"` //nolint:tagliatelle
AddressID int `json:"address_id"`
LastCheckedBlock int `json:"last_checked_block"`
LastCheckedAt time.Time `json:"last_checked_at"`
}
// CheckpointDetail combines checkpoint and address info for reporting.
type CheckpointDetail struct {
AddressID int `json:"address_id"` //nolint:tagliatelle
AddressID int `json:"address_id"`
Address string `json:"address"`
Label *string `json:"label"`
LastCheckedBlock int `json:"last_checked_block"` //nolint:tagliatelle
LastCheckedAt time.Time `json:"last_checked_at"` //nolint:tagliatelle
LastCheckedBlock int `json:"last_checked_block"`
LastCheckedAt time.Time `json:"last_checked_at"`
}
// NotificationConfig holds a user's notification preferences.
type NotificationConfig struct {
UserID string `json:"user_id"` //nolint:tagliatelle
DiscordWebhookURL *string `json:"discord_webhook_url"` //nolint:tagliatelle
TelegramChatID *string `json:"telegram_chat_id"` //nolint:tagliatelle
TelegramBotToken *string `json:"telegram_bot_token,omitempty"` //nolint:tagliatelle
UserID string `json:"user_id"`
DiscordWebhookURL *string `json:"discord_webhook_url"`
SlackWebhookURL *string `json:"slack_webhook_url"`
Email *string `json:"email"`
NotificationEnabled bool `json:"notification_enabled"` //nolint:tagliatelle
CreatedAt *time.Time `json:"created_at,omitempty"` //nolint:tagliatelle
UpdatedAt *time.Time `json:"updated_at,omitempty"` //nolint:tagliatelle
NotificationEnabled bool `json:"notification_enabled"`
CreatedAt *time.Time `json:"created_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
// NormalizedTx is a blockchain transaction normalized for internal use.
type NormalizedTx struct {
Hash string `json:"hash"`
From string `json:"from"`
Hash string `json:"hash"`
From string `json:"from"`
To *string `json:"to"`
Value string `json:"value"` // Wei as string for precision
BlockNumber int `json:"block_number"` //nolint:tagliatelle
BlockTimestamp int64 `json:"block_timestamp"` //nolint:tagliatelle
Value string `json:"value"` // Wei as string for precision
BlockNumber int `json:"block_number"`
BlockTimestamp int64 `json:"block_timestamp"`
}
// Direction indicates whether a transaction is incoming or outgoing.
type Direction string
// String implements fmt.Stringer.
func (d Direction) String() string { return string(d) }
// Direction constants indicate the flow of a transaction relative to a watched address.
const (
DirectionIncoming Direction = "incoming"
DirectionOutgoing Direction = "outgoing"
)
// ObservedTx is a NormalizedTx enriched with address and direction context.
type ObservedTx struct {
NormalizedTx
AddressID int `json:"address_id"` //nolint:tagliatelle
AddressID int `json:"address_id"`
Direction Direction `json:"direction"`
}

View File

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

View File

@@ -1,4 +1,3 @@
// Package firebase provides Firebase authentication integration.
package firebase
import (
@@ -11,13 +10,12 @@ import (
"google.golang.org/api/option"
)
var ( //nolint:gochecknoglobals
authClient *auth.Client //nolint:gochecknoglobals
once sync.Once //nolint:gochecknoglobals
errInit error //nolint:gochecknoglobals
var (
authClient *auth.Client
once sync.Once
initErr error
)
// Init initializes the Firebase app and auth client using the given project ID.
func Init(projectID string) error {
once.Do(func() {
ctx := context.Background()
@@ -32,23 +30,20 @@ func Init(projectID string) error {
app, err = fb.NewApp(ctx, nil)
}
if err != nil {
errInit = fmt.Errorf("initialize firebase app: %w", err)
initErr = fmt.Errorf("initialize firebase app: %w", err)
return
}
authClient, err = app.Auth(ctx)
if err != nil {
errInit = fmt.Errorf("initialize firebase auth: %w", err)
initErr = fmt.Errorf("initialize firebase auth: %w", err)
return
}
})
return errInit
return initErr
}
// Auth returns the initialized Firebase auth client.
func Auth() *auth.Client {
return authClient
}

View File

@@ -1,4 +1,3 @@
// Package handlers implements HTTP request handlers for the API.
package handlers
import (
@@ -15,17 +14,14 @@ import (
var ethAddressRe = regexp.MustCompile(`^0x[a-fA-F0-9]{40}$`)
// AddressHandler handles HTTP requests for address management.
type AddressHandler struct {
addresses *models.AddressModel
}
// NewAddressHandler creates a new AddressHandler.
func NewAddressHandler(addresses *models.AddressModel) *AddressHandler {
return &AddressHandler{addresses: addresses}
}
// Create handles POST requests to add a new tracked address.
func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
@@ -36,19 +32,16 @@ func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
log.Printf("Failed to decode address request body: %v", err)
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body")
return
}
if body.Address == "" {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Address is required")
return
}
if !ethAddressRe.MatchString(body.Address) {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid Ethereum address format")
return
}
@@ -58,12 +51,10 @@ func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
if err != nil {
if strings.Contains(err.Error(), "23505") || strings.Contains(err.Error(), "unique") {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "You are already tracking this address")
return
}
log.Printf("Error creating address: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create address")
return
}
@@ -71,7 +62,6 @@ func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, addr)
}
// List handles GET requests to list all tracked addresses for the current user.
func (h *AddressHandler) List(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
log.Printf("User %s listing addresses", userID)
@@ -80,7 +70,6 @@ func (h *AddressHandler) List(w http.ResponseWriter, r *http.Request) {
if err != nil {
log.Printf("Error listing addresses: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list addresses")
return
}
@@ -92,13 +81,11 @@ func (h *AddressHandler) List(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, addresses)
}
// Remove handles DELETE requests to remove a tracked address.
func (h *AddressHandler) Remove(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
addressID, ok := parseIntParam(r.PathValue("addressId"))
if !ok {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid address ID")
return
}
@@ -108,14 +95,12 @@ func (h *AddressHandler) Remove(w http.ResponseWriter, r *http.Request) {
if err != nil {
log.Printf("Error deleting address: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete address")
return
}
if !deleted {
log.Printf("Address %d not found or not owned by user", addressID)
writeError(w, http.StatusNotFound, "NOT_FOUND", "Address not found")
return
}

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import (
"github.com/kjannette/koin-ping/backend-go/internal/domain"
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
"github.com/kjannette/koin-ping/backend-go/internal/models"
"github.com/kjannette/koin-ping/backend-go/internal/notifications"
)
var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
@@ -50,7 +51,7 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
var body struct {
DiscordWebhookURL *string `json:"discord_webhook_url"`
TelegramChatID *string `json:"telegram_chat_id"`
SlackWebhookURL *string `json:"slack_webhook_url"`
Email *string `json:"email"`
NotificationEnabled *bool `json:"notification_enabled"`
}
@@ -62,7 +63,7 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
log.Printf("User %s updating notification config", userID)
if body.DiscordWebhookURL == nil && body.TelegramChatID == nil &&
if body.DiscordWebhookURL == nil && body.SlackWebhookURL == nil &&
body.Email == nil && body.NotificationEnabled == nil {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
"At least one configuration field must be provided")
@@ -76,6 +77,13 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
return
}
if body.SlackWebhookURL != nil && *body.SlackWebhookURL != "" &&
!strings.HasPrefix(*body.SlackWebhookURL, "https://hooks.slack.com/") {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
"Invalid Slack webhook URL format")
return
}
if body.Email != nil && *body.Email != "" && !emailRe.MatchString(*body.Email) {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
"Invalid email address format")
@@ -89,7 +97,7 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
cfg := domain.NotificationConfig{
DiscordWebhookURL: body.DiscordWebhookURL,
TelegramChatID: body.TelegramChatID,
SlackWebhookURL: body.SlackWebhookURL,
Email: body.Email,
NotificationEnabled: enabled,
}
@@ -126,3 +134,50 @@ func (h *NotificationConfigHandler) DeleteConfig(w http.ResponseWriter, r *http.
log.Println("Notification config deleted")
w.WriteHeader(http.StatusNoContent)
}
func (h *NotificationConfigHandler) TestWebhook(w http.ResponseWriter, r *http.Request) {
_ = middleware.GetUserID(r.Context())
var body struct {
Type string `json:"type"`
URL string `json:"url"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body")
return
}
if body.URL == "" {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "URL is required")
return
}
var ok bool
var err error
switch body.Type {
case "slack":
if !strings.HasPrefix(body.URL, "https://hooks.slack.com/") {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid Slack webhook URL")
return
}
ok, err = notifications.TestSlackWebhook(body.URL)
case "discord":
if !strings.HasPrefix(body.URL, "https://discord.com/api/webhooks/") {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid Discord webhook URL")
return
}
ok, err = notifications.TestDiscordWebhook(body.URL)
default:
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Type must be 'slack' or 'discord'")
return
}
if err != nil {
log.Printf("Webhook test failed: %v", err)
writeJSON(w, http.StatusOK, map[string]interface{}{"success": false, "error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"success": ok})
}

View File

@@ -1,8 +1,13 @@
package handlers
import (
"context"
"log"
"net/http"
"time"
"github.com/kjannette/koin-ping/backend-go/internal/models"
"github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
)
func HealthCheck(w http.ResponseWriter, r *http.Request) {
@@ -13,11 +18,62 @@ func HealthCheck(w http.ResponseWriter, r *http.Request) {
})
}
func SystemStatus(w http.ResponseWriter, r *http.Request) {
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": 0,
"lag": 0,
"status": "healthy",
"timestamp": time.Now().UTC().Format(time.RFC3339),
"latestBlock": latestBlock,
"lag": lag,
"trackedAddresses": len(checkpoints),
"status": status,
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}

View File

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

View File

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

View File

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

View File

@@ -2,9 +2,7 @@ package models
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/kjannette/koin-ping/backend-go/internal/domain"
)
@@ -25,7 +23,7 @@ func (m *CheckpointModel) GetLastCheckedBlock(ctx context.Context, addressID int
addressID,
).Scan(&block)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
if err.Error() == "no rows in result set" {
return 0, false, nil
}
return 0, false, err

View File

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

View File

@@ -9,22 +9,6 @@ import (
"time"
)
const (
// discordHTTPTimeoutSeconds is the timeout for Discord webhook requests.
discordHTTPTimeoutSeconds = 10
// Alert color codes for Discord embeds.
colorGreen = 0x00ff00
colorOrange = 0xff9900
colorRed = 0xff0000
colorBlue = 0x0099ff
)
// discordHTTPClient is a shared HTTP client with a timeout for Discord requests.
var discordHTTPClient = &http.Client{ //nolint:gochecknoglobals
Timeout: discordHTTPTimeoutSeconds * time.Second,
}
type AlertMetadata struct {
TxHash string
AddressLabel string
@@ -89,9 +73,7 @@ func SendDiscordNotification(webhookURL, message string, meta AlertMetadata) (bo
return false, fmt.Errorf("marshal discord payload: %w", err)
}
resp, err := discordHTTPClient.Post(
webhookURL, "application/json", bytes.NewReader(body),
)
resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body))
if err != nil {
log.Printf("Failed to send Discord notification: %v", err)
return false, err
@@ -116,9 +98,7 @@ func TestDiscordWebhook(webhookURL string) (bool, error) {
return false, err
}
resp, err := discordHTTPClient.Post(
webhookURL, "application/json", bytes.NewReader(body),
)
resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body))
if err != nil {
log.Printf("Discord webhook test failed: %v", err)
return false, err
@@ -131,14 +111,14 @@ func TestDiscordWebhook(webhookURL string) (bool, error) {
func colorForAlertType(alertType string) int {
switch alertType {
case "incoming_tx":
return colorGreen
return 0x00ff00 // Green
case "outgoing_tx":
return colorOrange
return 0xff9900 // Orange
case "large_transfer":
return colorRed
return 0xff0000 // Red
case "balance_below":
return colorRed
return 0xff0000 // Red
default:
return colorBlue
return 0x0099ff // Blue
}
}

View File

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

View File

@@ -0,0 +1,111 @@
package notifications
import (
"fmt"
"log"
"net/smtp"
"strings"
)
type SMTPConfig struct {
Host string
Port int
Username string
Password string
From string
}
func (c *SMTPConfig) IsConfigured() bool {
return c.Host != "" && c.From != ""
}
func (c *SMTPConfig) addr() string {
return fmt.Sprintf("%s:%d", c.Host, c.Port)
}
func SendEmailNotification(cfg SMTPConfig, toEmail, message string, meta AlertMetadata) (bool, error) {
if !cfg.IsConfigured() {
return false, fmt.Errorf("SMTP is not configured")
}
subject := fmt.Sprintf("Koin Ping Alert: %s", humanAlertType(meta.AlertType))
body := buildEmailBody(message, meta)
msg := strings.Join([]string{
fmt.Sprintf("From: %s", cfg.From),
fmt.Sprintf("To: %s", toEmail),
fmt.Sprintf("Subject: %s", subject),
"MIME-Version: 1.0",
"Content-Type: text/html; charset=UTF-8",
"",
body,
}, "\r\n")
var auth smtp.Auth
if cfg.Username != "" {
auth = smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
}
err := smtp.SendMail(cfg.addr(), auth, cfg.From, []string{toEmail}, []byte(msg))
if err != nil {
log.Printf("Failed to send email notification to %s: %v", toEmail, err)
return false, err
}
return true, nil
}
func TestEmailNotification(cfg SMTPConfig, toEmail string) (bool, error) {
meta := AlertMetadata{AlertType: "test"}
return SendEmailNotification(cfg, toEmail,
"This is a test notification from Koin Ping. Your email is configured correctly!", meta)
}
func buildEmailBody(message string, meta AlertMetadata) string {
txSection := ""
if meta.TxHash != "" {
txSection = fmt.Sprintf(
`<tr><td style="padding:8px 0;color:#666">Transaction</td>`+
`<td style="padding:8px 0"><a href="https://etherscan.io/tx/%s" style="color:#4f46e5">View on Etherscan</a></td></tr>`,
meta.TxHash,
)
}
return fmt.Sprintf(`<!DOCTYPE html>
<html>
<body style="margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f5f5f5">
<div style="max-width:560px;margin:24px auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.1)">
<div style="background:#4f46e5;padding:20px 24px">
<h1 style="margin:0;color:#fff;font-size:18px">Koin Ping Alert</h1>
</div>
<div style="padding:24px">
<p style="margin:0 0 16px;font-size:15px;color:#333">%s</p>
<table style="width:100%%;border-collapse:collapse;font-size:14px">
<tr><td style="padding:8px 0;color:#666">Address</td><td style="padding:8px 0">%s</td></tr>
<tr><td style="padding:8px 0;color:#666">Blockchain Address</td><td style="padding:8px 0;font-family:monospace;font-size:12px">%s</td></tr>
%s
</table>
</div>
<div style="padding:16px 24px;background:#fafafa;font-size:12px;color:#999;text-align:center">
Sent by Koin Ping
</div>
</div>
</body>
</html>`, message, meta.AddressLabel, meta.Address, txSection)
}
func humanAlertType(alertType string) string {
switch alertType {
case "incoming_tx":
return "Incoming Transaction"
case "outgoing_tx":
return "Outgoing Transaction"
case "large_transfer":
return "Large Transfer"
case "balance_below":
return "Balance Below Threshold"
default:
return "Alert"
}
}

View File

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

View File

@@ -0,0 +1,128 @@
package notifications
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
)
type slackBlock struct {
Type string `json:"type"`
Text *slackText `json:"text,omitempty"`
Fields []slackText `json:"fields,omitempty"`
Elements []slackText `json:"elements,omitempty"`
}
type slackText struct {
Type string `json:"type"`
Text string `json:"text"`
}
type slackPayload struct {
Blocks []slackBlock `json:"blocks"`
}
func SendSlackNotification(webhookURL, message string, meta AlertMetadata) (bool, error) {
etherscanLink := ""
if meta.TxHash != "" {
etherscanLink = fmt.Sprintf("<%s|View on Etherscan>", "https://etherscan.io/tx/"+meta.TxHash)
}
blocks := []slackBlock{
{
Type: "header",
Text: &slackText{Type: "plain_text", Text: emojiForAlertType(meta.AlertType) + " Koin Ping Alert"},
},
{
Type: "section",
Text: &slackText{Type: "mrkdwn", Text: message},
},
{
Type: "section",
Fields: []slackText{
{Type: "mrkdwn", Text: fmt.Sprintf("*Address:*\n%s", meta.AddressLabel)},
{Type: "mrkdwn", Text: fmt.Sprintf("*Blockchain Address:*\n`%s`", meta.Address)},
},
},
}
if etherscanLink != "" {
blocks = append(blocks, slackBlock{
Type: "section",
Text: &slackText{Type: "mrkdwn", Text: fmt.Sprintf("*Transaction:* %s", etherscanLink)},
})
}
blocks = append(blocks, slackBlock{
Type: "context",
Elements: []slackText{
{Type: "mrkdwn", Text: fmt.Sprintf("Koin Ping | %s", time.Now().UTC().Format(time.RFC3339))},
},
})
payload := slackPayload{Blocks: blocks}
body, err := json.Marshal(payload)
if err != nil {
return false, fmt.Errorf("marshal slack payload: %w", err)
}
resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body))
if err != nil {
log.Printf("Failed to send Slack notification: %v", err)
return false, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
log.Printf("Slack webhook failed: HTTP %d", resp.StatusCode)
return false, fmt.Errorf("slack webhook failed: HTTP %d", resp.StatusCode)
}
return true, nil
}
func TestSlackWebhook(webhookURL string) (bool, error) {
payload := slackPayload{
Blocks: []slackBlock{
{
Type: "section",
Text: &slackText{
Type: "mrkdwn",
Text: "Koin Ping test notification — Your Slack webhook is configured correctly!",
},
},
},
}
body, err := json.Marshal(payload)
if err != nil {
return false, err
}
resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body))
if err != nil {
log.Printf("Slack webhook test failed: %v", err)
return false, err
}
defer resp.Body.Close()
return resp.StatusCode >= 200 && resp.StatusCode < 300, nil
}
func emojiForAlertType(alertType string) string {
switch alertType {
case "incoming_tx":
return "📥"
case "outgoing_tx":
return "📤"
case "large_transfer":
return "🚨"
case "balance_below":
return "⚠️"
default:
return "🔔"
}
}

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ type EvaluatorService struct {
alertEvents *models.AlertEventModel
addresses *models.AddressModel
notifConfigs *models.NotificationConfigModel
smtpConfig notifications.SMTPConfig
}
func NewEvaluatorService(
@@ -26,6 +27,7 @@ func NewEvaluatorService(
alertEvents *models.AlertEventModel,
addresses *models.AddressModel,
notifConfigs *models.NotificationConfigModel,
smtpCfg notifications.SMTPConfig,
) *EvaluatorService {
return &EvaluatorService{
eth: eth,
@@ -33,6 +35,7 @@ func NewEvaluatorService(
alertEvents: alertEvents,
addresses: addresses,
notifConfigs: notifConfigs,
smtpConfig: smtpCfg,
}
}
@@ -168,44 +171,56 @@ func (s *EvaluatorService) fireAlert(ctx context.Context, rule domain.AlertRule,
log.Printf("[ALERT FIRED] Rule %d (%s) - %s - TX: %s", rule.ID, rule.Type, message, obs.Hash)
// Send Discord notification (non-fatal on failure)
if addr != nil {
go func() {
s.sendNotification(
ctx, addr.UserID, message, obs, addressLabel, rule, addr.Address,
)
}()
go s.sendNotifications(ctx, addr.UserID, message, obs, addressLabel, rule, addr.Address)
}
return nil
}
func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message string, obs domain.ObservedTx, addressLabel string, rule domain.AlertRule, address string) {
func (s *EvaluatorService) sendNotifications(ctx context.Context, userID, message string, obs domain.ObservedTx, addressLabel string, rule domain.AlertRule, address string) {
notifConfig, err := s.notifConfigs.GetConfig(ctx, userID)
if err != nil {
log.Printf("Failed to get notification config: %v", err)
return
}
if notifConfig == nil || !notifConfig.NotificationEnabled || notifConfig.DiscordWebhookURL == nil {
if notifConfig == nil || !notifConfig.NotificationEnabled {
return
}
sent, err := notifications.SendDiscordNotification(
*notifConfig.DiscordWebhookURL,
message,
notifications.AlertMetadata{
TxHash: obs.Hash,
AddressLabel: addressLabel,
AlertType: string(rule.Type),
Address: address,
},
)
meta := notifications.AlertMetadata{
TxHash: obs.Hash,
AddressLabel: addressLabel,
AlertType: string(rule.Type),
Address: address,
}
if err != nil || !sent {
log.Printf("Discord notification failed for user %s: %v", userID, err)
} else {
log.Printf("Discord notification sent to user %s", userID)
if notifConfig.DiscordWebhookURL != nil && *notifConfig.DiscordWebhookURL != "" {
sent, err := notifications.SendDiscordNotification(*notifConfig.DiscordWebhookURL, message, meta)
if err != nil || !sent {
log.Printf("Discord notification failed for user %s: %v", userID, err)
} else {
log.Printf("Discord notification sent to user %s", userID)
}
}
if notifConfig.SlackWebhookURL != nil && *notifConfig.SlackWebhookURL != "" {
sent, err := notifications.SendSlackNotification(*notifConfig.SlackWebhookURL, message, meta)
if err != nil || !sent {
log.Printf("Slack notification failed for user %s: %v", userID, err)
} else {
log.Printf("Slack notification sent to user %s", userID)
}
}
if notifConfig.Email != nil && *notifConfig.Email != "" && s.smtpConfig.IsConfigured() {
sent, err := notifications.SendEmailNotification(s.smtpConfig, *notifConfig.Email, message, meta)
if err != nil || !sent {
log.Printf("Email notification failed for user %s: %v", userID, err)
} else {
log.Printf("Email notification sent to user %s (%s)", userID, *notifConfig.Email)
}
}
}

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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,103 +1,116 @@
// API client for notification configuration
import { getAuthHeaders } from "./authHeaders";
import { API_BASE } from "./config";
import { getAuthHeaders } from './authHeaders';
import { API_BASE } from './config';
/**
* Get notification configuration for current user
* @returns {Promise<Object>} Notification config
*/
export async function getNotificationConfig() {
try {
const headers = await getAuthHeaders();
const response = await fetch(`${API_BASE}/notification-config`, {
headers: headers,
});
if (!response.ok) {
let errorMessage = "Failed to fetch notification config";
try {
const error = await response.json();
errorMessage = error.message || errorMessage;
} catch {
errorMessage = `Server error: ${response.status} ${response.statusText}`;
}
throw new Error(errorMessage);
}
return response.json();
} catch (error) {
if (error.message.includes("fetch")) {
throw new Error(
"Cannot connect to server. Is the backend running?",
);
}
throw error;
try {
const headers = await getAuthHeaders();
const response = await fetch(`${API_BASE}/notification-config`, {
headers: headers
});
if (!response.ok) {
let errorMessage = 'Failed to fetch notification config';
try {
const error = await response.json();
errorMessage = error.message || errorMessage;
} catch {
errorMessage = `Server error: ${response.status} ${response.statusText}`;
}
throw new Error(errorMessage);
}
return response.json();
} catch (error) {
if (error.message.includes('fetch')) {
throw new Error('Cannot connect to server. Is the backend running?');
}
throw error;
}
}
/**
* Update notification configuration
* @param {Object} config - Configuration to update
* @param {string} [config.discord_webhook_url] - Discord webhook URL
* @param {string} [config.telegram_chat_id] - Telegram chat ID
* @param {string} [config.slack_webhook_url] - Slack webhook URL
* @param {string} [config.email] - Email address
* @param {boolean} [config.notification_enabled] - Enable/disable notifications
* @returns {Promise<Object>} Updated config
*/
export async function updateNotificationConfig(config) {
try {
const headers = await getAuthHeaders();
const response = await fetch(`${API_BASE}/notification-config`, {
method: "PUT",
headers: headers,
body: JSON.stringify(config),
});
if (!response.ok) {
let errorMessage = "Failed to update notification config";
try {
const error = await response.json();
errorMessage = error.message || errorMessage;
} catch {
errorMessage = `Server error: ${response.status} ${response.statusText}`;
}
throw new Error(errorMessage);
}
return response.json();
} catch (error) {
if (error.message.includes("fetch")) {
throw new Error(
"Cannot connect to server. Is the backend running?",
);
}
throw error;
try {
const headers = await getAuthHeaders();
const response = await fetch(`${API_BASE}/notification-config`, {
method: 'PUT',
headers: headers,
body: JSON.stringify(config)
});
if (!response.ok) {
let errorMessage = 'Failed to update notification config';
try {
const error = await response.json();
errorMessage = error.message || errorMessage;
} catch {
errorMessage = `Server error: ${response.status} ${response.statusText}`;
}
throw new Error(errorMessage);
}
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 webhook URL by sending a test message via the backend
* @param {string} type - 'discord' or 'slack'
* @param {string} webhookUrl - Webhook URL to test
* @returns {Promise<boolean>} True if test successful
*/
async function testWebhook(type, webhookUrl) {
try {
const headers = await getAuthHeaders();
const response = await fetch(`${API_BASE}/notification-config/test`, {
method: 'POST',
headers: headers,
body: JSON.stringify({ type, url: webhookUrl })
});
if (!response.ok) {
return false;
}
const result = await response.json();
return result.success === true;
} catch (error) {
console.error(`${type} webhook test failed:`, error);
return false;
}
}
/**
* Test a Discord webhook URL
* Sends a test message to verify the webhook works
* @param {string} webhookUrl - Discord webhook URL to test
* @returns {Promise<boolean>} True if test successful
*/
export async function testDiscordWebhook(webhookUrl) {
try {
const payload = {
content:
"Koin Ping test notification - Your Discord webhook is configured correctly!",
};
const response = await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
return response.ok;
} catch (error) {
console.error("Discord webhook test failed:", error);
return false;
}
return testWebhook('discord', webhookUrl);
}
/**
* Test a Slack webhook URL
* @param {string} webhookUrl - Slack webhook URL to test
* @returns {Promise<boolean>} True if test successful
*/
export async function testSlackWebhook(webhookUrl) {
return testWebhook('slack', webhookUrl);
}

View File

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

View File

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

View File

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

View File

@@ -3,20 +3,21 @@ export default function Button({
onClick,
disabled = false,
type = "button",
}) {
}) {
return (
<button
type={type}
onClick={onClick}
disabled={disabled}
style={{
padding: "0.5rem 1rem",
fontSize: "1rem",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.6 : 1,
}}
>
{children}
</button>
<button
type={type}
onClick={onClick}
disabled={disabled}
style={{
padding: "0.5rem 1rem",
fontSize: "1rem",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.6 : 1,
}}
>
{children}
</button>
);
}
}

View File

@@ -7,26 +7,27 @@ export default function Input({
step,
min,
disabled = false,
}) {
}) {
return (
<label style={{ display: "block", marginBottom: "1rem" }}>
<div style={{ marginBottom: "0.25rem", fontSize: "0.9rem" }}>
{label}
</div>
<input
type={type}
value={value}
placeholder={placeholder}
step={step}
min={min}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
style={{
width: "100%",
padding: "0.5rem",
fontSize: "1rem",
}}
/>
</label>
<label style={{ display: "block", marginBottom: "1rem" }}>
<div style={{ marginBottom: "0.25rem", fontSize: "0.9rem" }}>
{label}
</div>
<input
type={type}
value={value}
placeholder={placeholder}
step={step}
min={min}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
style={{
width: "100%",
padding: "0.5rem",
fontSize: "1rem",
}}
/>
</label>
);
}
}

View File

@@ -1,85 +0,0 @@
import { Link, useLocation } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
const navLinks = [
{ to: "/addresses", label: "Addresses" },
{ to: "/alerts", label: "Configure Alerts" },
{ to: "/history", label: "Alert History" },
];
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

@@ -1,17 +1,17 @@
/**
* AuthContext - Firebase Authentication State Management
*
*
* Provides authentication state and methods throughout the app
*/
import { createContext, useContext, useEffect, useState } from "react";
import {
createUserWithEmailAndPassword,
signInWithEmailAndPassword,
signOut,
onAuthStateChanged,
} from "firebase/auth";
import { auth } from "../firebase/config";
import { createContext, useContext, useEffect, useState } from 'react';
import {
createUserWithEmailAndPassword,
signInWithEmailAndPassword,
signOut,
onAuthStateChanged
} from 'firebase/auth';
import { auth } from '../firebase/config';
const AuthContext = createContext();
@@ -20,95 +20,88 @@ const AuthContext = createContext();
* @returns {Object} Auth context value
*/
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within AuthProvider");
}
return context;
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
/**
* AuthProvider - Wraps app and provides auth state
*/
export function AuthProvider({ children }) {
const [currentUser, setCurrentUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [currentUser, setCurrentUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
/**
* Sign up with email and password
*/
async function signup(email, password) {
try {
setError(null);
const result = await createUserWithEmailAndPassword(
auth,
email,
password,
);
return result.user;
} catch (err) {
setError(err.message);
throw err;
}
/**
* Sign up with email and password
*/
async function signup(email, password) {
try {
setError(null);
const result = await createUserWithEmailAndPassword(auth, email, password);
return result.user;
} catch (err) {
setError(err.message);
throw err;
}
}
/**
* Log in with email and password
*/
async function login(email, password) {
try {
setError(null);
const result = await signInWithEmailAndPassword(
auth,
email,
password,
);
return result.user;
} catch (err) {
setError(err.message);
throw err;
}
/**
* Log in with email and password
*/
async function login(email, password) {
try {
setError(null);
const result = await signInWithEmailAndPassword(auth, email, password);
return result.user;
} catch (err) {
setError(err.message);
throw err;
}
}
/**
* Log out current user
*/
async function logout() {
try {
setError(null);
await signOut(auth);
} catch (err) {
setError(err.message);
throw err;
}
/**
* Log out current user
*/
async function logout() {
try {
setError(null);
await signOut(auth);
} catch (err) {
setError(err.message);
throw err;
}
}
/**
* Listen for auth state changes
*/
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, (user) => {
setCurrentUser(user);
setLoading(false);
});
/**
* Listen for auth state changes
*/
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, (user) => {
setCurrentUser(user);
setLoading(false);
});
// Cleanup subscription
return unsubscribe;
}, []);
// Cleanup subscription
return unsubscribe;
}, []);
const value = {
currentUser,
signup,
login,
logout,
error,
loading,
};
const value = {
currentUser,
signup,
login,
logout,
error,
loading
};
return (
<AuthContext.Provider value={value}>
{!loading && children}
</AuthContext.Provider>
);
return (
<AuthContext.Provider value={value}>
{!loading && children}
</AuthContext.Provider>
);
}

View File

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

View File

@@ -1,33 +0,0 @@
@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,14 +3,13 @@ import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { AuthProvider } from "./contexts/AuthContext";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</React.StrictMode>,
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</React.StrictMode>
);

View File

@@ -1,95 +1,118 @@
import { useState, useEffect } from "react";
import AddressForm from "../components/AddressForm";
import { getAddresses, createAddress } from "../api/addresses";
import Button from "../components/Button";
import { getAddresses, createAddress, deleteAddress } from "../api/addresses";
export default function Addresses() {
const [addresses, setAddresses] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [addresses, setAddresses] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [deletingId, setDeletingId] = useState(null);
// Load addresses on mount
useEffect(() => {
async function fetchAddresses() {
try {
setLoading(true);
const data = await getAddresses();
setAddresses(data);
} catch (err) {
setError(err.message);
console.error("Failed to fetch addresses:", err);
} finally {
setLoading(false);
}
}
fetchAddresses();
}, []);
// Handle new address submission
async function handleAddressSubmit(data) {
try {
const newAddress = await createAddress(data);
// Append new address to state
setAddresses((prev) => [...prev, newAddress]);
setError(null); // Clear any previous errors
} catch (err) {
setError(err.message);
console.error("Failed to create address:", err);
}
useEffect(() => {
async function fetchAddresses() {
try {
setLoading(true);
const data = await getAddresses();
setAddresses(data);
} catch (err) {
setError(err.message);
console.error("Failed to fetch addresses:", err);
} finally {
setLoading(false);
}
}
return (
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}>
<h1>Tracked Addresses</h1>
fetchAddresses();
}, []);
<div style={{ marginBottom: "2rem" }}>
<AddressForm onSubmit={handleAddressSubmit} />
</div>
async function handleAddressSubmit(data) {
try {
const newAddress = await createAddress(data);
setAddresses((prev) => [...prev, newAddress]);
setError(null);
} catch (err) {
setError(err.message);
console.error("Failed to create address:", err);
}
}
<div>
<h2>Existing Addresses</h2>
{loading && <p>Loading addresses...</p>}
{error && <p style={{ color: "red" }}>Error: {error}</p>}
{!loading && !error && addresses.length === 0 && (
<p style={{ color: "#808080" }}>
No addresses tracked yet. Add one above to get started.
</p>
)}
{addresses.length > 0 && (
<ul style={{ listStyle: "none", padding: 0 }}>
{addresses.map((addr, index) => (
<li
key={addr.id || index}
style={{
padding: "1rem",
marginBottom: "0.5rem",
border: "1px solid #444",
borderRadius: "4px",
backgroundColor: "#333",
}}
>
<div
style={{
fontWeight: "bold",
marginBottom: "0.25rem",
}}
>
{addr.label || "Unlabeled"}
</div>
<div
style={{
fontFamily: "monospace",
fontSize: "1.035rem",
color: "#b3b3b3",
}}
>
{addr.address}
</div>
</li>
))}
</ul>
)}
</div>
</div>
);
async function handleDelete(addressId) {
if (!window.confirm("Delete this address? All associated alert rules and events will also be removed.")) {
return;
}
try {
setDeletingId(addressId);
await deleteAddress(addressId);
setAddresses((prev) => prev.filter((a) => a.id !== addressId));
setError(null);
} catch (err) {
setError(err.message);
console.error("Failed to delete address:", err);
} finally {
setDeletingId(null);
}
}
return (
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}>
<h1>Tracked Addresses</h1>
<div style={{ marginBottom: "2rem" }}>
<AddressForm onSubmit={handleAddressSubmit} />
</div>
<div>
<h2>Existing Addresses</h2>
{loading && <p>Loading addresses...</p>}
{error && <p style={{ color: "red" }}>Error: {error}</p>}
{!loading && !error && addresses.length === 0 && (
<p style={{ color: "#666" }}>
No addresses tracked yet. Add one above to get started.
</p>
)}
{addresses.length > 0 && (
<ul style={{ listStyle: "none", padding: 0 }}>
{addresses.map((addr, index) => (
<li
key={addr.id || index}
style={{
padding: "1rem",
marginBottom: "0.5rem",
border: "1px solid #444",
borderRadius: "4px",
backgroundColor: "#2a2a2a",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<div style={{ fontWeight: "bold", marginBottom: "0.25rem" }}>
{addr.label || "Unlabeled"}
</div>
<div style={{ fontFamily: "monospace", fontSize: "0.9rem", color: "#999" }}>
{addr.address}
</div>
</div>
<Button
onClick={() => handleDelete(addr.id)}
disabled={deletingId === addr.id}
style={{
backgroundColor: deletingId === addr.id ? "#333" : "#dc3545",
color: "white",
border: "none",
cursor: deletingId === addr.id ? "not-allowed" : "pointer",
}}
>
{deletingId === addr.id ? "Deleting..." : "Delete"}
</Button>
</li>
))}
</ul>
)}
</div>
</div>
);
}

View File

@@ -2,84 +2,73 @@ import { useState, useEffect } from "react";
import { getAlertEvents } from "../api/alertEvents";
export default function AlertHistory() {
const [alertEvents, setAlertEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [alertEvents, setAlertEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Fetch alert events on mount
useEffect(() => {
async function fetchAlertEvents() {
try {
setLoading(true);
const data = await getAlertEvents();
setAlertEvents(data);
} catch (err) {
setError(err.message);
console.error("Failed to fetch alert events:", err);
} finally {
setLoading(false);
}
}
fetchAlertEvents();
}, []);
if (loading) {
return <div style={{ padding: "2rem" }}>Loading...</div>;
// Fetch alert events on mount
useEffect(() => {
async function fetchAlertEvents() {
try {
setLoading(true);
const data = await getAlertEvents();
setAlertEvents(data);
} catch (err) {
setError(err.message);
console.error("Failed to fetch alert events:", err);
} finally {
setLoading(false);
}
}
if (error) {
return (
<div style={{ padding: "2rem", color: "red" }}>Error: {error}</div>
);
}
fetchAlertEvents();
}, []);
return (
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}>
<h1>Recent Alerts</h1>
if (loading) {
return <div style={{ padding: "2rem" }}>Loading...</div>;
}
{alertEvents.length === 0 ? (
<p style={{ color: "#808080" }}>No alerts yet</p>
) : (
<ul style={{ listStyle: "none", padding: 0 }}>
{alertEvents.map((event) => (
<li
key={event.id}
style={{
padding: "1rem",
marginBottom: "0.75rem",
border: "1px solid #444",
borderRadius: "4px",
backgroundColor: "#333",
}}
>
<div style={{ marginBottom: "0.5rem" }}>
{event.message}
</div>
{event.address_label && (
<div
style={{
fontSize: "1.035rem",
color: "#808080",
marginBottom: "0.25rem",
}}
>
Address: {event.address_label}
</div>
)}
<small style={{ color: "#b3b3b3" }}>
{formatTimestamp(event.timestamp)}
</small>
</li>
))}
</ul>
)}
</div>
);
if (error) {
return <div style={{ padding: "2rem", color: "red" }}>Error: {error}</div>;
}
return (
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}>
<h1>Recent Alerts</h1>
{alertEvents.length === 0 ? (
<p style={{ color: "#666" }}>No alerts yet</p>
) : (
<ul style={{ listStyle: "none", padding: 0 }}>
{alertEvents.map((event) => (
<li
key={event.id}
style={{
padding: "1rem",
marginBottom: "0.75rem",
border: "1px solid #ddd",
borderRadius: "4px",
}}
>
<div style={{ marginBottom: "0.5rem" }}>{event.message}</div>
{event.address_label && (
<div style={{ fontSize: "0.9rem", color: "#666", marginBottom: "0.25rem" }}>
Address: {event.address_label}
</div>
)}
<small style={{ color: "#999" }}>
{formatTimestamp(event.timestamp)}
</small>
</li>
))}
</ul>
)}
</div>
);
}
// Helper to format timestamp for display
function formatTimestamp(timestamp) {
const date = new Date(timestamp);
return date.toLocaleString();
const date = new Date(timestamp);
return date.toLocaleString();
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,146 +1,143 @@
/**
* Login Page
*
*
* Allows existing users to sign in with email and password
*/
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
export default function Login() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const { login } = useAuth();
const navigate = useNavigate();
async function handleSubmit(e) {
e.preventDefault();
async function handleSubmit(e) {
e.preventDefault();
// Validation
if (!email || !password) {
setError("Please fill in all fields");
return;
}
try {
setError("");
setLoading(true);
await login(email, password);
navigate("/addresses"); // Redirect to main app
} catch (err) {
setError("Failed to log in: " + err.message);
} finally {
setLoading(false);
}
// Validation
if (!email || !password) {
setError('Please fill in all fields');
return;
}
return (
<div
style={{
maxWidth: "400px",
margin: "4rem auto",
padding: "2rem",
border: "1px solid #333",
borderRadius: "8px",
}}
>
<h1 style={{ marginBottom: "2rem", textAlign: "center" }}>
Koin Ping - Login
</h1>
try {
setError('');
setLoading(true);
await login(email, password);
navigate('/addresses'); // Redirect to main app
} catch (err) {
setError('Failed to log in: ' + err.message);
} finally {
setLoading(false);
}
}
{error && (
<div
style={{
padding: "0.75rem",
marginBottom: "1rem",
backgroundColor: "#ff000020",
border: "1px solid #ff0000",
borderRadius: "4px",
color: "#ff6666",
}}
>
{error}
</div>
)}
return (
<div style={{
maxWidth: '400px',
margin: '4rem auto',
padding: '2rem',
border: '1px solid #333',
borderRadius: '8px'
}}>
<h1 style={{ marginBottom: '2rem', textAlign: 'center' }}>
Koin Ping - Login
</h1>
<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: "#242424",
border: "1px solid #444",
borderRadius: "4px",
color: "white",
}}
required
/>
</div>
<div style={{ marginBottom: "1.5rem" }}>
<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: "#242424",
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 ? "Logging in..." : "Log In"}
</button>
</form>
<div style={{ marginTop: "1.5rem", textAlign: "center" }}>
<p style={{ color: "#b3b3b3" }}>
Don't have an account?{" "}
<Link
to="/signup"
style={{ color: "#0066cc", textDecoration: "none" }}
>
Sign up here
</Link>
</p>
</div>
{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: '1.5rem' }}>
<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>
<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 ? 'Logging in...' : 'Log In'}
</button>
</form>
<div style={{ marginTop: '1.5rem', textAlign: 'center' }}>
<p style={{ color: '#999' }}>
Don't have an account?{' '}
<Link
to="/signup"
style={{ color: '#0066cc', textDecoration: 'none' }}
>
Sign up here
</Link>
</p>
</div>
</div>
);
}

View File

@@ -1,188 +1,185 @@
/**
* 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";
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
export default function Signup() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { signup } = useAuth();
const navigate = useNavigate();
const { signup } = useAuth();
const navigate = useNavigate();
async function handleSubmit(e) {
e.preventDefault();
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);
}
// Validation
if (!email || !password || !confirmPassword) {
setError('Please fill in all fields');
return;
}
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>
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
{error && (
<div
style={{
padding: "0.75rem",
marginBottom: "1rem",
backgroundColor: "#ff000020",
border: "1px solid #ff0000",
borderRadius: "4px",
color: "#ff6666",
}}
>
{error}
</div>
)}
if (password.length < 6) {
setError('Password must be at least 6 characters');
return;
}
<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: "#242424",
border: "1px solid #444",
borderRadius: "4px",
color: "white",
}}
required
/>
</div>
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);
}
}
<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: "#242424",
border: "1px solid #444",
borderRadius: "4px",
color: "white",
}}
required
/>
</div>
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>
<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: "#242424",
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: "#b3b3b3" }}>
Already have an account?{" "}
<Link
to="/login"
style={{ color: "#0066cc", textDecoration: "none" }}
>
Log in here
</Link>
</p>
</div>
{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

@@ -1,24 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

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